/** * SegmentManager — coordinates write buffer, segment flush, manifest, and compaction. * * Manifest format (manifest.json): * { version: 2, generation: N, segments: [{id, docCount, totalLen, tier}], * tokenizer: {kind, minLen}, totalDocs, totalLen } * * Manifest is updated atomically: write manifest.tmp → rename to manifest.json. * Readers call `segments()` to get an immutable snapshot of the current reader list; * a concurrent flush or compaction does not affect that snapshot (segments are immutable, * old-segment deletion is deferred until after manifest commit). * * Compaction (LSM size-tiered): * Each segment has a tier. After each flush the new segment starts at tier 0. * chooseCompactionTargets() finds the lowest tier with >= fanout segments and merges them * into a single segment at tier+1. This cascades: after a successful merge, if the * result pushes tier+1 over the fanout, another merge is triggered automatically. * Manual compact() merges everything into a single segment at maxTier+1. */ import { SegmentReader } from "./segment.js"; import type { StorageBackend } from "./storage.js"; /** One entry in the manifest's segment list. */ export interface ManifestSegmentEntry { id: string; docCount: number; totalLen: number; /** Compaction tier. 0 = freshly flushed; N = result of merging fanout tier-(N-1) segments. */ tier: number; } export interface TokenizerConfig { kind: string; minLen: number; } export declare const DEFAULT_FLUSH_THRESHOLD = 1000; /** Thrown when manifest.json exists but cannot be parsed. */ export declare class ManifestCorruptionError extends Error { readonly detail: string; constructor(detail: string); } /** Thrown when manifest.json has a version other than MANIFEST_VERSION. */ export declare class ManifestVersionError extends Error { readonly found: number; readonly expected: number; constructor(found: number, expected: number); } /** Thrown when another process holds the write lock on this index directory. */ export declare class IndexLockedError extends Error { readonly pid: number; constructor(pid: number); } export interface SegmentManagerOpts { backend: StorageBackend; /** Directory on disk — required to enable the advisory .lock file (local FS only). */ dir?: string; /** * Enable multi-writer mode. When set, the manager owns only files whose * names carry this id (e.g. `manifest-.json`, `.lock-`, * `seg--NNNNNN.seg`). Other agents' files in the same directory are * loaded read-only into the reader snapshot. When unset, the manager runs * in legacy single-writer mode using `manifest.json` + `.lock`. */ agentId?: string; /** How many buffered docs trigger an automatic flush. Default: 1000. */ flushThreshold?: number; /** * How many same-tier segments trigger a tier merge (size-tiered compaction). * Default: 4. */ fanout?: number; tokenizer?: TokenizerConfig; /** * Called after the segment file is written but BEFORE the manifest is committed. * Use this to persist any side-data (e.g. docIds mapping) that must be consistent * with the manifest. A crash after this callback but before manifest commit leaves * the side-data ahead of the manifest — safe, because the mapping only grows. */ onBeforeManifest?: () => Promise; } export declare class SegmentManager { private readonly backend; private readonly flushThreshold; private readonly tieredFanout; private readonly tokenizerConfig; private readonly onBeforeManifest; /** Agent id for multi-writer mode, or undefined for legacy single-writer. */ private readonly agentId; /** Absolute path to the .lock file, or null for non-local-FS backends. */ private lockPath; private generation; /** Own (writable) manifest entries. */ private manifestSegments; /** * Own (writable) reader snapshot. Compaction/flush operate on this list. * The public `segments()` snapshot is the concatenation of `readerSnapshot` * and all external-agent readers. */ private readerSnapshot; /** Monotonically increasing segment ID counter (own segments only). */ private nextSegCounter; /** * Read-only views into other agents' manifests, keyed by their agentId. * Used to provide a federated reader view across all writers without * letting compaction/flush touch them. Each entry mirrors * `manifestSegments`/`readerSnapshot` for one external agent. */ private externalManifests; private externalReaders; private externalTotalDocs; private externalTotalLen; /** * Cache of the union (own + external) reader list. Refreshed whenever * own or external state changes. `segments()` returns this directly. */ private unionReaderCache; private buffer; private totalDocs; private totalLen; /** True when an existing manifest.json was loaded (not a fresh index). */ private _manifestLoaded; /** Tokenizer kind recorded in the manifest (null for fresh indexes). */ private _persistedTokenizerKind; /** Tokenizer minLen recorded in the manifest (null for fresh indexes). */ private _persistedTokenizerMinLen; /** Tombstone docIds accumulated since last flush — written into the next segment. */ private pendingTombstones; /** Serialize all state-mutating operations through a promise chain. Reads are lock-free. */ private _lock; private serialize; private constructor(); static open(opts: SegmentManagerOpts): Promise; /** Own agentId (multi-writer mode) or undefined (legacy single-writer). */ get ownerAgentId(): string | undefined; /** Release the advisory lock and flush any pending buffered writes. */ close(): Promise; private acquireLock; private releaseLock; private loadManifest; /** * Scenarios 1 + 2: delete orphaned temp files and unreferenced segment files. * Called after loadManifest so we know which segment IDs are referenced. * * In multi-writer mode, this only touches own-agent files: external agents' * segments stay untouched even if the local view of their manifest is stale. * Temp files are always cleaned regardless of owner — they are never expected * to be valid mid-commit state (the FsBackend's atomic rename means a `.tmp` * file is always orphaned from an aborted write). */ private recoverOrphans; /** * Buffer a document. `terms` is the analyzed term list with per-term frequencies. * Auto-flushes when the buffer reaches `flushThreshold`, then cascades tiered compaction * as long as any tier has >= fanout segments. */ add(docId: number, terms: Array<{ term: string; tf: number; }>): Promise; /** * Mark a document as removed. If it's still in the write buffer, drops it immediately. * Otherwise, queues a tombstone that will be written into the next segment on flush. * Idempotent — removing a doc not in the index is a no-op. */ remove(docId: number): Promise; /** * Flush the current write buffer to a new immutable segment, then atomically * update the manifest. No-op if the buffer is empty AND there are no pending tombstones. */ flush(): Promise; private flushLocked; /** * Merge all current segments into one. Safe to call manually at any time. * No-op if there is zero or one segment. * The merged result is assigned tier = maxExistingTier + 1. */ compact(): Promise; /** * Pick the compaction target: lowest tier with >= fanout segments. * Returns the tier and exactly `fanout` segment indices from that tier (the first fanout * by position, so segments are merged in arrival order). * Returns null if no tier has enough segments. */ private chooseCompactionTargets; /** * Run cascade compaction: repeatedly find the lowest eligible tier and merge it * until no tier has >= fanout segments. */ private cascadeCompactLocked; /** * Merge the segments at the given indices into a single new segment with the * given output tier. Segments not in the merge set are preserved unchanged. * * Algorithm: * 1. Build union tombstone set across merged segments. * 2. Collect surviving docs (original docIds preserved — no renumbering). * 3. K-way merge posting iterators by (term, segIndex) lex order. * 4. Write merged segment atomically (.seg.tmp → rename). * 5. Atomic manifest swap: merged segment + any segments not in the merge set. * 6. Carry forward tombstones that target docs in unmerged segments. * 7. Delete old segment files post-commit. * * DocIds are NEVER renumbered. TermLog assigns globally unique numIds; the * segment format supports sparse uint32 docIds natively (sidecar stores * [(docId, length)] pairs; postings use delta-encoded VByte). Preserving * original docIds is what keeps TermLog.numToStr lookups correct after merge * and ensures tombstones (which store original numIds) always match. */ private tieredCompactLocked; /** Write manifest atomically. FsBackend.writeBlob is itself atomic (tmp+rename). */ private writeManifest; /** * Scan for other agents' manifests in the same directory and open their * segments read-only. Called once at open() in multi-writer mode and again * by `refresh()` to pick up subsequent commits from other agents. * * Quiet on errors: a missing or malformed external manifest is skipped with * a console.warn rather than aborting the open. The own-agent's view stays * fully functional even if external manifests cannot be loaded. */ private loadExternalManifests; /** Rebuild the cached union reader list. Called whenever own or external state changes. */ private refreshUnion; /** * Re-scan the directory for new commits from other agents. Safe to call * concurrently with own writes (it does not touch own state). No-op in * legacy single-writer mode. */ refresh(): Promise; /** * Return an immutable snapshot of the current segment readers (own + external). * Callers may hold this snapshot across a concurrent flush or compaction — * the snapshot is unaffected (old readers hold their own Buffer references). * * Do NOT mutate the returned array. It is the live cache reference; * mutations would corrupt internal state. Treat it as readonly. */ segments(): readonly SegmentReader[]; /** The manifest generation counter — incremented on every successful flush or compact. */ commitGeneration(): number; /** Number of docs currently in the write buffer (not yet flushed). */ bufferedCount(): number; /** Total docs across own + external agents (the BM25 corpus size). */ get indexTotalDocs(): number; /** Total token length across own + external agents (the BM25 average-length denominator). */ get indexTotalLen(): number; /** Own-agent doc count (excludes external agents). */ get ownTotalDocs(): number; /** Own-agent total token length (excludes external agents). */ get ownTotalLen(): number; /** The tokenizer kind as recorded in the persisted manifest (null for fresh index). */ get persistedTokenizerKind(): string | null; /** The tokenizer minLen as recorded in the persisted manifest (null for fresh index). */ get persistedTokenizerMinLen(): number | null; /** True when an existing manifest was loaded (not a fresh index). */ get manifestLoaded(): boolean; /** * Approximate in-memory footprint of the SegmentManager in bytes. * * Accounts for: * - Each SegmentReader's postingsRegion Buffer + docLenArr Uint32Array + * tombstones Uint32Array + TermDict entries array (estimated at * ~(termCount × 80) bytes — one JS object + two numbers + average 8-char term). * - Write buffer: each BufferedDoc holds a terms array; estimated at * ~(termCount_per_doc × 64) bytes per doc (term string + number pair). * - pendingTombstones Set: ~(size × 40) bytes. * * The formula is intentionally conservative (underestimates V8 object overhead) * so callers treat it as a lower bound, not an exact measurement. */ estimatedBytes(): number; } //# sourceMappingURL=manager.d.ts.map