Contextfulv0.23.0

Docs / Specification

contract store · 10-store.md · 11-snapshot.md · 12-time.md · 13-replica.md · 14-drain.md · 15-endpoint.md · 16-collect.md · 17-merge.md · 18-pull.md · guide · card

The store and its sync

The store holds canonical Parquet data, JSON manifests and a pointer to each table’s current snapshot, which readers open without the engine. Two local SQLite catalogs cache metadata and hold machine state, and an S3-compatible bucket mirrors the tree key for key.

Flowchart diagram Flowchart, top to bottom. 14 nodes, 16 edges. run: pipeline landing → run parts [conditional create]; → commit log [leased cursor] read: query server → _pointer.json [sorted file list]; → access controls [admission value] authority: access controls topology: Catalog trait → machine database [leases through] store root: run parts → fold pass [omitted runs]; → schema.json [merged schema]; → derived cache [rebuild catalog]; → bucket manifest [push by digest] store root: commit log store root: fold pass → table snapshot [staged, then If-Match]; → _pointer.json [new pointer] store root: table snapshot store root: _pointer.json → table snapshot [names snapshot and fence]; → derived cache [rebuild catalog] store root: schema.json → derived cache [rebuild catalog] store root: derived cache store root: machine database bucket: bucket manifest → run parts [pull by digest]; → read-only replica [refresh] run pipeline landing read query server authority access controls topology Catalog trait store root omitted runs staged, then If-Match new pointer names snapshot and fence merged schema rebuild catalog rebuild catalog rebuild catalog run parts commit log fold pass table snapshot _pointer.json schema.json derived cache machine database bucket bucket manifest conditional create leased cursor leases through sorted file list admission value push by digest pull by digest refresh read-only replica

lay-out

Store directories, run and snapshot manifests, table pointers, local catalogs and node identifiers.

run manifests

A run commits through its manifest, and a repeated landing replays or refuses.

  • run-manifesttestA run commits by conditionally creating its node directory’s _manifest.json, a lay_out::RunManifest whose node_id equals the directory’s node segment.
  • run-replaytestRe-landing an unlogged run committed on its node, at its manifest’s position with rows reconstructing its parts column for column, writes nothing and returns the committed manifest as a replay.
    Whybecause a retry after a lost acknowledgement cannot tell whether its first landing committed
  • run-conflicttesttestAny other landing under a run id committed on its node or folded under its run key raises StoreRunConflict, naming the run, and changes nothing; a folded row without batch_digest predates digests and always conflicts.
    Whybecause other rows under a committed id are another run
  • run-digesttestA landing’s run manifest records batch_digest, the SHA-256 of its batches’ producer rows and fixed column types after writer rules, and of its position; injected columns and the commit instant stay outside it.
    WhyA-store#run-digest
  • run-folded-replaytesttestRe-landing a run whose manifest is gone and whose key the table’s folded runs name, with an equal batch_digest, writes nothing and returns a replay carrying the folded-runs row in place of a manifest.
    WhyA-store#run-digest
  • folded-committedtesttestStore::committed_rows answers a run whose manifest a collection removed from its folded-runs rows, summing their row counts; a run only a walked snapshot names, or one without a count, reads as uncommitted.
    WhyA-store#run-digest
  • run-id-shapetestA run id holds at most 128 chars of letters, digits, dot, underscore and hyphen; landing and request-ledger append reject any other before writing a file.
    Whybecause a run id is interpolated into run directories and request-ledger filenames
  • uncommitted-runtestA node directory without _manifest.json holds an uncommitted run, whose parts file lists exclude; a leased pipeline’s run also waits for its commit-log entry.
    WhyP4
  • cursor-in-committestA pipeline’s committed position is its newest commit-log cursor when leased, else its highest run-manifest cursor; machine.sqlite caches it.
    Whybecause a position committed apart from its rows re-lands the batch after a crash

format versions

Each manifest names its layout version.

  • manifest-defaulttestA field added to a manifest carries a default value.
  • format-versiontestRun and snapshot manifests carry format_version as <major>.<minor>: major 2 for snapshots referencing parts or segments, else 1; minor 1 for snapshots carrying folded_runs, else 0. An absent version reads as 1.0; readers parse a newer minor as their own.
    WhyA-store#manifest-carries-format
  • format-newertestA run or snapshot manifest of a newer format_version major raises StoreManifestFormatNewer, naming the file and both versions, and the table refuses reads until a newer build reads it.
    WhyA-store#manifest-carries-format
  • format-migratetestA major-2 reader reads a 1.x snapshot manifest as one part per directory, counting rows from footers; the next pass references those parts under major 2, rewriting no manifest in place.
    WhyA-store#manifest-carries-format
  • manifest-unreadabletestA run manifest or reachable snapshot manifest failing to parse raises StoreManifestUnreadable, naming the table and file; the table refuses reads until it parses.
    WhyP4

