Type: design Status: Active Systems: Sinks, Cache Author: neutral Date: 2026-06-25 Generated-by: neutral Related: LLP 0039, LLP 0013, LLP 0014
Technical design covering the request in LLP 0039: give the central forward sink and the core blob sink a per-sink watermark so each tick reads and ships only rows added since its last successful export, surviving both retention front-prunes and compaction generation swaps.
@ref LLP 0039: incremental sink reads requirement
Three facts from the code shape every decision below:
-
The driver re-hands the whole partition set every tick, with a fresh batch id.
createSinkDriver.runSink(src/core/sinks/driver.js) callsdiscoverReadyPartitions(every partition for the sink's datasets, scope limit 1000) and mintsnextBatchId = instance-<iso>-<seq>per tick. So nothing batch-id-keyed can be a cross-tick cursor: a cross-tick cursor must be keyed by(sink instance, partition)and persist outside the batch. -
The cache rewrites partitions out from under any positional reference.
- Retention (
src/core/cache/retention.js) position-deletes the oldest rows of each source table viaicebergDelete, producing a new delete snapshot on the same linear lineage. Data files and their physical row positions are untouched; the visible set shrinks from the front. - Compaction (
src/core/cache/maintenance.js→compactSourceTable) rewrites the whole partition into a brand-new table directory (table-<seq>/, orepoch=<N+1>/on the legacy layout) with a fresh Iceberg snapshot lineage, then repointscursor.json.tableDir. Iceberg stores absolutefile://URLs, so the directory can't be renamed and the new lineage has no ancestry link to the old one. Row values are copied verbatim; dedup only drops exact_hyp_cache_row_idmatches.
The logical partition directory (
<cacheRoot>/datasets/<dataset>/source=<source>/, i.e.partition.tablePath) is stable across both: only thetableDirinside it changes on compaction. - Retention (
-
Every cache row already passes one kernel write chokepoint.
decorateRow(src/core/cache/streaming-reader.js) stamps_hyp_cache_row_id(a content SHA-256, preserved through compaction and used as the compaction dedup key) and_hyp_cache_batch_idon every flushed row, and both are listed inINTERNAL_FIELDSso they never leak to query output or toreadRowsconsumers.
Two hard constraints from the spec, plus one cost constraint the acceptance criteria add:
- (A) survives a retention front-prune,
- (B) survives a compaction generation swap,
- (C) a tick reads ≈ N rows for N new rows, bounded reads, not just bounded sends (acceptance: "reads/sends ≈N rows, independent of total partition size").
What it actually is today: the iceberg sink's marker
(format-iceberg/src/state.js, markerSubsumedBySnapshot) is keyed by
(prefix, sink, dataset, batchId) and records the destination archive
snapshot id; the ancestry walk proves "this batch already committed into the
archive" so a respool doesn't double-append. It is destination-side,
batch-id-scoped retry idempotency, not a source-read cursor.
Why it does not generalize to the forward/blob source-read problem:
- It is batch-id-keyed, and batch ids are minted fresh per tick (fact 1), so it can never match across ticks: exactly the cross-tick reuse we need.
- Even reframed as "remember the last source snapshot exported and
incrementally scan files appended since it": that survives retention (A,
delete-only, linear lineage) but fails (B). Compaction starts a fresh
lineage, so the recorded snapshot id is absent from the new table's metadata;
markerSubsumedBySnapshotwould correctly judge it stale and fall back to a full re-read after every compaction, and compaction fires on routine file-count thresholds. Rejected on (B).
Add a kernel-assigned, append-monotonic int64 column _hyp_ingest_seq,
stamped at the same chokepoint as _hyp_cache_row_id (fact 3) and carried as an
ordinary internal (hidden) Iceberg column. The watermark for a
(sink, partition) is the highest seq it has durably exported; an incremental
read yields rows with seq > watermark.
- (A) Retention deletes the lowest-seq rows, all already
< watermark; a strict> watermarkfilter never looks at them. No skip, no dup. Pass. - (B) The seq is a row-resident value, so the compaction rewrite copies
it verbatim into the new generation. The watermark is keyed by the logical
partition path (stable; fact 2), not by snapshot id or physical position, so
it reads straight through the
tableDirswap. Pass. - (C) A strict
seq > watermarkpredicate emits only the N_new rows. As built, the predicate is a yielded-row filter over the partition's current snapshot (see §2): the scan still visits every surviving row, so a tick reads ≈ the surviving-partition size, not ≈N_new. Retention front-prunes already-exported rows, so "surviving" is bounded and shrinks, reads do not grow with total history, but the stronger O(N_new) bound is not yet delivered. A numericmin/maxcolumn statistic can in principle let icebird skip whole data files whosemax(seq) ≤ watermark(seq correlates with append order, and numeric stats dodge the string-stats truncation hazard); landing that null-aware file/row-group pushdown is a follow-up optimization that layers on without changing the read contract. Partial (bounded by surviving partition, pending icebird pushdown).
It is the only shape that meets (A) and (B) and is the right basis for (C):
row-resident (survives every cache rewrite), totally ordered (a strict > is
exactly-once), and stats-prunable once icebird gains null-aware seq pushdown.
Persist the set of exported row ids; skip any row already in it.
- Correct across (A) and (B): the row id is preserved through compaction.
- But fails (C): to decide which ids are new you must scan the whole partition every tick (O(N) read per tick → O(N·K) cumulative; only the send shrinks), and the id set grows unboundedly (needs GC coupled to retention). Rejected on cost.
Recommend Candidate B: a monotonic _hyp_ingest_seq watermark. The
mechanism iceberg proves (snapshot ancestry) is destination-side, batch-scoped,
and does not survive a source compaction; the content-addressed set is correct
but cannot meet the bounded-read goal even in principle. A row-resident,
totally-ordered, stats-prunable sequence clears (A) and (B) outright and is the
only shape that can also reach the O(N_new) bound (C) once icebird gains
null-aware seq pushdown; until then it bounds reads by the surviving-partition
size (full scan emitting N_new), which is the same correctness with a weaker
cost guarantee.
QueryStorageService.readRows today
(hypaware-plugin-kernel-types.d.ts, impl in src/core/cache/storage.js):
readRows(tablePath: string, columns?: string[]): AsyncIterable<Record<string, unknown>>Extend with an optional, back-compatible third argument and add a cursor-aware sibling:
interface SinkContinuation { v: 1; seq: string } // int64 as decimal string
interface ReadRowsOptions { since?: SinkContinuation }
readRows(tablePath: string, columns?: string[], opts?: ReadRowsOptions):
AsyncIterable<Record<string, unknown>>
// cursor-aware surface for sinks that must advance a watermark
readRowsSince(tablePath: string, opts: { since?: SinkContinuation, columns?: string[] }):
AsyncIterable<{ row: Record<string, unknown>, after: SinkContinuation }>- Back-compat:
optsabsent ⇒ identical to today (full scan). Every current caller,central/sink.js,local-fs,s3,format-iceberg,ai-gatewayprojector & dataset,vector-search, backfill, and the querydataSourceForTablepath, passes nothing and is byte-for-byte unchanged. sincesemantics: yields only rows with_hyp_ingest_seq > since.seq. The token is opaque and versioned so the mechanism can change later without invalidating persisted watermarks;seqis a decimal string to dodge bigint/JSON hazards (the column is int64).- Why a sibling, not just a filter:
_hyp_ingest_seqis anINTERNAL_FIELDstripped from output, so a sink readingreadRowscannot learn the high-water seq to persist.readRowsSincepairs each clean (internal-stripped) row with theaftertoken to store once this row is durably shipped. Internally both share one scan; the kernel reads_hyp_ingest_seq, emits the token, then strips it, so the seq never reaches the wire payload or query results. - Implementation point: both route through
scanRowsFromTable(src/core/cache/iceberg/store.js), which already projects columns over the latest snapshot;sincebecomes a predicate (ideally pushed to icebird as aseq > xfile/row-group skip, falling back to a yielded-row filter).
-
One watermark per
(sink instance, partition). Stored under the sink plugin'sPluginPaths.stateDir(the kernel already threadsctx.pathsto request sinks and to blob writer/destination ctx insrc/core/sinks/materialize.js):<stateDir>/watermarks/<dataset>/<partition-key>.json { "v": 1, "continuation": { "v": 1, "seq": "<int64>" }, "exportedRowCount": <n>, "updatedAt": "<iso>" } -
partition-keyis the LOGICAL partition identity, derived frompartition.tablePathrelative tocacheRoot(thesource=<source>segments), never the physicaltableDir. This is the hinge of constraint (B): the logical path is stable, thetableDiris not. Segments are sanitized as instate.js'ssanitizeSegment. -
Local for both sinks. The watermark tracks progress reading the local cache, so it lives on local disk even when the blob destination is S3. (The iceberg sink's destination-side marker is a separate concern and stays where it is.)
-
Advances only after a successful, durable export, ONCE per partition:
- forward sink: after every chunk of the partition has been acked
(
202/2xx), advance to the partition's high-wateraftertoken (the last row read). The advance is end-of-partition, never per-chunk: the scan is not seq-ordered (§4 risk #3), soafteris a running max, a chunk that physically precedes a lower-seq chunk would, if checkpointed, advance the watermark past rows still un-acked in a later chunk and skip them forever on a between-chunk failure. A partial partition therefore never checkpoints; a crash/failure re-reads the whole partition next tick and the server ledger dedupes the already-acked prefix (stable per-chunk batch ids, §4). - blob sink: after the encoded blob is durably PUT, advance to the
aftertoken of the last row in that blob.
- forward sink: after every chunk of the partition has been acked
(
-
Crash-safety: atomic write-rename (the
writeCursor/writeProgressidiom). Invariant: ship/PUT first, advance watermark second. A crash between the two re-exports a bounded suffix next tick (at-least-once); §5 shows dedup makes it exactly-once.
Forward sink (hypaware-core/plugins-workspace/central/src/sink.js,
forwardPartition): load the continuation for (instance, partition-key);
replace
for await (const row of storage.readRows(tablePath)) { … }with
for await (const { row, after } of storage.readRowsSince(tablePath, { since })) { … }keep the existing MAX_CHUNK_ROWS / MAX_CHUNK_BYTES chunking and the
backpressure/Retry-After loop (LLP 0014) untouched; once every chunk is
acked, persist the partition's high-water after as the new watermark
(end-of-partition, never per-chunk: see §3). The
batchIdForChunk(signal, tablePath, chunkStartSeq, body) derivation keys each
chunk by the seq it starts after (the prior chunk's after, or since for
the first chunk) plus its bytes: not a per-tick chunkIndex ordinal. Keying
on the start seq keeps an id stable across a watermark advance: a respool
re-reads from the (unchanged) watermark, reproduces the same [startSeq, body],
and so the same id, so the server ledger (server LLP 0001) dedupes the redelivered
prefix. An ordinal would re-number the suffix from 0 after an advance and mint a
fresh id for an already-stored chunk, double-storing it. With nothing new, the
since filter yields zero rows → zero chunks → 0 bytes.
Core blob sink (src/core/sinks/materialize.js → destination
local-fs/s3 index.js → src/core/sinks/encoder.js): the destinations feed
storage.readRows(partition.tablePath) into encodePartition. Switch them to
load the continuation and read via readRowsSince({ since }), feeding the clean
row stream into the unchanged encoder.encodePartition contract; after the blob
is PUT, advance the watermark to the blob's last after. An empty new-row set
writes no blob (skip, 0 bytes). The output filename embeds the
[sinceSeq, lastSeq] range so a crash-retry re-PUTs the same object key
(idempotent overwrite): the blob sink's stand-in for the server ledger.
The format-iceberg sink is unchanged (it already has destination-side
idempotency); it may later adopt the same source watermark to bound its reads,
but that is out of scope. The server idempotency ledger is retained as the
in-flight retry net (spec requirement); it now backstops only a bounded suffix
instead of the whole partition.
- No new rows.
readRowsSince(since=watermark)yields nothing → forward sends 0 chunks; blob writes no file. Watermark unchanged. ≈0 bytes. (acceptance 1) - N new rows. Exactly the rows with
seq > watermarkare yielded and shipped (≈N sent, independent of total history). As built the read scans the surviving partition and filters to N (a yielded-row filter, §2/§1(C)); file-levelmax(seq) ≤ watermarkpruning to make the read ≈N is a pending icebird-pushdown optimization. The watermark advances tomax(seq). (acceptance 2: sends bounded now; reads bounded by surviving partition, O(N_new) reads pending pushdown) - Across a retention prune. The prune deletes only rows with
seqfar below the watermark (already exported). A> watermarkread is blind to them: no skip, no dup. (acceptance 3a) - Across a compaction generation swap. The seq rides the row into the new
tableDir; the watermark is keyed by the stable logical partition path; the read filters> watermarkover the new generation and yields the same survivors. Compaction dedup can only remove exact_hyp_cache_row_idduplicates, which were never two distinct un-exported rows. No skip, no dup. (acceptance 3b) - Mid-batch retry. The watermark advances only after a durable export
completes (forward: every chunk acked, end-of-partition; blob: blob PUT), so a
crash leaves it at the last fully exported seq. The next tick re-reads from
there: the forward sink re-streams the whole partition and the server ledger
dedupes the already-acked chunks (stable
chunkStartSeqbatch ids); the blob sink re-PUTs the same[sinceSeq,lastSeq]object key (idempotent overwrite). (acceptance 4)- Known gap (not yet closed): if a chunk/blob commits, its watermark write
is then lost (crash in the PUT→advance window), and new rows append before
the retry, the resumed export reads from the old watermark and the in-flight
unit grows past what committed: the forward sink's last partial chunk gets a
new body → new batch id → the server stores it alongside the committed one;
the blob sink's range grows →
S.<since>-<lastA>andS.<since>-<lastB>both land. The dedup nets (server ledger / seq-range filename) assume the retry reproduces the identical unit, which later arrivals break. Closing this needs the in-flight upper bound persisted as a pre-commit intent so the retry caps its read at the committedlastSeq(a readuntilbound + a pending intent record). Tracked as risk #7 (§6).
- Known gap (not yet closed): if a chunk/blob commits, its watermark write
is then lost (crash in the PUT→advance window), and new rows append before
the retry, the resumed export reads from the old watermark and the in-flight
unit grows past what committed: the forward sink's last partial chunk gets a
new body → new batch id → the server stores it alongside the committed one;
the blob sink's range grows →
- Pre-upgrade rows have a null
_hyp_ingest_seq. (RESOLVED.) A sink with no durable watermark treats null-seq rows as "new" (one full backlog export,readRowsSince/readRowsdefaultincludeLegacy: true); once it has a watermark it passesincludeLegacy: false, so the backlog is re-exported exactly once instead of on every tick (which also dodges duplicates after a compaction reorders the body). This is safe because no NEW null-seq row can appear post-upgrade,decorateRowstamps a real seq on every flushed row, so a row that is null after the first export is always one already shipped. - Seq allocator durability is the most delicate piece.
decorateRowruns in the spool reader, which resumes from a byte offset (streamFlushFile/writeProgress). The monotonic counter (e.g.nextSeqreserved in blocks incursor.json) must never go backwards across a crash/resume: a new row stamped≤ watermarkwould be skipped forever. Duplicate seqs across a crash boundary are tolerable (strict>plus row-id dedup); regressions are not. The allocator that satisfies this is specified in §7. - Interleaving weakens file-level pruning. Multiple sources/late arrivals in one partition can scatter seq ranges across files, so a tick reads more than N (still correct, just less cheap). Acceptable; flagged.
- New internal int64 column is an additive cache-schema change (LLP 0029
path): must be nullable, must not perturb partition-spec stability, and must
be added to
INTERNAL_FIELDSso it never leaks to query output or the forward NDJSON payload. - Retention-vs-export coupling (the open question in
LLP 0013): a durable
per-sink watermark finally makes "evict only past the minimum exported
watermark" (
wait_for_sink_ack) implementable. This design does not change retention: a lagging sink can still have un-exported rows pruned (data loss). Decide whether to wire ack-coupled eviction alongside this. - Watermark vs. driver outbox. (TESTED.) The driver's outbox respool and
the watermark are two retry mechanisms; they compose, the outbox replays the
partition, the watermark bounds the replay to the un-acked work, and the
end-to-end acceptance suite exercises it (
sink-incremental-acceptance). - Watermark-write-lost + new-arrivals duplication. (OPEN: escalated.) The
narrow exactly-once gap detailed in §5: a unit commits, its watermark write is
lost in the commit→advance window, and new rows append before the retry, so
the resumed in-flight unit grows past what committed and the dedup net (server
ledger / seq-range filename) no longer recognizes it. The common in-flight
retry (no new arrivals) IS covered. Closing this needs a pre-commit intent: a
read
untilupper bound plus a persisted intent record so the retry caps its read at the committedlastSeqand reproduces the identical body/key. This is a design-level addition to the watermark contract (both sinks; the forward sink's streaming chunking makes the upper bound awkward) and is deferred to a follow-up rather than patched half-way under the exactly-once claim.
Refines risk #2. The _hyp_ingest_seq counter is cache-global, persisted at
<cacheRoot>/_hyp_ingest_seq.json ({ v, nextSeq, updatedAt }, atomic
write-rename): not in a per-partition cursor.json. Two reasons:
decorateRowruns before rows are grouped intosource=<…>destination partitions (fact 3 + the flush re-grouping inappendChunk), so at the stamp point there is no destination partition cursor to write.- Two distinct spool table paths, live capture (
datasets/<ds>) andbackfill(datasets/<ds>/<backfill-seg>), flush into the same destination partition. Only a single cache-wide counter guarantees every partition observes a strictly-increasing seq subsequence; a per-partition counter would interleave two independent sequences and could regress.
Reservation is block-wise (createIngestSeqAllocator, default block 1024):
a whole block is durably reserved (persisted nextSeq advanced) before any
seq in it is stamped onto a row. A crash therefore abandons at most the unused
tail of the current block (a harmless gap in the sequence) and can never
re-issue a seq ≤ one already stamped. Seqs start at 1, so a null/0 watermark
("exported nothing") is < every real row's seq. In-process concurrency (two
flushes sharing the one allocator) is serialized through a promise-chain mutex;
cross-process concurrent flush of one cache is out of scope (the daemon owns the
cache, matching the existing single-writer write-rename idiom).