File size: 11,775 Bytes
4e23b01
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
// src/text-postings.ts
//
// On-disk postings storage for the full-text index (larger-than-RAM).
//
// Each term's postings list (sorted `(docID, freq)` pairs) is stored as one
// append-only record in a flat file. The in-memory term dictionary maps
// `term -> { off, len, df }` and points at these records, so the bulk of the
// index (every (doc, term) pair) lives on disk and is read on demand, while
// only the small dictionary stays in RAM.
//
// Record frame (little-endian), CRC-verified:
//   off        size  field
//    0          2   termLen   uint16
//    2          t   term      utf8
//    2+t        4   df        uint32 (document frequency at build time)
//    6+t        4   payloadLen uint32
//   10+t        p   payload   delta+varint encoded (docID, freq) pairs
//   10+t+p      4   crc32     over [termLen .. payload]
//
// Payload encoding: docIDs are sorted ascending and delta-encoded; both the
// deltas and the freqs are varint-coded (LEB128, unsigned). This is pure JS
// and gives ~5-10x compression for dense docID ranges.

import fs from 'node:fs';
import fsp from 'node:fs/promises';
import path from 'node:path';
import { crc32 } from './crc32.ts';
import { readAtAsync } from './value-reader.ts';

const HEADER_LEN = 2 + 4 + 4; // termLen + df + payloadLen (term is variable)
const CRC_LEN = 4;
// Coalesce record writes into ~1 MiB writev batches; each batch await is also
// the rebuild's event-loop yield point.
const FLUSH_BYTES = 1 << 20;

// ---- varint (unsigned LEB128, uint32) ------------------------------------

function encodeVarintInto(n: number, out: number[]): void {
  n >>>= 0;
  while (n >= 0x80) {
    out.push((n & 0x7f) | 0x80);
    n >>>= 7;
  }
  out.push(n);
}

function decodeVarint(buf: Buffer, cur: { i: number }): number {
  let r = 0;
  let shift = 0;
  for (;;) {
    const b = buf[cur.i++];
    if (b === undefined) throw new Error('postings: truncated varint');
    r |= (b & 0x7f) << shift;
    if ((b & 0x80) === 0) return r >>> 0;
    shift += 7;
    if (shift > 35) throw new Error('postings: varint too long');
  }
}

// ---- posting list codec ---------------------------------------------------

/** Encode a sorted (by docID asc) list of [docID, freq] pairs. Accepts any
 *  sized iterable (an array or a Map's entries view) so a large build never
 *  materializes a per-term copy — a hot term's list can have millions of
 *  entries and spreading it was an OOM vector. */
export function encodePostingList(entries: ReadonlyMap<number, number> | readonly (readonly [number, number])[]): Buffer {
  const count = Array.isArray(entries) ? (entries as readonly unknown[]).length : (entries as ReadonlyMap<number, number>).size;
  const bytes: number[] = [];
  encodeVarintInto(count, bytes);
  let prev = 0;
  for (const [docID, freq] of entries as Iterable<readonly [number, number]>) {
    encodeVarintInto(docID - prev, bytes);
    encodeVarintInto(freq, bytes);
    prev = docID;
  }
  return Buffer.from(bytes);
}

/**
 * Decode a payload back into [docID, freq] pairs (ascending docID). With
 * `maxEntries`, only that many leading pairs are decoded (docIDs ascend, so
 * this is the lowest-docID prefix): a query-time work budget can stop the
 * decode of a hot term's list early instead of always paying its full length.
 */
export function decodePostingList(buf: Buffer, maxEntries?: number): [number, number][] {
  const cur = { i: 0 };
  const count = decodeVarint(buf, cur);
  const n = maxEntries === undefined ? count : Math.min(count, maxEntries);
  const out = Array.from<[number, number]>({ length: n });
  let prev = 0;
  for (let k = 0; k < n; k++) {
    const d = decodeVarint(buf, cur);
    const freq = decodeVarint(buf, cur);
    prev += d;
    out[k] = [prev, freq];
  }
  return out;
}

// ---- record frame codec ---------------------------------------------------