node identity

Each machine resolves one node id that keeps its files apart.

unsettled: Does a store re-bind the node id it last derived, so runs landed before its root moved push without CONTEXTFUL_NODE_ID? owner: store affects: store.lay-out

init

Project initialization, and project discovery when a command omits --project.

Scenarios

declare

Table declarations, primary keys, row ordering, write modes and replacement behavior.

unsettled: What retires a key a source stops serving under append, given the source-side deletion is invisible in the tree? owner: store affects: store.declare

reserve

Reserved column and table names, injected columns, _commit_seq assignment and request ledgers.

commit

A commit holds its table’s commit lock, assigns _commit_seq above every held value and publishes in sequence order.

request ledgers

Each run appends its outbound requests to its own ledger file, which fold passes merge and collection retires.

unsettled: Does a replica merging another node’s runs renumber _commit_seq, or does an export cursor hold one position per writing node? owner: store affects: store.reserve

unsettled: Does a fold backfill _commit_seq onto parts landed without the column, and in which order among their runs? owner: store affects: store.reserve

reconcile

Schema reconciliation across table files, type promotion, added columns and scan behavior.

unsettled: What dimension caps a fixed-size vector column, given the engine bounds an ARRAY width? owner: store affects: store.reconcile

unsettled: Does the variant column’s Struct encoding give way to Parquet VARIANT once the linked DuckDB and parquet releases read and write it? owner: store affects: store.reconcile

fold

Compaction passes, their triggers and retention, and snapshot publication through a pointer update.

pass scheduling

When a table’s pass fires, and which writer runs it.

A fold pass under the compaction lease, from run selection to the pointer commit.

Sequence diagram Sequence diagram. 5 participants, 8 messages. Participants: fold pass (F), compaction lease (L), staging directory (S), _pointer.json (P), reader (R). 1. fold pass → compaction lease: acquire, fence N alt another node holds it: 2. compaction lease --> fold pass: LeaseHeld, pass skipped else acquired: 3. fold pass → _pointer.json: read ETag 4. fold pass → fold pass: select omitted runs, merge, sort, partition 5. fold pass → staging directory: Parquet and sidecars, fence N 6. fold pass → _pointer.json: replace If-Match ETag, fence N alt condition holds: 7. _pointer.json --> reader: new snapshot for statements starting after the commit else condition lost: 8. _pointer.json --> fold pass: nothing published, staging collected next pass catalog store root altanother node holds itacquired altcondition holdscondition lost fold passfold pass compaction leasecompaction lease staging directorystaging directory _pointer.json_pointer.json readerreader acquire, fence N LeaseHeld, pass skipped read ETag select omitted runs, merge,sort, partition Parquet and sidecars, fenceN replace If-Match ETag, fenceN new snapshot for statementsstarting after the commit nothing published, stagingcollected next pass

index

Sidecar indexes, bloom filters, row clustering and partitions.

bloom filters

Only a project without an encryption key writes bloom filters.

  • bloom-partstestWithout a project encryption key, each landed or folded part carries bloom filters, sized to its row count, on the columns bloom_filter names; erasure rewrites carry none.
    WhyA-store#sidecar-names-id
  • bloom-vintage-keytestWithout a project encryption key, each landed or folded vintage-table part carries a bloom filter on every primary-key column, whatever bloom_filter declares.
    Whybecause interleaved writers write parts with overlapping key bounds
  • bloom-encryptedtestA project binding an encryption key writes no bloom filter in any part, whatever bloom_filter names.
    Whybecause Parquet modular encryption leaves a bloom filter in plaintext, answering key membership

sidecar builds

Fold passes build, extend and rebuild each declared sidecar.

partitions

Clustering orders rows within files, and partitioning splits a table’s files.

  • clusteringtestcluster_by sorts file rows lexicographically by its columns in order, so zone maps skip row groups with no manifest entry or sidecar.
  • partitioningtestA table uses partitioning only when the table declares partition_by.
  • partition-typetestA partition_by column typed binary or vector raises StorePartitionColumnType at validation, before any Parquet.
    Whybecause bytes name no directory that keeps two distinct values apart
  • partition-warningstestPlanning warns, naming the column, where a partition specification projects more than 1000 partitions or a median partition below 16 MiB.
  • tenant-outermosttestA multi-tenant table carries the tenant identifier as its outermost partition column.
    Whybecause a dropped tenant predicate then reads nothing instead of every tenant
  • tenant-verbatimtestLanding applies no canonical form to tenant values: writes and comparisons keep bytes without trimming, case folding or Unicode normalization, and percent-escaping changes only the directory name.
    WhyA-store#tenant-value-bytes

