Skip to content

feat(inputlayer-py): subscribe(), watch() and on() with typed Change events - #245

Merged
jsam merged 6 commits into
mainfrom
fm/sdk-p1-f
Oct 4, 2026
Merged

jsam merged 6 commits into
mainfrom
fm/sdk-p1-f

Conversation

@jsam

@jsam jsam commented Oct 4, 2026 •

Copy link
Copy Markdown
Contributor

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

  • Adds a subscription API to the Python SDK (new subscription.py). KnowledgeGraph.subscribe() yields typed Change events (snapshot, delta, unverified or resync). 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 an unverified event, followed by a resync that carries the exact difference. watch() yields the whole current set with a verified flag, and on() returns a handle with close(). subscribe() raises SubscriptionRejected(reason) for limit/offset, OR conditions and session views. The sync client gets blocking iterators, and Change, Live, Row, Subscription, SubscriptionHandle, SubscriptionStats and SubscriptionRejected are exported.
  • Changes Connection to support resubscribe on reconnect. It now has link listeners (lost, given_up, closed) and a closed_for_good property. Pushes that arrive before their .subscribe reply are held and then filtered by its generation, and remove_route only 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. QueryPlan gains public pick() and from_engine() so a projection keeps its duplicate rows.
  • Adds unit tests against a scripted engine and live tests (tests/subscriptions_live.py, now part of make 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 document subscribe(), watch() and on().

Risk Assessment

⚠️ Medium: The subscription state machine is large and new. Chunk assembly, seq/generation checks, multiplicity, the bounded queue, slow-consumer and reconnect resync all traced correctly. One gap remains: a transient error on the first open ends the subscription instead of retrying, which diverges from the JS twin. That can be fixed in a follow-up.

Testing

I built the release server and ran the repo's make python-sdk-live target 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.

  • Live validation: ✅ go - 12 of 12 scenarios driven live against the product
Scenario Result Live Evidence
Snapshot plus deltas from subscribe(E) equal a fresh query at every checkpoint over seeded random histories (oracle generator port) ✅ pass live python-sdk-live.log: test_history_matches_the_oracle_generator, test_snapshot_plus_deltas_equals_a_fresh_query_over_random_histories PASSED
Projection with two supporting tuples reports only 0<->1 transitions ✅ pass live python-sdk-live.log: test_projection_two_supporting_tuples PASSED
Chunked snapshots and deltas are assembled into single events ✅ pass live python-sdk-live.log: test_streamed_snapshot_and_streamed_deltas_are_assembled PASSED
A seq gap and a subscription_reset each yield an unverified event, then an exact resync ✅ pass live python-sdk-live.log: test_a_seq_gap_and_a_reset_end_in_unverified_then_an_exact_resync PASSED
A slow consumer overflowing the bounded queue gets unverified:slow_consumer, then a resync ✅ pass live python-sdk-live.log: test_a_slow_consumer_gets_unverified_then_a_resync_once_it_has_read PASSED
After the link drops, the consumer gets unverified:connection_lost at once, then a resync with only the rows that changed ✅ pass live python-sdk-live.log: test_reconnect_gives_unverified_at_once_then_only_what_changed PASSED
An ACL revoke ends the subscription with SubscriptionRejected('access_denied') ✅ pass live python-sdk-live.log: test_an_acl_revoke_ends_the_subscription_with_access_denied PASSED
subscribe() refuses limit/offset, OR conditions and session views with SubscriptionRejected(reason) ✅ pass live python-sdk-live.log: test_refused_before_or_at_the_engine PASSED
watch() yields the whole current set with a verified flag, and on(X, cb) calls back and close() stops it ✅ pass live python-sdk-live.log: test_watch_yields_the_whole_result_and_on_calls_back PASSED
The sync client's blocking iterator yields the snapshot, then a delta after another client inserts a row ✅ pass live drive_scenarios.log S1: snapshot [E(1,1),E(2,2)] then delta inserted=[E(3,3)] seq=1 verified=True
Adversarial: with auto_reconnect=False, after the server dies and the connection gives up, a new subscribe() raises ConnectionLost at once instead of retrying forever ✅ pass live drive_scenarios.log S2: closed_for_good=True; raised ConnectionLost after 0.001s
Adversarial: subscribe() while the server is down and the connection is reconnecting retries transient errors and yields the snapshot once the server is back ✅ pass live drive_scenarios.log S3: first event still pending with no error after 2s; verified snapshot 0.34s after the server restarted
Evidence: make python-sdk-live transcript (58 passed against a real engine)
    Finished `release` profile [optimized] target(s) in 0.16s
Resolved 67 packages in 1ms
Checked 63 packages in 0.76ms
============================= test session starts ==============================
platform linux -- Python 3.10.22, pytest-9.1.1, pluggy-1.6.0 -- ~/.no-mistakes/worktrees/9eea230c3aae/01M43H9FB9Q10HGP9AX4J9GDNH/packages/inputlayer-py/.venv/bin/python
cachedir: .pytest_cache
rootdir: ~/.no-mistakes/worktrees/9eea230c3aae/01M43H9FB9Q10HGP9AX4J9GDNH/packages/inputlayer-py
configfile: pyproject.toml
plugins: langsmith-0.12.1, asyncio-1.3.0, anyio-4.13.0
asyncio: mode=auto, debug=False, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function
collecting ... collected 58 items

tests/docs_queries_live.py::test_basic_queries_all_rows PASSED           [  1%]
tests/docs_queries_live.py::test_basic_queries_with_a_filter PASSED      [  3%]
tests/docs_queries_live.py::test_selecting_specific_columns PASSED       [  5%]
tests/docs_queries_live.py::test_a_projection_is_a_set PASSED            [  6%]
tests/docs_queries_live.py::test_joins PASSED                            [  8%]
tests/docs_queries_live.py::test_self_joins PASSED                       [ 10%]
tests/docs_queries_live.py::test_computed_columns PASSED                 [ 12%]
tests/docs_queries_live.py::test_ordering_and_pagination PASSED          [ 13%]
tests/docs_queries_live.py::test_sort_and_limit_run_in_the_engine PASSED [ 15%]
tests/docs_queries_live.py::test_aggregations_group_by_department_with_stats PASSED [ 17%]
tests/docs_queries_live.py::test_agg_two_measures_same_column PASSED     [ 18%]
tests/docs_queries_live.py::test_aggregates_leave_no_session_rule PASSED [ 20%]
tests/docs_queries_live.py::test_count_without_a_column PASSED           [ 22%]
tests/docs_queries_live.py::test_aggregations_top_k_per_group PASSED     [ 24%]
tests/docs_queries_live.py::test_aggregate_ordered_and_limited PASSED    [ 25%]
tests/docs_queries_live.py::test_working_with_results PASSED             [ 27%]
tests/docs_queries_live.py::test_query_plans PASSED                      [ 29%]
tests/docs_queries_live.py::test_debug_takes_the_arguments_of_query PASSED [ 31%]
tests/docs_queries_live.py::test_debug_shows_the_plan_of_an_aggregate_query PASSED [ 32%]
tests/docs_queries_live.py::test_why_returns_the_selected_columns_with_a_proof_per_row PASSED [ 34%]
tests/docs_queries_live.py::test_why_explains_an_aggregate_query PASSED  [ 36%]
tests/docs_queries_live.py::test_why_returns_a_column_of_the_second_joined_relation PASSED [ 37%]
tests/docs_queries_live.py::test_why_returns_a_computed_column PASSED    [ 39%]
tests/docs_queries_live.py::test_an_offset_without_a_limit_pages_in_the_engine PASSED [ 41%]
tests/docs_queries_live.py::test_why_orders_and_paginates_like_query PASSED [ 43%]
tests/docs_queries_live.py::test_why_returns_the_distinct_rows_of_a_projection_like_query PASSED [ 44%]
tests/docs_queries_live.py::test_paging_a_projection_ordered_by_an_unselected_column_is_refused PASSED [ 46%]
tests/docs_queries_live.py::test_why_orders_and_limits_an_aggregate_query_like_query PASSED [ 48%]
tests/docs_queries_live.py::test_raw_iql PASSED                          [ 50%]
tests/docs_queries_live.py::test_derived_relations_querying_a_recursive_rule PASSED [ 51%]
tests/docs_queries_live.py::test_sessions_session_facts_mix_with_persistent_data PASSED [ 53%]
tests/docs_queries_live.py::test_session_insert_keeps_every_fact PASSED  [ 55%]
tests/docs_queries_live.py::test_distance_functions PASSED               [ 56%]
tests/docs_queries_live.py::test_temporal_functions PASSED               [ 58%]
tests/docs_queries_live.py::test_or_conditions_merge_their_branches PASSED [ 60%]
tests/compile_rules_live.py::test_every_fixture_command_runs_and_rejected_spellings_fail PASSED [ 62%]
tests/compile_rules_live.py::test_rule_definition_reads_the_clauses PASSED [ 63%]
tests/compile_rules_live.py::test_session_rules_list_and_drop_by_clause PASSED [ 65%]
tests/compile_rules_live.py::test_datetime_columns_define_insert_and_read_back PASSED [ 67%]
tests/compile_rules_live.py::test_in_and_not_in_queries PASSED           [ 68%]
tests/compile_rules_live.py::test_in_in_a_rule_and_a_conditional_delete PASSED [ 70%]
tests/compile_rules_live.py::test_ground_negation_binds_a_constant_for_the_request_only PASSED [ 72%]
tests/compile_rules_live.py::test_negation_sharing_no_variable_is_refused_before_sending PASSED [ 74%]
tests/compile_rules_live.py::test_view_with_ground_negation PASSED       [ 75%]
tests/compile_rules_live.py::test_session_view_with_ground_negation PASSED [ 77%]
tests/compile_rules_live.py::test_negated_comparison PASSED              [ 79%]
tests/compile_rules_live.py::test_literals_round_trip_through_the_engine PASSED [ 81%]
tests/compile_rules_live.py::test_unrepresentable_values_are_refused_before_sending PASSED [ 82%]
tests/subscriptions_live.py::test_history_matches_the_oracle_generator PASSED [ 84%]
tests/subscriptions_live.py::test_snapshot_plus_deltas_equals_a_fresh_query_over_random_histories PASSED [ 86%]
tests/subscriptions_live.py::test_projection_two_supporting_tuples PASSED [ 87%]
tests/subscriptions_live.py::test_streamed_snapshot_and_streamed_deltas_are_assembled PASSED [ 89%]
tests/subscriptions_live.py::test_a_seq_gap_and_a_reset_end_in_unverified_then_an_exact_resync PASSED [ 91%]
tests/subscriptions_live.py::test_a_slow_consumer_gets_unverified_then_a_resync_once_it_has_read PASSED [ 93%]
tests/subscriptions_live.py::test_reconnect_gives_unverified_at_once_then_only_what_changed PASSED [ 94%]
tests/subscriptions_live.py::test_an_acl_revoke_ends_the_subscription_with_access_denied PASSED [ 96%]
tests/subscriptions_live.py::test_refused_before_or_at_the_engine PASSED [ 98%]
tests/subscriptions_live.py::test_watch_yields_the_whole_result_and_on_calls_back PASSED [100%]

============================= 58 passed in 22.23s ==============================
Evidence: Live script output: sync iterator, given-up connection, first-open retry

== S1 sync client: blocking iterator yields snapshot then delta
event 1: snapshot inserted= [E(a=1, b=1), E(a=2, b=2)] rev= 7 seq= 0 verified= True
event 2: delta inserted= [E(a=3, b=3)] retracted= [] seq= 1 verified= True
S1 PASS

== S2 auto_reconnect=False, server dies, connection gives up; new subscribe() fails fast
kg connection is il connection: False | kg conn state: open
kg connection state: closed closed_for_good: True
raised ConnectionLost after 0.001s: Not connected: the connection is closed
S2 PASS

== S3 first open while the server is down: transient failures retried, snapshot once it is back
kg connection state before subscribe: reconnecting
after 2s with server down, first event pending: True | errored: False
server restarted; first event after 0.34s: snapshot rows= [(1, 1), (2, 2), (3, 3)] verified= True
S3 PASS
Evidence: Live driver script
"""Live drive of the P1-F fix-round behaviours plus the sync iterator against a
disposable inputlayer-server (private port, temp data dir)."""

import asyncio
import os
import shutil
import subprocess
import sys
import tempfile
import threading
import time
import urllib.request

from inputlayer import ConnectionLost, InputLayer, InputLayerSync, Relation

BIN = sys.argv[1]
PORT = 8198
URL = f"ws://127.0.0.1:{PORT}/ws"
DATA = tempfile.mkdtemp(prefix="il-p1f-drive-")
server = None


class E(Relation):
    a: int
    b: int


def start():
    global server
    env = dict(os.environ, INPUTLAYER_ADMIN_PASSWORD="admin")
    server = subprocess.Popen(
        [BIN, "--host", "127.0.0.1", "--data-dir", DATA, "--port", str(PORT)],
        env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
    for _ in range(80):
        try:
            urllib.request.urlopen(f"http://127.0.0.1:{PORT}/health", timeout=1)
            return
        except Exception:
            time.sleep(0.25)
    raise RuntimeError("server did not start")


def stop():
    server.terminate()
    server.wait()


def log(*a):
    print(*a, flush=True)


async def seed():
    il = InputLayer(URL, username="admin", password="admin")
    await il.connect()
    kg = il.knowledge_graph("p1f_drive")
    await kg.define(E)
    await kg.insert([E(a=1, b=1), E(a=2, b=2)])
    await il.close()


def scenario_sync_iterator():
    log("\n== S1 sync client: blocking iterator yields snapshot then delta")
    with InputLayerSync(URL, username="admin", password="admin") as il:
        kg = il.knowledge_graph("p1f_drive")
        sub = kg.subscribe(E)
        it = iter(sub)
        first = next(it)
        log("event 1:", first.kind, "inserted=", first.inserted, "rev=", first.revision,
            "seq=", first.seq, "verified=", first.verified)
        assert first.kind == "snapshot" and sorted(r.a for r in first.inserted) == [1, 2]
        with InputLayerSync(URL, username="admin", password="admin") as w:
            w.knowledge_graph("p1f_drive").insert(E(a=3, b=3))
        second = next(it)
        log("event 2:", second.kind, "inserted=", second.inserted, "retracted=", second.retracted,
            "seq=", second.seq, "verified=", second.verified)
        assert second.kind == "delta" and second.inserted == [E(a=3, b=3)] and second.seq == 1
        sub.close()
    log("S1 PASS")


async def scenario_given_up():
    log("\n== S2 auto_reconnect=False, server dies, connection gives up; new subscribe() fails fast")
    il = InputLayer(URL, username="admin", password="admin", auto_reconnect=False)
    await il.connect()
    kg = il.knowledge_graph("p1f_drive")
    await kg.query(E)  # open the graph's own connection
    log("kg connection is il connection:", kg._conn is il._conn, "| kg conn state:", kg._conn.state)
    stop()
    for _ in range(100):
        if kg._conn.state == "closed":
            break
        await asyncio.sleep(0.05)
    log("kg connection state:", kg._conn.state, "closed_for_good:", kg._conn.closed_for_good)
    t0 = time.monotonic()
    try:
        await asyncio.wait_for(kg.subscribe(E).__anext__(), 10)
        log("S2 FAIL: got an event")
        raise SystemExit(1)
    except ConnectionLost as e:
        log(f"raised {type(e).__name__} after {time.monotonic() - t0:.3f}s: {e}")
    except asyncio.TimeoutError:
        log("S2 FAIL: hung (no event, no error within 10s)")
        raise SystemExit(1)
    await il.close()
    log("S2 PASS")


async def scenario_first_open_retry():
    log("\n== S3 first open while the server is down: transient failures retried, snapshot once it is back")
    start()
    il = InputLayer(URL, username="admin", password="admin", reconnect_delay=0.1,
                    max_reconnect_attempts=1000)
    await il.connect()
    kg = il.knowledge_graph("p1f_drive")
    await kg.query(E)  # make sure the graph is selected
    stop()
    for _ in range(100):
        if kg._conn.state != "open":
            break
        await asyncio.sleep(0.05)
    log("kg connection state before subscribe:", kg._conn.state)
    sub = kg.subscribe(E)
    task = asyncio.ensure_future(sub.__anext__())
    await asyncio.sleep(2.0)
    log("after 2s with server down, first event pending:", not task.done(),
        "| errored:", task.done() and task.exception() is not None)
    assert not task.done(), task.exception() if task.done() else None
    t = time.monotonic()
    threading.Thread(target=start).start()
    ev = await asyncio.wait_for(task, 60)
    log(f"server restarted; first event after {time.monotonic() - t:.2f}s:",
        ev.kind, "rows=", sorted((r.a, r.b) for r in ev.inserted), "verified=", ev.verified)
    assert ev.kind == "snapshot" and ev.verified
    await sub.close()
    await il.close()
    log("S3 PASS")


try:
    start()
    asyncio.run(seed())
    scenario_sync_iterator()
    asyncio.run(scenario_given_up())
    asyncio.run(scenario_first_open_retry())
finally:
    if server and server.poll() is None:
        stop()
    shutil.rmtree(DATA, ignore_errors=True)
Evidence: Fix-round live results
S1 snapshot inserted=[E(1,1),E(2,2)] seq=0 verified=True -> delta inserted=[E(3,3)] seq=1 PASS
S2 kg conn closed_for_good=True; raised ConnectionLost after 0.001s PASS
S3 conn reconnecting; first event still pending after 2s with no error; server restarted -> snapshot after 0.34s verified=True PASS

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

⚠️ **Review** - 1 info
  • ⚠️ packages/inputlayer-py/src/inputlayer/subscription.py:450 - The first open does not retry transient errors, so Python behaves differently from the JS twin. _start calls _open(&#34;snapshot&#34;) once, and any exception goes to _fail(e). A transient error on the first .subscribe therefore ends the iterator: ConnectionLost (the link drops while opening, see _on_link lines 713-714), RateLimited, a DeadlineExceeded on a large snapshot, Cancelled, or OutcomeUnknownError. 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 through openWithBackoff(&#39;snapshot&#39;), 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 == &#34;opening&#34;, with _check_persistent inside it. Also send .unsubscribe first when _registered (for example after OutcomeUnknownError), so the retry is not refused as id_taken. Only a non-transient error or given_up should end the iterator.
  • ℹ️ packages/inputlayer-py/tests/subscriptions_live.py:193 - The live tests port tests/differential_oracle/generate.rs to 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_generator pins 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. _start calls _open(&#34;snapshot&#34;) once, and any exception goes to _fail(e). A transient error on the first .subscribe therefore ends the iterator: ConnectionLost (the link drops while opening, see _on_link lines 713-714), RateLimited, a DeadlineExceeded on a large snapshot, Cancelled, or OutcomeUnknownError. 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 through openWithBackoff(&#39;snapshot&#39;), 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 == &#34;opening&#34;, with _check_persistent inside it. Also send .unsubscribe first when _registered (for example after OutcomeUnknownError), so the retry is not refused as id_taken. Only a non-transient error or given_up should end the iterator.
  • ℹ️ packages/inputlayer-py/tests/subscriptions_live.py:193 - The live tests port tests/differential_oracle/generate.rs to 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_generator pins 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(&#34;snapshot&#34;), 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 the given_up link event (_on_link). _start registers 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_up sets the state to closed with _reopenable=False. Later the app runs async for c in kg.subscribe(E). Each attempt reaches Connection._ensure_open (connection.py:579-581), which raises ConnectionLost(&#34;Not connected: the connection is closed&#34;). _transient counts 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-lazy Connection closed by close() behaves the same: _reopenable is true but lazy is false, so it also lands on line 579.

Before this fix round, the same call raised ConnectionLost at 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 except of _open_with_backoff, call _fail(e) when isinstance(e, ConnectionLost) and self._conn.state == &#34;closed&#34;. A lazy connection that can reopen would have reopened instead of raising, and a dropping link reports reconnecting, so this check only catches a connection that is closed for good. Another option is a check in _start before the loop. Add a unit test that subscribes after the connection has given up and expects ConnectionLost. 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 port tests/differential_oracle/generate.rs to 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_generator pins 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.

  • Live validation: ✅ go - 12 of 12 scenarios driven live against the product
Scenario Result Live Evidence
Snapshot plus deltas from subscribe(E) equal a fresh query at every checkpoint over seeded random histories (oracle generator port) ✅ pass live python-sdk-live.log: test_history_matches_the_oracle_generator, test_snapshot_plus_deltas_equals_a_fresh_query_over_random_histories PASSED
Projection with two supporting tuples reports only 0<->1 transitions ✅ pass live python-sdk-live.log: test_projection_two_supporting_tuples PASSED
Chunked snapshots and deltas are assembled into single events ✅ pass live python-sdk-live.log: test_streamed_snapshot_and_streamed_deltas_are_assembled PASSED
A seq gap and a subscription_reset each yield an unverified event, then an exact resync ✅ pass live python-sdk-live.log: test_a_seq_gap_and_a_reset_end_in_unverified_then_an_exact_resync PASSED
A slow consumer overflowing the bounded queue gets unverified:slow_consumer, then a resync ✅ pass live python-sdk-live.log: test_a_slow_consumer_gets_unverified_then_a_resync_once_it_has_read PASSED
After the link drops, the consumer gets unverified:connection_lost at once, then a resync with only the rows that changed ✅ pass live python-sdk-live.log: test_reconnect_gives_unverified_at_once_then_only_what_changed PASSED
An ACL revoke ends the subscription with SubscriptionRejected('access_denied') ✅ pass live python-sdk-live.log: test_an_acl_revoke_ends_the_subscription_with_access_denied PASSED
subscribe() refuses limit/offset, OR conditions and session views with SubscriptionRejected(reason) ✅ pass live python-sdk-live.log: test_refused_before_or_at_the_engine PASSED
watch() yields the whole current set with a verified flag, and on(X, cb) calls back and close() stops it ✅ pass live python-sdk-live.log: test_watch_yields_the_whole_result_and_on_calls_back PASSED
The sync client's blocking iterator yields the snapshot, then a delta after another client inserts a row ✅ pass live drive_scenarios.log S1: snapshot [E(1,1),E(2,2)] then delta inserted=[E(3,3)] seq=1 verified=True
Adversarial: with auto_reconnect=False, after the server dies and the connection gives up, a new subscribe() raises ConnectionLost at once instead of retrying forever ✅ pass live drive_scenarios.log S2: closed_for_good=True; raised ConnectionLost after 0.001s
Adversarial: subscribe() while the server is down and the connection is reconnecting retries transient errors and yields the snapshot once the server is back ✅ pass live drive_scenarios.log S3: first event still pending with no error after 2s; verified snapshot 0.34s after the server restarted
  • cargo build --release --bin inputlayer-server
  • make 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 down
  • uv run --extra dev pytest tests/test_subscription.py -q (targeted unit regressions for the fix rounds: 33 passed)
✅ **Document** - passed

✅ No issues found.

⚠️ **Lint** - 1 info
  • ℹ️ packages/inputlayer-py/src/inputlayer/knowledge_graph.py - ruff check passes on every changed file. ruff format --check would still reformat client_sync.py, compiler.py, connection.py, exceptions.py and knowledge_graph.py, and mypy (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.

jsam added 3 commits October 4, 2026 13:21
…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
jsam marked this pull request as ready for review October 4, 2026 13:41
@jsam
jsam merged commit c649d12 into main Oct 4, 2026
20 checks passed
@jsam
jsam deleted the fm/sdk-p1-f branch October 4, 2026 13:42
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