Repository navigation
feat(inputlayer-py): subscribe(), watch() and on() with typed Change events - #245
Merged
Merged
Conversation
…events kg.subscribe() is an async iterator of Change events (snapshot, delta, unverified, resync) with the JS SDK's kinds, fields and reasons. Streamed deltas are assembled and checked against their counts; seq gaps, broken streams, subscription_reset, subscription_error, a slow consumer and a lost connection give unverified at once, then one resync with the exact difference after resubscribing with backoff. Projected queries count the engine rows behind each projected row and report only 0<->1 transitions. watch() yields the whole current set with a verified flag; on() calls back. KnowledgeGraphSync mirrors all three as blocking iterators. The connection core holds pushes that arrive before their .subscribe reply and tells listeners when its link drops, gives up or is closed.
Snapshot and deltas with typed rows, streamed snapshots and deltas, a push that overtakes its .subscribe reply, projection multiplicity, seq gaps, stale generations, four broken-stream shapes, reset and error reopen paths, a refused reopen, a slow consumer, reconnect, a connection closed for good, client close, leaving the loop, every refusal, watch, on and the sync mirror.
Snapshot plus deltas is checked against a fresh query at every checkpoint of the differential oracle's seeded random histories (generate.rs ported with the same SplitMix64; identical to the JS port for seeds 1-6), for all ten oracle queries and two projected builder queries. Also: projection with two supporting tuples, streamed snapshot and deltas, a seq gap and a reset, a slow consumer, reconnect, ACL revoke, the refusals, watch and on.
jsam
marked this pull request as ready for review
October 4, 2026 13:41
This was referenced Oct 4, 2026
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Intent
So fix the SDKs by improving them. (Context: the captain asked to rework the SDKs so agents use InputLayer without writing Datalog; the release blocker is that neither SDK has a subscription API. Agreed design: ~/firstmate/data/sdk-design/report.md; this item is phase-1 PR P1-F P1-F (Py). Python compiler v2 (#235, #239) and the Python connection core (#236) are merged; the JS twin (sdk-p1-g) is being built in parallel and must keep identical event shapes.)
The referenced item P1-F of the agreed design is, in substance: the Python SDK gets kg.subscribe() / kg.watch() / kg.on() with typed Change events, chunk assembly, generation and seq checks, projection multiplicity, bounded queues, and resubscribe on reconnect; live tests against a real engine cover snapshot+delta exactness over random histories (reusing the tests/differential_oracle history generator), a seq gap, a reset, a slow consumer, an ACL revoke, and projection_two_supporting_tuples. The design's subscription contract: subscribe() yields Change events of one shape (kind snapshot | delta | unverified | resync, inserted and retracted rows, revision, seq, verified); the first event is the snapshot; deltas are set differences; a lost connection, seq gap, subscription_reset, subscription_error or slow consumer yields an unverified event at once and then one resync carrying the exact difference between the rows the consumer holds and the fresh result; stale generations are dropped and counted; chunked snapshots and deltas are assembled and checked against announced counts; a projected subscription keeps a multiplicity per projected row and reports only 0<->1 transitions; per-iterator queues are bounded (default 1024) and overflow never blocks the reader; subscribe() refuses with SubscriptionRejected(reason) for limit/offset, OR conditions, and session views; watch() yields the whole current set with a verified flag; on(X, callback) returns a handle with close(); the sync client gives a blocking iterator. The JS twin (#241) defines the event kinds, field names and reason strings the Python SDK must match.
What Changed
subscription.py).KnowledgeGraph.subscribe()yields typedChangeevents (snapshot,delta,unverifiedorresync). It assembles chunked frames and checks them against the announced counts. It also checks generation and seq, keeps a count per projected row so a projection only reports 0↔1 transitions, and bounds each iterator's queue at 1024 by default without blocking the reader. A dropped connection, seq gap, reset, error or slow consumer produces anunverifiedevent, followed by aresyncthat carries the exact difference.watch()yields the whole current set with averifiedflag, andon()returns a handle withclose().subscribe()raisesSubscriptionRejected(reason)for limit/offset, OR conditions and session views. The sync client gets blocking iterators, andChange,Live,Row,Subscription,SubscriptionHandle,SubscriptionStatsandSubscriptionRejectedare exported.Connectionto support resubscribe on reconnect. It now has link listeners (lost,given_up,closed) and aclosed_for_goodproperty. Pushes that arrive before their.subscribereply are held and then filtered by its generation, andremove_routeonly removes a route that matches the given one. Opening a subscription retries transient failures with the reconnect backoff and stops once the connection is closed for good.QueryPlangains publicpick()andfrom_engine()so a projection keeps its duplicate rows.tests/subscriptions_live.py, now part ofmake python-sdk-live). The live tests cover snapshot plus deltas over random histories, seq gaps, resets, slow consumers, reconnect and ACL revoke. The Python SDK guide and README now documentsubscribe(),watch()andon().Risk Assessment
Testing
I built the release server and ran the repo's
make python-sdk-livetarget against a disposable engine; all 10 subscription live tests passed, along with the docs and compile-rule live tests that share the target. I then wrote a script that starts, stops and restarts its own server on a private port and temp data dir. With it I checked three things live. The sync client's blocking iterator yields a snapshot and then a seq=1 delta. With auto_reconnect=False, after the server dies, a new subscribe() raises ConnectionLost within about 1 ms and does not hang. A subscribe() started while the server was down retried for 2 s without failing, then yielded a verified snapshot 0.34 s after the server came back. The targeted unit tests for the fix rounds also passed, and I removed the build output afterwards. This is an SDK library, so there is no UI and no screenshots; the evidence is the CLI transcripts.Evidence: make python-sdk-live transcript (58 passed against a real engine)
Evidence: Live script output: sync iterator, given-up connection, first-open retry
Evidence: Live driver script
Evidence: Fix-round live results
Pipeline
Updates from git push no-mistakes
✅ **intent** - passed
✅ No issues found.
✅ **Rebase** - passed
✅ No issues found.
packages/inputlayer-py/src/inputlayer/subscription.py:450- The first open does not retry transient errors, so Python behaves differently from the JS twin._startcalls_open("snapshot")once, and any exception goes to_fail(e). A transient error on the first.subscribetherefore ends the iterator:ConnectionLost(the link drops while opening, see_on_linklines 713-714),RateLimited, aDeadlineExceededon a large snapshot,Cancelled, orOutcomeUnknownError. Example:async for c in kg.subscribe(E)while the connection is reconnecting or rate-limited raises from the first__anext__and never yields a snapshot. JS PR feat(inputlayer-js): subscribe(), watch() and on() with typed Change events #241 runs the snapshot open throughopenWithBackoff('snapshot'), which retries with backoff on exactly these errors (transient()) and fails only on a refusal or a final connection close. The Python retry path (_reopen_loop) covers resync only. The design asks the two SDKs to behave the same, and it asks for resubscribe on reconnect. Fix: put the first open behind the same backoff loop as_reopen_loop, gated on_state == "opening", with_check_persistentinside it. Also send.unsubscribefirst when_registered(for example afterOutcomeUnknownError), so the retry is not refused asid_taken. Only a non-transient error orgiven_upshould end the iterator.packages/inputlayer-py/tests/subscriptions_live.py:193- The live tests porttests/differential_oracle/generate.rsto Python instead of calling it. The intent says 'reusing the tests/differential_oracle history generator'. I checked the port against the Rust code and it matches: SplitMix64, the 0-6/7-11/12/13/14-17/18 branches, BTreeSet ordering via sorted()/min(), and evaluation order.test_history_matches_the_oracle_generatorpins the first steps of seed 1. Because it is a copy, it can drift silently if generate.rs changes. No action is needed now.🔧 Fix applied.
3 issues (2 warnings, 1 info) still open:
packages/inputlayer-py/src/inputlayer/subscription.py:450- The first open does not retry transient errors, so Python behaves differently from the JS twin._startcalls_open("snapshot")once, and any exception goes to_fail(e). A transient error on the first.subscribetherefore ends the iterator:ConnectionLost(the link drops while opening, see_on_linklines 713-714),RateLimited, aDeadlineExceededon a large snapshot,Cancelled, orOutcomeUnknownError. Example:async for c in kg.subscribe(E)while the connection is reconnecting or rate-limited raises from the first__anext__and never yields a snapshot. JS PR feat(inputlayer-js): subscribe(), watch() and on() with typed Change events #241 runs the snapshot open throughopenWithBackoff('snapshot'), which retries with backoff on exactly these errors (transient()) and fails only on a refusal or a final connection close. The Python retry path (_reopen_loop) covers resync only. The design asks the two SDKs to behave the same, and it asks for resubscribe on reconnect. Fix: put the first open behind the same backoff loop as_reopen_loop, gated on_state == "opening", with_check_persistentinside it. Also send.unsubscribefirst when_registered(for example afterOutcomeUnknownError), so the retry is not refused asid_taken. Only a non-transient error orgiven_upshould end the iterator.packages/inputlayer-py/tests/subscriptions_live.py:193- The live tests porttests/differential_oracle/generate.rsto Python instead of calling it. The intent says 'reusing the tests/differential_oracle history generator'. I checked the port against the Rust code and it matches: SplitMix64, the 0-6/7-11/12/13/14-17/18 branches, BTreeSet ordering via sorted()/min(), and evaluation order.test_history_matches_the_oracle_generatorpins the first steps of seed 1. Because it is a copy, it can drift silently if generate.rs changes. No action is needed now.packages/inputlayer-py/src/inputlayer/subscription.py:566- The round-1 fix added_open_with_backoff("snapshot"), and it now retries forever when the connection is already closed for good at subscribe time. It is supposed to stop on a given-up connection, but it only learns of one through thegiven_uplink event (_on_link)._startregisters that listener only when the subscription opens. If the connection gave up earlier, the event already fired and never fires again.Failing sequence:
auto_reconnect=False(or reconnect attempts run out, or a final notice arrives), the link drops, and_give_upsets the state toclosedwith_reopenable=False. Later the app runsasync for c in kg.subscribe(E). Each attempt reachesConnection._ensure_open(connection.py:579-581), which raisesConnectionLost("Not connected: the connection is closed")._transientcounts that as retryable, so the loop backs off up to 30s and tries again indefinitely. The first__anext__never returns and never raises. The sync client's blocking iterator hangs the same way. A non-lazyConnectionclosed byclose()behaves the same:_reopenableis true butlazyis false, so it also lands on line 579.Before this fix round, the same call raised
ConnectionLostat once. The recorded user decision requires "a given-up connection still stop the retries", and the JS twin fails on a final connection close.Fix: in the
exceptof_open_with_backoff, call_fail(e)whenisinstance(e, ConnectionLost) and self._conn.state == "closed". A lazy connection that can reopen would have reopened instead of raising, and a dropping link reportsreconnecting, so this check only catches a connection that is closed for good. Another option is a check in_startbefore the loop. Add a unit test that subscribes after the connection has given up and expectsConnectionLost. The resync path is not affected, because its listener is registered before any give-up can happen.🔧 Fix applied.
1 info still open:
packages/inputlayer-py/tests/subscriptions_live.py:193- The live tests porttests/differential_oracle/generate.rsto Python instead of calling it. The intent says 'reusing the tests/differential_oracle history generator'. I checked the port against the Rust code and it matches: SplitMix64, the 0-6/7-11/12/13/14-17/18 branches, BTreeSet ordering via sorted()/min(), and evaluation order.test_history_matches_the_oracle_generatorpins the first steps of seed 1. Because it is a copy, it can drift silently if generate.rs changes. No action is needed now.✅ **Test** - passed
✅ No issues found.
cargo build --release --bin inputlayer-servermake python-sdk-live PY_LIVE_PORT=8197(starts a disposable server with a temp data dir and runs docs_queries_live.py, compile_rules_live.py and subscriptions_live.py: 58 passed)uv run --extra dev python ~/.no-mistakes/evidence/01M43H9FB9Q10HGP9AX4J9GDNH/drive_scenarios.py target/release/inputlayer-server: a manual live script on its own port and temp data dir that stops and restarts the server; covers the sync iterator, subscribing on a given-up connection, and a first open while the server is downuv run --extra dev pytest tests/test_subscription.py -q(targeted unit regressions for the fix rounds: 33 passed)✅ **Document** - passed
✅ No issues found.
packages/inputlayer-py/src/inputlayer/knowledge_graph.py-ruff checkpasses on every changed file.ruff format --checkwould still reformat client_sync.py, compiler.py, connection.py, exceptions.py and knowledge_graph.py, andmypy(strict) reports 45 errors across the package. Both were already present at the base commit: the same five files failed the format check at 1ee8da7, and every flagged mypy line in the touched files comes from the base. This change added no new issues. I did not reformat whole files, since that would mix unrelated churn into this change. A separate format and mypy cleanup could follow.✅ **Push** - passed
✅ No issues found.