encrypt

At-rest encryption of Parquet, sidecars, ledgers, metadata, machine catalogs and node run-state files.

unsettled: How long does a sidecar decrypted into process memory stay resident across reads, and what evicts it? owner: store affects: store.encrypt

unsettled: How does a project rotate its key, and what reseals a part carried by reference under the retiring key version? owner: store affects: store.encrypt

emit

Push planning from local files without reading or writing the bucket.

push

Uploading local files, constraining bucket prefixes and committing the bucket manifest.

fire-driven pushes

A run or a job fire pushes the store without an operator’s push.

  • after-runtesttestWith [sync] push_after_run = true, run start, pipeline run and job fire push the store once the fire’s runs close, a failed push failing the command.
    Whybecause a push spends bucket writes on every fire, which a scheduled sync avoids
  • job-pushtestcontextful job fire on a sync-push job pushes the store once as contextful sync push does; on a store declaring no [sync] the fire fails, naming the store’s config.toml.
    WhyA-surface#maintenance-kinds

generations

Each manifest commit numbers a generation and writes its immutable copy, which a restore reads.

  • generationtestEach manifest commit records generation under generation-floor and the project’s table pointers read before it, then creates <prefix>/manifests/gen-<N>.json with the committed bytes under If-None-Match.
    Whybecause a restore or pre-push gate names one committed state
  • commit-stamptestEach manifest commit records committed_at, the push instant, and committed_by, the pushing node id; a plan records neither.
  • generation-floortestA commit’s generation is one past the greater of the read manifest’s generation, absent reading as 0, and the newest gen-<N>.json under the prefix.
    Whybecause reusing a taken number collides with its immutable copy
  • generation-probetestPush lists gen-<G>.json, G the read manifest’s generation, and each later generation’s key one by one until the first absent key; only a manifest without generation lists all of manifests/.
    Whybecause the prefix gains one copy per commit and is never pruned
  • generation-healtestBefore each manifest commit, a push finding no gen-<N>.json for the read manifest’s generation creates it from the bytes it read.
    Whybecause a missing gen-<N>.json otherwise leaves a gap no later push sees
  • generation-conflicttestA push whose create of gen-<N>.json meets another commit’s bytes raises SyncGenerationConflict, naming N, after its manifest commit applied.
    Whybecause the immutable copy keeps the first commit, so pull --generation restores another state

A push: digest, upload, then the bucket-manifest commit by merge and compare-and-set.

Sequence diagram Sequence diagram. 3 participants, 7 messages. Participants: writer (W), bucket objects (B), manifest.json (M). 1. writer → writer: walk store root, digest each file 2. writer → manifest.json: read remote manifest + ETag loop each file whose digest differs: 3. writer → bucket objects: upload under the prefix loop up to 64 rounds, 5 without progress: 4. writer → writer: merge own entries with remote ones 5. writer → manifest.json: replace If-Match ETag alt condition holds: 6. manifest.json --> writer: push committed else lost the race: 7. manifest.json --> writer: re-read, re-merge Note over writer,manifest.json: exhausted retries raise SyncManifestRebaseExhausted bucket loopeach file whose digest differs loopup to 64 rounds, 5 withoutprogress altcondition holdslost the race writerwriter bucket objectsbucket objects manifest.jsonmanifest.json exhausted retries raiseSyncManifestRebaseExhausted walk store root, digest eachfile read remote manifest + ETag upload under the prefix merge own entries withremote ones replace If-Match ETag push committed re-read, re-merge

unsettled: Which pointer does the bucket keep when a local fold’s snapshot descends from none the bucket pointer names? owner: store affects: store.push

Scenarios

Shapes

Each example names the contextful_core::store type it serializes. Each example parses into that type and writes back unchanged.

The store tree and its bucket mirror, drawn by lay_out::TableLayout:

.contextful/context/<project>/          bucket: <prefix>/<project>/
  derived.sqlite                         not synced
  machine.sqlite                         not synced
  config.toml
  cursors/<pipeline-id>/<node-id>/       commit log of a leased pipeline, per node
    <seq>.json
  nodes/<node-id>/run-state.json         newest runs, cursor markers
  tables/<t>/
    schema.json
    _pointer.json
    data/runs/<run-id>/<node-id>/
      part-00000.parquet
      _manifest.json
    data/snapshots/snapshot-01742054400000000000/
      part-00000.parquet
      _manifest.json
      indexes/vec-<col>-<model>/zone=<label>/
      indexes/fts-<col>-<tokenizer>/
    data/snapshots/<id>.staging/
    requests/
      <run-id>.<node-id>.parquet
      folded-<snapshot-id>.parquet

