Type: Spec Status: Active Systems: Sinks Author: Phil / Claude Date: 2026-06-01 Related: LLP 0010, LLP 0013, LLP 0025
Extended-by: LLP 0305 (eligible open datasets use catalog registration and begin at a fresh local high-water); LLP 0307 (durable dataset rollout distinguishes existing partitions from future ones)
Export targets and the export driver. Decomposed from
hypaware-design.md(Sinks).
Sinks receive data out of the cache (LLP 0013) on a configurable schedule and ship it somewhere durable. The two are decoupled: sources write to the cache only; the export pipeline reads the cache and pushes to sinks. The cache is the recent-query story; sinks are the long-term-storage and downstream-integration story.
"Destination" is two different kinds of thing:
- Blob destinations: local filesystems and object stores
(
@hypaware/local-fs,@hypaware/s3, future@hypaware/gcs). Accept "put these bytes at this path." Format is separable. - Request destinations: endpoints with their own wire protocol
(
@hypaware/webhook,@hypaware/central). Accept "send this structured payload via my protocol." Format is bound to the destination.
For blob destinations, format is factored into writer plugins:
Blob destinations provide: hypaware.blob-store @hypaware/local-fs, @hypaware/s3
Encoders (per-batch) require: hypaware.blob-store @hypaware/format-parquet, @hypaware/format-jsonl
provide: hypaware.encoder
Table formats require: blob-store + encoder @hypaware/format-iceberg
provide: hypaware.table-format
Request destinations (no separable format) @hypaware/webhook, @hypaware/central
The Parquet writer doesn't know whether it writes to local disk or S3; the S3 plugin doesn't know whether it holds Parquet, JSONL, or an Iceberg manifest. Iceberg-on-S3 and Parquet-on-S3 share one S3 plugin. Adding GCS later is one plugin and every existing writer works.
A table-format writer lays data out for the archive's job, which is not the
cache's job. The @hypaware/format-iceberg writer partitions exported tables by
a writer-owned day grain and sorts each partition by the dataset's lookup
key: deliberately not inheriting the cache's cachePartitioning, which is
tuned for recent-query lookups and would impose an unbounded per-conversation
file count on an archive. See LLP 0022.
A sink implements an export contract, not a per-row writer:
interface Sink {
exportBatch(batch: ExportBatch, opts: ExportOptions): Promise<ExportResult>
flush?(): Promise<void>
close(): Promise<void>
}exportBatch is called on the configured schedule with a batch of ready cache
partitions; the sink writes them and acks.
@hypaware/central is a request sink: exportBatch POSTs each partition's rows
to the server in bounded chunks. The server rate-limits per gateway, so a chunk
can come back 429/503 carrying a Retry-After. The sink treats this as
backpressure, not failure:
- It retries the same chunk in place: byte-identical body and
X-Hyp-Batch-Id, so the re-send is idempotent (the server dedupes the already-delivered prefix; server LLP 0001). - It honors a positive
Retry-After. An absent, garbage, or non-positive value (a legalRetry-After: 0and a past HTTP-date both parse to0) carries no useful pacing and falls back to the linear ladder (30→60→120→300s). Taking a zero verbatim would retry with no delay and spin the loop; the ladder guarantees every wait advances so the inline budget can bound it. The same rule governs the config pull loop (LLP 0025). - The inline wait per chunk is bounded (~5 min). Past the budget the chunk
throws and the export driver respools the partition (
ExportResult.retryPartitions) on the next scheduled tick: cheap, because the server has already deduped what landed. - The wait is abortable:
close()aborts an in-flight pause so daemon shutdown is never wedged by a parked chunk.
The wire contract for these statuses lives in @hypaware/central's proto.md
("Response 429 / 503").
Blob sinks compose a writer + destination; request sinks are one-piece
(plugin). Both carry schedule. See LLP 0010.
{ "sinks": {
"archive": { "writer": "@hypaware/format-iceberg", "destination": "@hypaware/s3",
"config": { "bucket": "acme-hyp-archive", "schedule": "0 * * * *" } },
"forward": { "plugin": "@hypaware/central",
"config": { "endpoint": "https://hypaware.acme.internal", "schedule": "*/5 * * * *" } } } }schedule is a standard 5-field cron expression, chosen over a friendly
DSL because cron expresses "02:00 UTC nightly" naturally and the kernel parses
one grammar. The kernel validates writer/destination compatibility at
config-load time: format-parquet + @hypaware/webhook is rejected with an
explicit message (writer requires hypaware.blob-store; webhook provides
hypaware.http-endpoint), so the failure is configuration, not runtime.
Sinks declare what they support via the supports list in their manifest
(renamed from capabilities to avoid clashing with the global registry).
Recognized tag at V1: queryable. Queryability of a blob sink is a property
of the resolved writer/destination pair: Parquet-on-local-fs is queryable,
JSONL-on-local-fs is not. A queryable sink adds a read API; hypaware query
scans its data in place and queries transparently span cache + sink. If no
queryable sink is configured, queries run against the cache and retention bounds
the horizon.
Default install. The walkthrough installs @hypaware/local-fs +
@hypaware/format-parquet together when the user picks "export to a local
directory," so the common case still feels like one decision; the two-package
shape only surfaces for non-default pairings.