export interface DecodedRecord {
  term: string;
  df: number;
  payload: Buffer;
}

/** Encode one term's record frame (with CRC trailer). */
export function encodeRecord(term: string, df: number, payload: Buffer): Buffer {
  const termBuf = Buffer.from(term, 'utf8');
  if (termBuf.length > 0xffff) throw new RangeError('postings: term too long');
  const bodyLen = HEADER_LEN + termBuf.length + payload.length;
  const body = Buffer.alloc(bodyLen);
  let o = 0;
  body.writeUInt16LE(termBuf.length, o);
  o += 2;
  termBuf.copy(body, o);
  o += termBuf.length;
  body.writeUInt32LE(df >>> 0, o);
  o += 4;
  body.writeUInt32LE(payload.length, o);
  o += 4;
  payload.copy(body, o);
  const crc = crc32(body);
  const out = Buffer.alloc(bodyLen + CRC_LEN);
  body.copy(out, 0);
  out.writeUInt32LE(crc >>> 0, bodyLen);
  return out;
}

/** Decode + CRC-verify a record frame. */
export function decodeRecord(buf: Buffer): DecodedRecord {
  if (buf.length < HEADER_LEN + CRC_LEN) throw new Error('postings: record too short');
  const stored = buf.readUInt32LE(buf.length - CRC_LEN);
  const calc = crc32(buf.subarray(0, buf.length - CRC_LEN));
  if (stored !== calc) throw new Error('postings: record crc mismatch');
  let o = 0;
  const termLen = buf.readUInt16LE(o);
  o += 2;
  if (o + termLen + 4 + 4 > buf.length - CRC_LEN) throw new Error('postings: record term length out of bounds');
  const term = buf.toString('utf8', o, o + termLen);
  o += termLen;
  const df = buf.readUInt32LE(o);
  o += 4;
  const payloadLen = buf.readUInt32LE(o);
  o += 4;
  if (o + payloadLen > buf.length - CRC_LEN) throw new Error('postings: record payload length out of bounds');
  const payload = buf.subarray(o, o + payloadLen);
  return { term, df, payload };
}

// ---- postings file --------------------------------------------------------

/** A pointer to one term's record in the postings file. */
export interface PostingEntry {
  off: number;
  len: number;
  df: number;
}

/**
 * Append-only postings file with synchronous positioned reads. Synchronous I/O
 * is deliberate: `TextIndex.search()` is synchronous (so `db.search()` /
 * `db.query()` keep their sync API), and hot records are served from the OS
 * page cache or the in-memory LRU cache anyway. Rewrites ({@link rebuild}) are
 * the async counterpart — they run in the background of a live database.
 */
export class PostingsFile {
  private fd: number | null = null;

  /** The path this handle reads. Mutable for exactly one caller: stage 5's
   *  generation builder repoints a freshly committed base from the build's
   *  tmp directory to the published generation directory after the atomic
   *  rename (same file, new name — POSIX keeps the fd valid throughout). */
  path: string;

  private constructor(filePath: string) {
    this.path = filePath;
  }

  /**
   * Open an existing postings file for positioned reads. Throws if the file is
   * missing — callers treat a missing file as an empty index. Read-only: the
   * file is only ever rewritten wholesale by {@link rebuild}, so the fd
   * stays valid until the next rebuild (which the caller must close + reopen).
   */
  static open(filePath: string): PostingsFile {
    const pf = new PostingsFile(filePath);
    pf.fd = fs.openSync(filePath, 'r');
    return pf;
  }

  get open(): boolean {
    return this.fd !== null;
  }

  /** Read + decode one term's postings record by dictionary pointer. With
   *  `maxEntries`, only the leading (lowest-docID) part of the list is
   *  decoded — see decodePostingList. */
  read(entry: PostingEntry, maxEntries?: number): [number, number][] {
    if (this.fd === null) throw new Error('postings file is closed');
    const buf = Buffer.alloc(entry.len);
    let got = 0;
    while (got < entry.len) {
      const r = fs.readSync(this.fd, buf, got, entry.len - got, entry.off + got);
      if (r === 0) throw new Error('postings: short read past EOF');
      got += r;
    }
    const rec = decodeRecord(buf);
    return decodePostingList(rec.payload, maxEntries);
  }