<prefix>/manifest.json                   key -> { sha256, size, owner }
<prefix>/_contextful/cas-probe/<uuid>
<prefix>/leases/<pipeline-id>.json
<prefix>/leases/compact/<table>.json

A table declaration, declare::TableDecl:

[[pipeline.tables]]
name         = "documents"
primary_key  = ["document_id", "page"]
order_by     = "revised_at"
write_mode   = "replace"
cluster_by   = ["source_id", "revised_at"]
partition_by = ["tenant"]
retain_runs  = "7d"

[pipeline.tables.valid_time]
from = "effective_from"
to   = "effective_to"

[[pipeline.tables.indexes]]
kind            = "vector"
column          = "embedding"
id_column       = "passage_id"
model           = "e5-small"
dim             = 384
metric          = "cosine"
m               = 16
ef_construction = 200

[[pipeline.tables.indexes]]
kind      = "fulltext"
column    = "body"
id_column = "passage_id"
tokenizer = "cjk"

A run manifest, a snapshot manifest and a table pointer, lay_out::RunManifest, lay_out::SnapshotManifest and lay_out::Pointer:

{ "run_id": "run-4815", "table": "documents", "node_id": "ingest-a",
  "format_version": "1.0", "parts": [{ "name": "part-00000.parquet", "key_version": 3 }],
  "committed_at": "2025-03-15T15:58:00Z", "pipeline_id": "documents-sync",
  "cursor": { "field": "revised_at", "at": "2025-03-15T15:57:41Z" }, "fence": null }

{ "snapshot_id": "snapshot-01742054400000000000", "parent": "snapshot-01741968000000000000",
  "format_version": "1.0", "table": "documents", "created_at": "2025-03-15T16:00:00Z",
  "includes_runs": ["run-4812/ingest-a", "run-4813/ingest-a", "run-4814/ingest-b"],
  "primary_key": ["document_id", "page"], "order_by": "revised_at", "row_count": 128400,
  "valid_time": { "from": "effective_from", "to": "effective_to" }, "fence": 12,
  "parts": [{ "name": "part-00000.parquet", "key_version": 3 }],
  "indexes": [{ "kind": "vector", "path": "indexes/vec-embedding-e5-small/zone=all",
                "table": "documents", "snapshot_id": "snapshot-01742054400000000000",
                "column": "embedding", "id_column": "passage_id", "model": "e5-small", "dim": 384,
                "metric": "cosine", "m": 16, "ef_construction": 200,
                "builder": "contextful-hnsw", "builder_version": 4, "row_count": 128400,
                "key_version": 3 },
              { "kind": "fulltext", "path": "indexes/fts-body-cjk",
                "table": "documents", "snapshot_id": "snapshot-01742054400000000000", "column": "body",
                "id_column": "passage_id", "tokenizer": "cjk",
                "builder": "contextful-postings", "builder_version": 2, "row_count": 128400,
                "term_count": 902113, "key_version": 3 }] }

{ "snapshot_id": "snapshot-01742054400000000000", "fence": 12 }

A lease object and a commit-log entry, lease::BucketLease and commit_log::CommitEntry:

{ "holder": "ingest-a", "acquired_at": "2025-03-15T16:00:00Z", "expires_at": "2025-03-15T16:10:00Z", "fence": 418 }
{ "kind": "commit", "table": "documents", "run_id": "run-4816", "cursor": { "kind": "opaque-token", "value": "eyJwYWdlIjo0Mn0" }, "fence": 418 }

A bucket manifest, sync::BucketManifest:

{ "format": 1, "generation": 7,
  "entries": { "documents/tables/documents/_pointer.json": { "sha256": "9f2c41d07be3a5c8e6f1d2b4a7c90e3f5d8b1a6c4e7f2d9b0a3c5e8f1d4b7a2c", "size": 74, "owner": "ingest-a" } },
  "tombstones": { "documents/tables/documents/data/runs/run-4811/ingest-a/part-00000.parquet": { "owner": "ingest-a", "deleted_at": "2025-03-15T16:00:00Z" } },
  "pointers": { "documents/tables/documents/_pointer.json": { "snapshot_id": "snapshot-01741968000000000000", "fence": 11 } } }

A store’s config.toml, config::StoreConfig:

[sync]
bucket            = "context-prod"
endpoint          = "r2://<account-id>"
access_key_id     = "secret://sync-access-key-id"
secret_access_key = "secret://sync-secret-access-key"
prefix_from       = "env:CONTEXTFUL_SYNC_PREFIX"
coordination      = "cas"
push_retries      = 5
transfers         = 16
push_wait_secs    = 300
pull_before_run   = true
push_after_run    = true

[node]
id = "ingest-a"

[encryption]
key_source = "env:CONTEXTFUL_KEY"

[connector]
require_pin = true