Files
rdb/lib/rdb/fast.rb
T
wkh c2ea36c6d6 Schneller Start ohne RubyGems, Rational statt BigDecimal, Helfer in Rdb::Helpers
bin/rdb läuft mit --disable-gems (ca. 85 statt 170 ms je Befehl), dafür
rechnen Summen und /act mit Rational. Hilfsfunktionen und Konstanten liegen
nicht mehr auf Object, Kopf-/Strichzeile und awk-Aufruf an einer Stelle.
Tests laufen parallel: 21 statt 70 Sekunden.
2026-10-30 10:01:36 +01:00

404 lines
13 KiB
Ruby

# frozen_string_literal: true
module Rdb
# Fast-access methods for tables. Rows are found by byte offset, so a record
# can be blanked or replaced in place without rewriting the file.
#
# -ms sequential (no index file) -mb binary search on a sorted table
# -mr record numbers table.r -mi inverted index table.i
# -mh hash table.h
#
# table.r and table.h hold fixed-width entries (a head line, a dash line,
# then "%8d\n" per entry), so one entry can be rewritten in place.
module Fast
ENTRY_WIDTH = 9
SUFFIX = { 'h' => '.h', 'i' => '.i', 'r' => '.r' }.freeze
FIXED_HEAD = "Offset\n------\n"
Row = Struct.new(:from, :to, :line) do
def cells
@cells ||= line.split("\t", -1)
end
def blank?
line.delete("\t ").empty?
end
end
Hit = Struct.new(:row, :xfrom, :xto)
# A table file. The head is read at once; the rows only when a method
# needs all of them (sequential search, -mb, update.rdb). A lookup through
# an index reads just its row, by byte offset.
class Data
attr_reader :path, :head
def initialize(path)
@path = path
File.exist?(path) or Rdb.die("#{path}: no such table")
File.open(path, 'rb') do |f|
first = f.gets.to_s
Rdb.die("#{path}: is a list; fast access needs a table") if first == "\n"
@head = first.chomp("\n").split("\t", -1).map { |h| h.dup.force_encoding(Encoding::UTF_8).strip }
@body = f.pos
second = f.gets
@body = f.pos if second && Table.dash_line?(second.chomp("\n").split("\t", -1))
end
end
def rows
@rows ||= begin
bytes = File.binread(path).byteslice(@body..).to_s
off = @body
bytes.each_line("\n").map do |l|
row = Row.new(off, off + l.bytesize, l.chomp("\n"))
off += l.bytesize
row
end
end
end
def live
@live ||= rows.reject(&:blank?)
end
def column(name)
Rdb.column_index(@head, name) || Rdb.die("#{path}: no column named '#{name}'")
end
def row_at(offset)
return @by_from[offset] if @rows && (@by_from ||= @rows.to_h { |r| [r.from, r] })
return nil if offset < @body
File.open(path, 'rb') do |f|
f.seek(offset)
line = f.gets or return nil
Row.new(offset, offset + line.bytesize, line.chomp("\n"))
end
end
end
module_function
def index_path(path, method)
SUFFIX[method] ? path + SUFFIX[method] : nil
end
def key_of(cells, idx)
cells[idx].to_s
end
def norm(str, fold)
s = str.to_s.strip
fold ? s.downcase : s
end
def fmt_entry(num)
format("%8d\n", num)
end
# FNV-1a over the key without blanks. (A plain sum of the bytes put
# 3000 five-digit numbers into 30 slots.)
def hash_slot(slots, key)
key.b.delete(" \t").each_byte.reduce(0xcbf29ce484222325) { |h, b| ((h ^ b) * 0x100000001b3) & 0xffffffffffffffff } % slots
end
def live_rows(data)
data.live
end
# ---- building indexes -------------------------------------------------
def build(path, method, keycol, fold: false)
data = Data.new(path)
rows = live_rows(data)
case method
when 'r'
Rdb.write_file(path + '.r', FIXED_HEAD + rows.map { |r| fmt_entry(r.from) }.join)
when 'i'
k = data.column(keycol)
ents = rows.map { |r| [r.cells[k].to_s, r.from] }
ents = ents.sort_by { |key, off| [fold ? key.strip.downcase : key.strip, off] }
Rdb.write_file(path + '.i', "#{keycol}\tOffset\n#{'-' * keycol.size}\t------\n" + ents.map { |key, off| "#{key}\t#{off}\n" }.join)
when 'h'
k = data.column(keycol)
slots = [rows.size * 2, 2].max
table = Array.new(slots, 0)
rows.each { |r| table[free_slot(table, hash_slot(slots, r.cells[k].to_s))] = r.from }
Rdb.write_file(path + '.h', FIXED_HEAD + table.map { |o| fmt_entry(o) }.join)
when 'b'
k = data.column(keycol)
body = live_rows(data).sort_by { |r| r.cells[k].to_s.strip }.map { |r| r.line + "\n" }
Rdb.write_file(path, Table.head_text(data.head) + body.join)
end
end
def free_slot(table, start)
i = start
i = (i + 1) % table.size while table[i] != 0 && table[i] != -1
i
end
# ---- searching ----------------------------------------------------------
# One entry of a fixed-width index (.r, .h) without reading the rest.
def fixed_count(path)
[(File.size(path) - FIXED_HEAD.bytesize) / ENTRY_WIDTH, 0].max
end
def fixed_entry(io, i)
io.seek(FIXED_HEAD.bytesize + ENTRY_WIDTH * i)
io.read(ENTRY_WIDTH).to_i
end
# Entries of an inverted index (.i) from the first key that is not below
# target, found by bisecting on byte positions. Yields hashes like
# read_inverted does.
def each_inverted_from(path, below)
File.open(path, 'rb') do |f|
f.gets
f.gets
lo = f.pos
hi = f.size
while lo < hi
mid = (lo + hi) / 2
if mid == lo
at = lo
else
f.seek(mid - 1)
f.gets
at = f.pos
end
if at >= hi
hi = mid
next
end
f.seek(at)
line = f.gets
if below.call(line.split("\t", 2)[0])
lo = at + line.bytesize
else
hi = at
end
end
f.seek(lo)
loop do
from = f.pos
line = f.gets or break
key, off = line.chomp("\n").split("\t", 2)
yield({ from: from, to: from + line.bytesize, key: key, offset: off.to_i })
end
end
end
def read_fixed(path)
File.binread(path).byteslice(FIXED_HEAD.bytesize..).to_s.lines.map { |l| l.to_i }
end
def read_inverted(path)
off = 0
ents = []
File.binread(path).each_line("\n").with_index do |l, n|
ents << { from: off, to: off + l.bytesize, key: l.chomp("\n").split("\t", 2)[0], offset: l.chomp("\n").split("\t", 2)[1].to_i } if n >= 2
off += l.bytesize
end
ents
end
def matches?(cell, key, partial, exact)
if partial
norm(cell, true).start_with?(norm(key, true))
elsif exact
cell == key
else
cell.strip == key.strip
end
end
# Find rows for one key. method is one of s b h i r; keycols are column names.
def search(data, method, keycols, key, first: false, partial: false, exact: false)
path = data.path
idxs = keycols.map { |c| data.column(c) }
keyvals = Array(key)
hits = []
case method
when 'b'
rows = live_rows(data)
k = idxs[0]
lo = rows.bsearch_index { |r| (partial ? norm(r.cells[k], true)[0, keyvals[0].strip.size] : r.cells[k].strip) >= (partial ? norm(keyvals[0], true) : keyvals[0].strip) }
if lo
rows[lo..].each do |r|
break unless matches?(r.cells[k], keyvals[0], partial, exact)
hits << Hit.new(r, 0, 8)
break if first
end
end
when 'r'
ip = path + '.r'
n = keyvals[0].to_i
if n.positive? && n <= fixed_count(ip)
off = File.open(ip, 'rb') { |f| fixed_entry(f, n - 1) }
r = data.row_at(off) and hits << Hit.new(r, FIXED_HEAD.bytesize + ENTRY_WIDTH * (n - 1), FIXED_HEAD.bytesize + ENTRY_WIDTH * n)
end
when 'i'
target = partial ? norm(keyvals[0], true) : keyvals[0].strip
below = ->(key) { (partial ? norm(key, true)[0, keyvals[0].strip.size] : key.strip) < target }
each_inverted_from(path + '.i', below) do |e|
break unless matches?(e[:key], keyvals[0], partial, exact)
r = data.row_at(e[:offset])
hits << Hit.new(r, e[:from], e[:to]) if r
break if first
end
when 'h'
ip = path + '.h'
slots = fixed_count(ip)
File.open(ip, 'rb') do |f|
i = hash_slot(slots, keyvals[0])
slots.times do
o = fixed_entry(f, i)
break if o.zero?
if o.positive? && (r = data.row_at(o)) && matches?(r.cells[idxs[0]], keyvals[0], false, exact)
hits << Hit.new(r, FIXED_HEAD.bytesize + ENTRY_WIDTH * i, FIXED_HEAD.bytesize + ENTRY_WIDTH * (i + 1))
break if first
end
i = (i + 1) % slots
end
end
else
live_rows(data).each do |r|
c = r.cells
next unless idxs.each_with_index.all? { |ci, n| matches?(c[ci].to_s, keyvals[n].to_s, partial, exact) }
hits << Hit.new(r, 0, 8)
break if first
end
end
hits
end
# ---- changing rows --------------------------------------------------------
def blanked(line)
line.gsub(/[^\t]/, ' ')
end
def splice(path, from, to, text)
b = File.binread(path)
Rdb.write_file(path, b.byteslice(0, from) + text + b.byteslice(to..).to_s)
end
# Remove a record's entry from the secondary index for method.
def index_remove(path, method, row_from)
case method
when 'r'
ip = path + '.r'
ents = read_fixed(ip)
ents.delete(row_from)
Rdb.write_file(ip, FIXED_HEAD + ents.map { |o| fmt_entry(o) }.join)
when 'h'
ip = path + '.h'
ents = read_fixed(ip).map { |o| o == row_from ? -1 : o }
Rdb.write_file(ip, FIXED_HEAD + ents.map { |o| fmt_entry(o) }.join)
when 'i'
ip = path + '.i'
e = read_inverted(ip).find { |x| x[:offset] == row_from }
splice(ip, e[:from], e[:to], '') if e
end
end
def index_add(path, method, row_from, cells, data_head, keycol)
case method
when 'r'
Rdb.write_file(path + '.r', File.binread(path + '.r') + fmt_entry(row_from))
when 'i'
ip = path + '.i'
name = File.binread(ip).lines.first.split("\t")[0]
key = cells[data_head.index(name)].to_s
ents = read_inverted(ip)
pos = ents.find { |e| e[:key].strip > key.strip }
line = "#{key}\t#{row_from}\n"
if pos then splice(ip, pos[:from], pos[:from], line)
else Rdb.write_file(ip, File.binread(ip) + line)
end
when 'h'
ip = path + '.h'
ents = read_fixed(ip)
if ents.any? { |o| o <= 0 }
key = cells[data_head.index(keycol)].to_s
ents[free_slot(ents, hash_slot(ents.size, key))] = row_from
Rdb.write_file(ip, FIXED_HEAD + ents.map { |o| fmt_entry(o) }.join)
else
build(path, 'h', keycol)
end
when 'b'
build(path, 'b', keycol)
end
end
def append_row(path, cells, method, keycol)
Rdb.check_cells!(cells)
line = cells.join("\t")
b = File.binread(path)
b += "\n" unless b.empty? || b.end_with?("\n")
from = b.bytesize
Rdb.write_file(path, b + line + "\n")
index_add(path, method, from, cells, Data.new(path).head, keycol) if SUFFIX[method] || method == 'b'
from
end
def delete_hit(path, hit, method)
r = hit.row
splice(path, r.from, r.to, blanked(r.line) + "\n")
index_remove(path, method, r.from)
end
# Put cells where the record at hit was. If the new record is too long
# even after trimming blanks, append it (unless append is false).
def replace_row(path, from, to, cells, method, xfrom, xto, keycol, allow_append: true)
Rdb.check_cells!(cells)
room = to - from - 1
line = cells.join("\t")
line = cells.map(&:strip).join("\t") if line.bytesize > room
if line.bytesize <= room
splice(path, from, to, line + ' ' * (room - line.bytesize) + "\n")
return from
end
return nil unless allow_append
old = File.binread(path).byteslice(from, room)
splice(path, from, to, blanked(old) + "\n")
newfrom = append_row(path, cells.map(&:strip), 'x', keycol)
reindex_entry(path, method, xfrom, xto, newfrom, cells, keycol)
newfrom
end
def reindex_entry(path, method, xfrom, xto, newfrom, cells, keycol)
case method
when 'r', 'h'
splice(path + SUFFIX[method], xfrom, xto, fmt_entry(newfrom))
when 'i'
ip = path + '.i'
name = File.binread(ip).lines.first.split("\t")[0]
head = Data.new(path).head
splice(ip, xfrom, xto, "#{cells[head.index(name)].to_s.strip}\t#{newfrom}\n")
end
end
# Read keys: a table of keys, or one key per line.
def read_keys(io, ncols)
text = io.read.to_s
return [] if text.empty?
lines = text.lines.map { |l| l.chomp("\n") }
if Table.headed?(lines)
lines.drop(2).reject(&:empty?).map { |l| l.split("\t", -1).first(ncols) }
else
lines.reject(&:empty?).map { |l| l.split("\t", -1).first(ncols) }
end
end
end
end