  /** Async twin of read() (stage 6): the positioned read runs on the libuv
   *  thread pool, so a cold postings lookup no longer blocks the event loop
   *  on readSync. Purely additive — the synchronous read path is unchanged. */
  async readAsync(entry: PostingEntry, maxEntries?: number): Promise<[number, number][]> {
    if (this.fd === null) throw new Error('postings file is closed');
    const buf = Buffer.alloc(entry.len);
    let got = 0;
    while (got < entry.len) {
      const r = await readAtAsync(this.fd, buf, got, entry.len - got, entry.off + got);
      if (r === 0) throw new Error('postings: short read past EOF');
      got += r;
    }
    const rec = decodeRecord(buf);
    return decodePostingList(rec.payload, maxEntries);
  }

  close(): void {
    if (this.fd !== null) {
      fs.closeSync(this.fd);
      this.fd = null;
    }
  }

  /**
   * Build a fresh postings file from an iterator of `{ term, entries }`
   * (entries must be sorted by docID asc). Writes to `<path>.tmp`, fsyncs, and
   * atomically renames over `<path>`. Returns the new term dictionary plus the
   * file's byte length and whole-file crc32 (stage 5's generation manifest
   * records them; the crc streams along with the write batches, so it costs no
   * extra read). The old file (if any) is replaced only after the new one is
   * fully durable, so a crash mid-build leaves the previous file intact.
   *
   * Async so a large rebuild does not starve the event loop: record writes are
   * coalesced into ~1 MiB writev batches (each batch await is a yield point).
   * The commit section is SYNCHRONOUS — `hooks.beforeRename` (e.g. closing the
   * previous read handle, required on Windows) and the rename itself run as
   * one atomic step, so a reader swaps over without an interleavable gap.
   */
  static async rebuild(
    filePath: string,
    iter: Iterable<{ term: string; entries: ReadonlyMap<number, number> | readonly (readonly [number, number])[] }>,
    hooks: { beforeRename?: () => void } = {},
  ): Promise<{ dict: Map<string, PostingEntry>; bytes: number; crc32: number }> {
    const tmp = filePath + '.tmp';
    const dict = new Map<string, PostingEntry>();
    let off = 0;
    let crc = 0;
    let batch: Buffer[] = [];
    let batchBytes = 0;
    const fh = await fsp.open(tmp, 'w');
    const flushBatch = async (): Promise<void> => {
      if (batch.length === 0) return;
      const buf = Buffer.concat(batch);
      batch = [];
      batchBytes = 0;
      crc = crc32(buf, crc);
      let written = 0;
      while (written < buf.length) {
        const { bytesWritten } = await fh.write(buf, written);
        if (bytesWritten === 0) throw new Error('postings: rebuild write made no progress');
        written += bytesWritten;
      }
    };
    try {
      for (const { term, entries } of iter) {
        const count = Array.isArray(entries) ? (entries as readonly unknown[]).length : (entries as ReadonlyMap<number, number>).size;
        if (count === 0) continue;
        const payload = encodePostingList(entries);
        const rec = encodeRecord(term, count, payload);
        dict.set(term, { off, len: rec.length, df: count });
        batch.push(rec);
        batchBytes += rec.length;
        off += rec.length;
        if (batchBytes >= FLUSH_BYTES) await flushBatch();
      }
      await flushBatch();
      await fh.sync();
    } finally {
      await fh.close();
    }
    hooks.beforeRename?.();
    fs.renameSync(tmp, filePath);
    // Best-effort directory fsync so the rename survives a crash.
    try {
      const dfd = fs.openSync(path.dirname(filePath), 'r');
      try {
        fs.fsyncSync(dfd);
      } finally {
        fs.closeSync(dfd);
      }
    } catch {
      /* some platforms disallow fsync on a directory */
    }
    return { dict, bytes: off, crc32: crc >>> 0 };
  }
}