Skip to content

feat(ws): ordered notification stream with epoch cursor and revision-tagged subscriptions (#136) - #215

Merged
jsam merged 43 commits into
mainfrom
fm/il-w04
Oct 3, 2026
Merged

jsam merged 43 commits into
mainfrom
fm/il-w04

Conversation

@jsam

@jsam jsam commented Oct 3, 2026

Copy link
Copy Markdown
Contributor

Draft. W04: stream generation/revision, snapshot handoff and recovery; ordered notifications with an epoch cursor; revision-tagged subscription deltas. Depends on W01 request IDs (reconciled in #214) and T01-T03 (#208, on main). Validated through no-mistakes before merge.

Tracker: #199

jsam added 30 commits October 3, 2026 17:54
Adds perf-gate/, a standalone tool workspace that drives real
inputlayer-server binaries over /ws: warm cheap and bound queries,
durable single and batch inserts, writer-to-agent delta latency for 1
and 64 subscribers, and isolation under a long request and a slow
consumer. Baseline and candidate run in interleaved rounds with fresh
servers; every timed result is checked against a client-computed answer.
compare judges cost ratios with a bootstrap interval over rounds under
perf-gate/policy.toml and exits zero only on PASS.

make perf-gate builds the approved baseline commit and the working tree
and runs the gate; make perf-gate-check lints and tests the tool.
…terval

Each round's two arms run back to back, so judge the median of the
per-round cost ratios; host drift between rounds cancels. The exact
binomial interval of a median replaces the bootstrap: deterministic,
and at 10 rounds it tolerates one outlier round per side. Agents now
drain against a deadline from the writer's last write, so a slow writer
no longer counts against them.
QueryJob::execute holds a storage read guard for the whole program, but
.debug, .why, .why full, .why_not and the .index commands acquired a
second read on the same lock. parking_lot's RwLock prefers writers, so a
writer queued between the two reads deadlocked the query.

- .debug and .index reuse the outer guard.
- .why and .why_not capture a ProofSnapshot (KG snapshot plus index
  metrics) under the outer guard, release it, then run query evaluation
  and proof search on the snapshot alone. A failed proof re-acquires the
  guard for the statements that follow, as .compact does.
- StorageEngine::proof_snapshot_on and KnowledgeGraphSnapshot::proof_inputs
  separate snapshot capture from evaluation; execute_and_get_context is
  built from them and the unused get_rules_and_data is removed.

A test-only hook queues a writer after execute takes its guard and before
the meta command runs; every covered command deadlocked before this fix
and completes after it. No lock is added.
Sessions authenticated over /ws kept working for up to 24 h after their
API key was revoked, their password changed or their user dropped:
execute_program only re-read the role, and queued results, notifications
and subscription pushes were sent without any credential check.

Credentials now live in an in-memory CredentialRegistry, loaded from
_internal at startup and updated by every user and key mutation after
it is persisted. Authentication returns a Principal bound to exactly one
credential: one API key, or one generation of a user's password, each
with a canonical CredentialId. Revoking a credential trips its flag
before the command returns; other credentials, including other keys of
the same user, are untouched.

Enforcement:
- execute_program checks the principal at admission (one immutable
  permission snapshot for the whole program, replacing the per-request
  users scan) and again at release, so a credential revoked mid-run gets
  none of the output; writes committed before revocation stay.
- every outbound /ws data frame passes the fence after the socket is
  ready and before the frame is committed to it;
- a per-connection one-shot revocation signal closes the connection,
  stops its standing queries and sends one final notice.
- kg_change notices for other KGs reach admins only, and _internal
  changes are never sent, live or on replay.

Checks are an atomic load on state the session already holds; no shared
lock, lookup or storage access per request or frame. Login and REST key
checks use one registry lookup instead of a snapshot scan.

Closes #137
Adds `perf-gate genbi` and `make bench-genbi`: each genbi-trust scenario
(read in place from GENBI_TRUST_DIR, never copied) runs on a fresh server.
The IQL seed is cold-loaded, every business question becomes a standing
subscription over /ws, and an independent writer applies the ordered
mutations. Per mutation it records writer-send to agent-delta latency
(split insert vs retraction, and propagation after the durable ack),
write ack, a full re-query baseline, memory and throughput; every
checkpoint compares the agent's maintained answer with the
evaluator-private expected rows (counts only are reported) and with a
fresh evaluation, so a broken delta path fails as delta divergence.
--fault drop-retractions proves the checks catch one.

The perf-gate client gains rows-returning query() and subscription ids
on pushed frames; the server helper reports current RSS.
A capped result was adopted as the full set: .subscribe returned it as a
complete snapshot (truncated: false) and a re-evaluation crossing the cap
diffed against the partial rows. Reject a truncated result before touching
state: .subscribe errors without registering, re-evaluation pushes a
subscription_error and keeps the last complete result, and the next delta
once the result fits is relative to it.
Both SDKs turned an engine `error` frame into a one-row result with an
`error` column, and ignored `errors[]`, so a failed insert, load or
migration read as success (Python insert returned InsertResult(count=1)
for a WAL append failure, #93).

One error contract per SDK, applied in the connection:
- `error` frame -> QueryError with `code` and validation errors.
- non-empty `errors[]` -> StatementFailedError with each failed
  statement and the whole result; chunked results keep `errors` and
  `timing_breakdown` through assembly, and an error frame mid-stream
  raises typed.
- KG auto-create, migration recorder and langchain/langgraph helpers
  branch on the exception and `code`, never on message text.
- insert() counts the facts the engine reports storing.

JS fixes the error contract exposed:
- the reader queues a reply's frames, so a chunked reply sent in one
  burst is no longer dropped after its first frame;
- load() sends the local file instead of the client-only `.load`;
- vectorSearch() sends a plain query and sorts and limits locally
  instead of a head aggregate the engine rejects;
- dropKnowledgeGraph() leaves the KG before dropping it.
Two servers on one data_dir (for example a Kubernetes RollingUpdate on a
ReadWriteOnce volume) interleave WAL appends and shard rewrites and corrupt
both. StorageEngine::new now takes an OS advisory lock (flock / LockFileEx
via fs4) on data_dir/LOCK before creating, reading or writing anything
else, and holds it as the engine's last-dropped field.

A second engine on a locked directory fails with
StorageError::DataDirLocked naming the directory and the owner's recorded
pid, and leaves the directory byte-identical. The OS releases the lock when
the owner exits, including SIGKILL, so a stale LOCK file never blocks a
restart. Distinct directories lock independently.

Startup only: one open + flock + pid write (~80us measured); no per-request
lock or hot-path change.

Closes #142
Add `make e2e-reactive`: each test starts a real inputlayer-server
process, agents subscribe to standing queries over /ws, and independent
writer connections insert and retract facts and change rules. Agents
must receive the exact added/retracted rows with contiguous seq, without
re-querying, and end equal to a fresh full query. Covers one subscriber,
64 subscribers, reconnect/resubscribe and crash-restart, unrelated
writes, and write bursts.

Defects tracked by the reactive plan run as expected failures that fail
as XPASS once fixed: capped snapshots and refreshes adopted as complete
(S05), out-of-order notifications and a restart-reused reconnect cursor
(W04), and an undeliverable oversized delta that still advances the
subscription (W05).

The harness is the test-only `testkit` workspace crate (engine process,
/ws agent client, fixtures, raw writer->agent latency samples in schema
inputlayer.reactive.delta_latency.v1), shared with the benches. The
suite runs in test-all, ci-test-all and lint; plain `cargo test` skips it.
No production code changes.
A write now commits a Transaction: every change of one commit, across any
number of shards, at one revision. The WAL stores it as one checksummed
record whose trailing newline is the commit boundary, written with one
append and (in immediate mode) one fsync. Recovery replays the longest
intact prefix of records, so a restart yields the state after an exact
number of commits, never part of one.

- PersistBackend::append(shard, updates) becomes commit(txn). Inserts and
  deletes commit one-op transactions; startup multiplicity corrections and
  prefix clears commit one transaction across all their relations, so a
  failed prefix clear now leaves every relation intact.
- TxnOp is a typed enum (facts today); rule and schema changes become new
  variants. A checksummed record this server cannot decode fails startup
  with StorageError::WalUnreadable instead of being skipped.
- Tuples are stored with the lossless batch codec (base64), fixing NaN and
  infinite floats that JSON wrote as null and replay then dropped. v1
  records still replay so v1 data dirs migrate with their WAL.
- A crash-torn last line is cut off. Any other damage stops replay there
  and the cut-off bytes are saved to wal/current.wal.<ms>.corrupt, instead
  of skipping the damaged record and replaying later ones out of order.
- A failed write or fsync is cut back to the exact committed length; if
  that fails, the repair runs before the next write, rewrite or shutdown,
  so a commit that returned an error is not replayed.
- Remove the unused legacy src/storage/wal.rs, a second WAL format.

Hot path: same locks (WAL mutex, shard map, per-KG write lock), nothing
on the query path; one record per commit instead of one per tuple.

Part of #131
…tions (#88)

Replay every corpus.json scenario, controls included, and every
benchmark.json recorded reference extraction through the real rule pack,
and score both with one evaluator contract. Prints per-family detection,
the false-alarm rate over control rows and revision fidelity over
correction rows; fails on any control finding, stale correction,
over-retraction, finding-kind drift, unevaluated scenario, vacuous
control, missing family or empty metric. No model calls.

- evaluator.py: pure verdict/metric/contract module, self-tested by
  test_evaluator.py with planted defects
- engine_replay.py: corpus facts -> one namespaced KG, two-phase
  assert/retract, per-scenario observation with cross-scenario leak check;
  replaces the duplicate full_bench.engine_pass
- gate.py + scripts/run_vc_gate.sh + make vc-gate: CI entry on a
  throwaway engine; path-filtered workflow verified-completions.yml
- corpus controls now carry extractor-truth facts (corrections retract
  the superseded fact); fixes six restatement controls whose "$1k" vs
  "1500 USD" was a real contradiction
- poc_verify scores recorded extractions through the shared contract and
  checks presence against the engine; verify_each no longer passes
  controls without evaluating them
Add tests/differential_oracle: one history (statements, restarts, named
checkpoints) replayed through independent adapters and compared at every
checkpoint as Z-sets:

- reference: naive stratified fixpoint over finite sets in the test,
  sharing only the parser with the engine; unmodeled constructs desync it
  with an explicit, reported reason
- recompute: the snapshot evaluator queried afresh
- subscription: standing queries assembled purely from pushed deltas via
  the real notification, dependency-filtering and coalescing registry
- spec: results recorded in .iql.out transcripts (corpus cases)

Histories come from hand-written scenarios (duplicate supports, recursive
edge removal, negation, aggregates, rule replacement, restart), seeded
random generation and 620 .iql.out cases. Divergences are minimized with
delta debugging; a deliberately corrupted adapter (dropped retractions) is
caught and shrunk to two statements. The Adapter trait takes the revision
an answer must reflect, so a persistent per-KG dataflow adapter plugs in.

Production change: the notification-to-ChangeSet mapping moves into
subscription::change_of so the oracle drives the same mapping the
WebSocket loop uses. Add `make oracle-test` and document it in TESTING.md.
S05 landed in this checkpoint, so its two expected-failure markers flip
to required passes that pin the exact contract: a capped snapshot is
refused with nothing registered, and a refresh over the cap pushes
subscription_error, keeps the last complete result and recovers with a
contiguous delta.
Credential revocation (S03) shares subscription re-evaluation with the
fail-closed result cap (S05) and the handler with the guard-free proof
path (S06). One test holds a subscriber at the cap and revokes it: the
recovery delta never reaches it. The other revokes during .why proof
search, which now runs after the storage guard is released: no proof
is delivered.
make doc-check (rustdoc -D warnings) rejects intra-doc links from public
module docs to private modules; name them in plain text instead.
Quiet-window A/A of the approved baseline at 20 rounds with pinned
servers: 17 of 20 required metrics pass against themselves; durable-ack
p50 and interference delta p99 stay inconclusive on this host's virtual
disk. Raw samples and host notes sit beside approved.toml.
The 2,000-row .why full proof took 38-48 ms in release, below the
100 ms needed to revoke during it, so the test failed there. Grow the
fixture until the fastest of two proofs takes 100 ms, and revoke at a
quarter of that, so it holds in debug and release.
jsam added 9 commits October 3, 2026 19:26
Consecutive fact statements of a program (+, -, conditional delete,
update) are staged without touching the knowledge graph and committed
together under the KG's existing write lock: one effective delta, one
WAL record at one logical time, one snapshot publish, and one
notification per changed relation after the publish. A statement with
other persistent effects (schema, rule, meta command) or the end of the
program commits the run first. If any statement of a run fails, none of
them is applied and none reports success.

Conditional deletes and updates evaluate on the snapshot plus the
changes staged before them and record the relations their query reads.
The commit refuses a stale read when one of those relations or a rule
changed, and the run is staged again with a short random backoff, up to
8 times, before failing with a conflict. Writes to other relations never
conflict. A cancelled (timed out) program never commits.

insert_tuples_into and delete_tuples_from are one-statement programs on
the same path, so there is one write path. Update statements now notify
the relation they change instead of "multiple". State-dependent insert
rejections (arity, view, vector index, schema) report code validation.

Partial #131 (slice #169).
A program that changes persistent state now commits all of it as one
transaction on its knowledge graph: facts, schema declarations, persistent
rules and rule removals (-name, .rule drop, .rule drop prefix, .rule
remove, .rule clear). Statements apply in order to staged copies of the
catalogs under the KG's existing write lock, so a fact is checked against
a schema declared earlier and a conditional delete or update after a rule
change evaluates with the new rule. Everything is written as one WAL
record and published as one snapshot: readers see one rule generation
with its data. The first failing statement fails the program; nothing is
applied, its query does not run, and subscribers are not notified.

Commands that cannot join the transaction (.kg, .rel, .rel drop, .clear,
.index, .compact, inspection, user, ACL, ontology, subscription) are
rejected with code unsupported before any statement runs when they share
a program with writes. Programs without writes run as before.

- TxnOp::Catalog carries each changed rule's and persistent schema's
  state after the commit, so replay is idempotent. Catalog files record
  the revision they reflect and are written atomically; the WAL keeps a
  catalog change until both files are saved past it, and startup replays
  what the files miss. KG drop removes its WAL catalog changes.
- Every catalog API is a one-statement program: one write path.
- .ontology install deploys as one transaction; .ontology remove drops a
  pack's rules in one transaction and each relation on its own.
- Python SDK load() docs state the real transaction boundary.

Hot path: fact commits take the same locks as before (the catalog log
lock only when a transaction carries catalog changes); queries are
untouched. Rule and schema changes now cost a WAL fsync and two atomic
catalog saves.

Part of #131 (slice #170).
# Conflicts:
#	.github/workflows/ci.yml
#	Cargo.toml
#	docs/content/docs/guides/persistence.mdx
#	docs/guides/persistence.md
#	packages/inputlayer-py/src/inputlayer/knowledge_graph.py
#	src/protocol/handler.rs
#	src/storage/persist/mod.rs
#	src/storage/persist/transaction.rs
#	src/storage/persist/wal.rs
#	src/storage/persist/wal_record.rs
#	src/storage_engine/mod.rs
#	tests/wal_transaction_recovery.rs
…ed subscriptions

Change notifications and standing-query results now have explicit, separate
stream contracts.

- One NotificationLog per engine run numbers, retains and broadcasts each
  notification under its existing ring lock, so seq order, ring order and
  delivery order agree; the two duplicated publish paths and their three
  fields are gone.
- authenticated carries stream_epoch, a random id of the engine run. A
  reconnect presents last_seq with epoch; the server replays the retained
  notifications after it with live ones following without gap or overlap,
  or sends one non-closing replay_gap notice (other epoch, evicted, ahead).
  Replay passes the credential fence frame by frame.
- Snapshots carry a revision from one engine-wide counter, assigned under
  the KG write lock. A subscription evaluation pins one snapshot (rules,
  data and revision) via Handler::query_snapshot; subscribed and every
  subscription_delta name the revision they are the exact answer at.
- Registration compares the KG's current revision with the snapshot's and
  re-evaluates at once when it moved on, so a commit announced before the
  subscription existed is not lost.

Hot path: no new lock. Publication extends the existing ring critical
section by the broadcast send; subscribe adds one snapshot read.

Part of #174, #136
…ng-subscribe delivery

The W04 known-defect markers become required passes in a new stream module:
concurrent commits notify in strictly increasing seq order; a cursor from
before an engine restart gets one replay_gap notice and no replayed
notifications of the new run; and commits racing a .subscribe all reach the
agent, whose view ends equal to a fresh query.

The testkit agent now checks that every delta names a higher revision than
the state it applies to (Violation::StaleRevision) and exposes the stream
epoch. In-process tests cover the reconnect cursor over a real /ws
connection (replay then live without overlap; other or missing epoch and
evicted cursors get replay_gap) and increasing revisions on deltas.

Part of #174
The AsyncAPI spec, the WebSocket guide, the ws-protocol crate docs and
TESTING.md describe the two stream contracts: notification seq within a
stream epoch with last_seq+epoch replay or replay_gap, and per-subscription
gapless seq with increasing knowledge graph revisions. The Python and JS SDK
and GUI frame types gain stream_epoch, Subscribed.revision,
SubscriptionDelta.revision and the replay_gap notice code (non-closing).
The GUI docs bundle is regenerated, which also picks up earlier guide
changes it had not yet bundled.

Part of #174, #136
A session kept receiving its knowledge graph's change notifications, live and
on replay, after .kg acl revoke took its access away, and a subscription
delta computed before the revocation could still be sent.

Every pushed frame now passes a read-access check for its knowledge graph.
The handler counts access-list changes (grant, revoke, KG drop cleanup, user
drop); each connection caches its answer per knowledge graph and looks the
access list up again only when that generation moved. A withheld delta
becomes a subscription_error naming its skipped seq.

Hot path: one atomic load per pushed frame while access lists are unchanged.

Part of #174
# Conflicts:
#	Cargo.toml
#	src/protocol/handler.rs
#	src/storage/persist/mod.rs
#	src/storage/persist/transaction.rs
#	src/storage/persist/wal.rs
#	src/storage_engine/catalog_commit_tests.rs
#	src/storage_engine/program_commit_tests.rs
#	tests/atomic_program_tests.rs
#	tests/fact_program_tests.rs
@jsam
jsam marked this pull request as ready for review October 3, 2026 20:24
@jsam
jsam merged commit 4578fe6 into main Oct 3, 2026
14 checks passed
@jsam
jsam deleted the fm/il-w04 branch October 3, 2026 20:24
jsam added a commit that referenced this pull request Oct 3, 2026
Bring W04 (stream epoch cursor, revision-tagged subscriptions, KG access
fence), which main carries as the #215 squash, onto W02+W03:

- Subscription handoff: one mechanism. W02 evaluates a .subscribe snapshot
  off the connection loop (Opening/Opened); registration now uses W04's
  revision check (re-evaluate when the KG moved past the snapshot) instead
  of W02's record of notifications seen while opening, which could miss a
  commit whose notification had not reached the loop yet. registry.add
  loses its `missed` argument; W02's two tests for it are removed.
- ws.rs: W04's epoch cursor, replay and per-push KG access fence in W02's
  pipelined loop; the .subscribe reply names the snapshot revision.
- handler.rs: W04's run_job (pinned snapshot jobs) runs under the request
  control via supervise::run_blocking; query_snapshot gets the server
  deadline; execute_program_status finishes the control before W04's
  settle_result.
jsam added a commit that referenced this pull request Oct 3, 2026
main is d75ce6e (#208) plus 4578fe6 (#215), the squash of fm/il-w04,
whose tree is identical to fm/il-w04 merged above; keep this branch's tree.
jsam added a commit that referenced this pull request Oct 4, 2026
…ry request (#249)

The release-checkpoint performance gate fails on main: cheap_query serial
p50 is 1.110x the approved baseline e732cd5 on the 32-vCPU runner
(115 -> 126 us). Concurrent latency and throughput did not move. Bisected
to #214 (per-connection request pipelining), made worse by #222.

Cause: the pipeline runs requests in a `FuturesUnordered`. Its `poll_next`
wakes its own task and returns `Pending` after any poll that polled every
future in the set. With one request in flight, which is every serial
client, that happens on each request's first poll. The connection task
wakes itself, tokio schedules that as a yield, and a yield wakes an idle
worker to take the task. Each request therefore costs one more context
switch (4.0 -> 5.0 per query) and a hand-off of the connection task to
another core. #222 added no work per query but made the hand-off dearer:
user cycles per query rose 15% at the same instruction count.

Fix: keep one never-woken future in the set. After the pipeline's first
poll it is never polled again, so no poll reaches every future and the
forced yield no longer fires. The yield after futures that wake
themselves still applies. Pipelining, ordering, deadlines, cancellation
and the credential fence are unchanged. Nothing is added per request: no
lock, atomic or allocation. Each connection gets one more set node.

Bisect (cheap_query, `?edge(1, Y)`, serial). Server counters per query
from perf stat. Ratios are against e732cd5, from an interleaved probe
with one pinned server per commit, 40 rounds:

| commit | PR | user instr/query | ctx switches/query | p50 ratio |
|---|---|---|---|---|
| e732cd5 | baseline | 1.282M | 4.0 | 1.000 |
| d75ce6e | #208 atomic programs | 1.279M | 4.0 | 0.993 |
| 4578fe6 | #215 notification stream | 1.271M | 4.0 | 0.991 |
| 71a4d24 | #216 online backup | 1.275M | 4.0 | 1.016 |
| 2f621c5 | #200 first-update latency | 1.272M | 4.0 | 1.009 |
| e96df5c | #207 | 1.279M | 4.0 | 0.998 |
| 4dd8fe9 | #209 shard index | 1.272M | 4.0 | 0.987 |
| 277c7f0 | #206 | 1.274M | 4.0 | 1.008 |
| 3554c98 | #214 pipelining, deadlines | 1.296M | 5.0 | 1.020 |
| 3492a59 | #213 | 1.294M | 5.0 | 1.030 |
| 2ba9a5a | #212 provenance | 1.297M | 5.0 | 1.029 |
| 9ed61b4 | #204 key expiry | 1.297M | 5.0 | 1.027 |
| be35ee3 | #221 | 1.297M | 5.0 | 1.013 |
| c98a590 | #222 bounded streaming | 1.298M | 5.0 | 1.047 |
| a6fd628 | #219 wildcard fix | 1.297M | 5.0 | 1.061 |
| b7f7ad6 | #220 WAL durability | 1.300M | 5.0 | 1.056 |
| de54807 | #223 shared standing eval | 1.299M | 5.0 | 1.071 |
| 55ec34d | #225 checkpoint-1 fixes | 1.300M | 5.0 | 1.045 |
| 23712b8 | main | 1.300M | 5.0 | 1.064 |
| this change | | 1.300M | 4.0 | 1.033 |

Confirming A/Bs with the gate's own fixture (fresh pinned servers each
round, 40 rounds, ratio [95% interval]):

- #214 vs its parent 277c7f0: 1.039 [1.027..1.049]
- #222 vs its parent be35ee3: 1.025 [1.006..1.044]
- A/A 3554c98: 1.010 [1.002..1.025]
- e732cd5 -> main: 1.048 [1.028..1.066]
- main -> this change: 0.978 [0.958..0.989]
- e732cd5 -> this change: 1.021 [1.009..1.044], PASS (budget 1.05)

User cycles per query (perf stat, same instruction count): e732cd5 958K,
be35ee3 1053K, c98a590 1206K, this change 1006K.

Full gate, all fixtures, 20 rounds, servers pinned to CPUs 2-15:

- main -> this change: no metric worse. Every required metric is PASS, or
  INCONCLUSIVE with an interval that includes 1.0. cheap_query p50 0.979
  [0.953..1.007].
- e732cd5 -> this change: cheap_query p50 1.029 [1.019..1.072],
  INCONCLUSIVE on a busy host (load average up to 22); bound_query p50
  1.022, PASS. The insert and delta metrics improved since the baseline.

The shared host is about 4x slower per query than the CI runner, so the
same hand-off is a smaller share of each query here than there.

The remaining ~1.7% of user instructions per query since #214 is the
pipeline's own per-request bookkeeping: the boxed request future (one
4-8 KiB allocation), its set node, the request control and the in-flight
maps.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant