Feat/retire aimdb ws protocol - #201
Conversation
…sport on AimX The mapping-table design doc 036 A2 gated the protocol unification on (038 §3.9, 034 §3.10 rc10): AimX ↔ ws-protocol frame mapping, the gap decisions (wildcard subscribe via optional Event.topic, Snapshot routing via a new sub field, the explicit subscribed ack, the 6→3 error-code collapse, shared record.query/record.list result shapes), two scope corrections verified against the code (the aimdb-client demux fold was already done in PR #124; query passthrough already existed — only the result shape was missing), and the go/no-go. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…r AimX (design 045)
The two features that justified the ws-protocol fork, added to AimX so
every transport inherits them:
- session::topic_match — the MQTT-style matcher moved in from
aimdb-ws-protocol (same semantics, same tests) plus is_wildcard.
- Subscription streams yield SubUpdate {topic: Option<Arc<str>>, data}
on both engines; Outbound::Event gains an optional topic and
Outbound::Snapshot the routing sub, so a wildcard subscription's
events and late-join snapshots name the record that fired.
Session::snapshot becomes snapshots() — one per covered record.
- AimxDispatch matches wildcard patterns against the registry once at
subscribe time (builder-frozen record set), merges matched streams
under one subscription id, and snapshots each match on open.
- AimxCodec learns the {"t":"subscribed"} ack (servers running
acks_subscribe:true — the WS connector) and gets a dedicated
roundtrip test suite; exact-topic frames are unchanged on the wire.
- ClientConfig::topic_routed_subs removed — it existed solely for the
retired ws wire; all subscriptions are id-routed.
- Shared result vocabulary: remote::QueryRecord {topic, payload, ts},
with_persistence's QueryHandlerFn returning {records, total} (sorted
by ts), and RecordMetadata gaining optional schema_type/entity.
- aimdb-client: AimxConnection::subscribe_with_topics yields
(Option<String>, Value) pairs; wildcard e2e test over the production
UDS server; query-shape test against the persistence registration.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ign 045)
The wire is now AimX — one tagged frame per WS text message, the same
envelope as UDS/serial/TCP; the delta really was just the envelope
(everything below the codec already rode the shared session engine).
- run_session drives the shared AimxCodec; the 507-line per-connection
WsCodec and its id↔topic maps are deleted, as are the multi-topic
Subscribe split and the protocol re-export shim.
- ClientManager delivers topic-tagged SubUpdates; the per-frame envelope
is applied by each connection's codec (payload bytes stay Arc-shared).
The pre-serialized Data frame, its server-side ts, and with_raw_payload
are gone.
- WsDispatch converges on the AimX vocabulary: record.query via a
plugged-in QueryHandler (now returning core QueryRecord rows) or the
Extensions QueryHandlerFn from with_persistence; record.list answers
{name, schema_type, entity} rows (TopicInfo now lives here).
Errors collapse to the 3-code set; auth stays at the HTTP 401.
- SnapshotProvider::snapshots(pattern) returns every cached value under
the pattern, so wildcard subscriptions late-join each covered record.
- Auto-subscribe seeds AimX sub frames with ids counting down from
u64::MAX (cannot collide with client-chosen ids; design 045 §3.6).
- ws-client builder rides AimxCodec (topic_routed_subs gone); e2e tests
rewritten to drive raw AimX frames, including golden wire frames and
ws↔AimX parity for ack/wildcard/snapshot/query/list.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…c (design 045)
The browser is now a first-class engine client: a web_sys::WebSocket-
backed Connection/Dialer pair (JS events funneled into the engine's
async recv; single-threaded wasm Send shims, same pattern and compile
guard as SendFuture) drives run_client + AimxCodec, so reply and
subscription correlation, reconnect backoff, keepalive, and the offline
queue exist exactly once — in core. The hand-rolled 835-line demux is
gone.
The #[wasm_bindgen] surface is preserved (write, query, listTopics,
onStatusChange, status, disconnect, connectBridge options; lateJoin is
retained for option-shape compatibility — snapshots are server-driven
under AimX). Subscription pumps mirror topic-tagged updates into local
records and re-subscribe when a stream ends; the queued subscribe
replays after the engine redials. WasmDb.discover speaks a one-shot
record.list request and resolves {name, schema_type, entity} rows.
Depends on aimdb-core's connector-session + remote features (the
engines cross-compile to wasm32); the aimdb-ws-protocol dep is gone.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…idue (design 045) With no consumers left, the fork is gone: - aimdb-ws-protocol crate deleted (353 lines) and de-registered from the workspace members, Makefile build/test/clippy/doc targets, and the publish sequence (17 → 16 crates). - aimdb-client: dead NDJSON helpers retired with the hand-rolled client in PR #124 are deleted (RequestExt/ResponseExt/serialize_message/ parse_message/EventMessage/cli_hello — grep-confirmed zero consumers); the protocol re-export keeps RecordMetadata/WelcomeMessage/Request/ Response/Event. - design 038 §2.5/§3.9 annotated as resolved by 045; root and per-crate CHANGELOGs describe the breaking release. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nents and documentation
…ancelToken, CancelHandle, WasmWsConnection, WasmWsDialer and WsBridge
…ndshake and compatibility checks
…ns and related components
- Updated comments in `Cargo.toml` files to clarify transport features. - Revised comments in `engine.rs`, `aimx_session.rs`, and various other files to remove design references and improve clarity. - Removed outdated design references in `protocol.rs`, `query.rs`, and `codec.rs`. - Enhanced clarity in `dispatch.rs`, `builder_ext.rs`, and `http.rs` by simplifying comments. - Adjusted test descriptions in `query_handler_shape.rs` and `e2e.rs` for better understanding. - Cleaned up `ws_bridge.rs`, `registry.rs`, and `session.rs` to focus on current functionality without legacy design notes. - Updated `CHANGELOG.md` to reflect changes in `record.list` response structure and removed references to obsolete types. - Improved overall consistency and readability of comments throughout the codebase.
Reconcile main's direct-JSON-byte remote path (#192/#203) with design-048's SubUpdate.skipped loss-signal design: - Subscribe path keeps design-048's SubUpdate/skipped contract (the merged Session::subscribe trait yields BoxStream<SubUpdate>); stream.rs stays on the (Value, u64) design. - dispatch.rs combines both: SubUpdate subscribe + wildcard fan-in + WI1 hello() version gate, plus main's DispatchReply / record.get fast path. Restored the futures_util::StreamExt import main had dropped. - aimdb-bench/Cargo.toml unions both bench sets (fanout_encode + remote_json); remote_json.rs updated for Outbound::Event's new `topic` field. main's direct-JSON-bytes optimization in the live subscribe path is superseded here and left as the documented follow-up (design 048, "removing the intermediate serde_json::Value"). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The subscribe path never inspects a record update between reading it and encoding it onto the wire, yet it parsed the buffer's JSON bytes into a `serde_json::Value` (`recv_json`) only to re-serialize the tree back to bytes (`to_payload`) at the dispatch boundary — a pure parse+re-serialize round-trip per update. This is the direct-JSON-bytes follow-up design 048 flagged. `stream_record_updates` now reads owned JSON bytes via `recv_json_bytes` and yields `(Payload, u64)`; the two `stream.map` sites in the AimX dispatch build `SubUpdate` directly from those bytes. The design-048 `skipped` loss signal is untouched — it still rides each `SubUpdate`. No wire or public API change. Verified with the `remote_json` subscription-event benchmark (Tree vs Direct): the direct-bytes path sustains ~2× the throughput of the former tree path, matching the 2.06–2.49× encode figure in design 048. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… for data integrity
| let _ = tx.try_send(data); | ||
| Ok(Outbound::Snapshot { sub, topic, data }) => { | ||
| if let Some(tx) = subs.get(sub) { | ||
| let _ = tx.try_send(Ok(SubUpdate::tagged(Arc::from(topic), data))); |
There was a problem hiding this comment.
Snapshot delivery bypasses the new loss accounting: try_send errors are ignored and snapshots carry no sequence or gap metadata.
I reproduced this with a wildcard subscription whose dispatch returned 300 snapshots. After a server-side RPC barrier and before draining the stream, only 256 updates were available, exactly SUBSCRIBE_CHANNEL_CAP with no error or skipped signal.
That breaks the "one snapshot per matched record" and later event sequence numbers cannot reveal which initial states were lost. Could snapshot setup apply backpressure or surface an explicit overflow/error/gap?
I think we should add a regression covering more than 256 matched records.
There was a problem hiding this comment.
Fixed: snapshots now carry seq in the subscription's sequence space and route through the same deliver() gap accounting as events, so overflow surfaces as skipped.
Regression added: oversized_snapshot_burst_surfaces_as_a_gap (300 records, verified failing pre-fix).
There was a problem hiding this comment.
Sharing the snapshot/event sequence space fixes a dropped snapshot once some later update is delivered. However, the tail of a snapshot-only burst is still silent until that later update exists.
I reran the 300-record case but checked accounting before injecting event_tx.send(...): the stream delivered 256/300 snapshots, every delivered item had skipped == 0 and then it remained open. A static subscription may never produce the "first event" used by the regression, so the consumer still cannot distinguish a complete snapshot from a truncated one.
Could snapshot completion itself provide backpressure or an explicit completion/overflow signal, without relying on a future live event? I think this thread should remain open until the snapshot-only case is covered.
There was a problem hiding this comment.
Fixed: the burst's final snapshot is now flagged and the client reserves a sink slot for it. So your 300-record case delivers 255 + 1 flagged snapshot_end with skipped == 44. It is auditable the moment the burst ends, no live event needed.
Still uncovered and documented rather than fixed: the flag rides the final snapshot's frame, so it's lost if that frame fails to encode or decodes as malformed and a pattern matching zero records emits no burst at all.
|
|
||
| // Frame funnel: onmessage pushes text frames; onclose closes it so | ||
| // the engine's `recv` observes end-of-stream. | ||
| let (frame_tx, frames) = futures_channel::mpsc::unbounded::<Vec<u8>>(); |
There was a problem hiding this comment.
This creates an unbounded, frame-sized queue between the browser callbacks and the client engine. A fast server or stalled WASM executor can therefore grow Rust-owned browser memory w/o limit before the bounded subscription channels are reached.
Because this queue also carries RPC replies, silently dropping individual frames would leave calls unresolved. Could this be bounded and treat overflow as a transport failure, closing the connection and letting reconnect policy recover with a burst regression?
There was a problem hiding this comment.
This one seems was not addressed yet. The browser frame funnel is still an UnboundedReceiver, created with mpsc::unbounded, and on_message still uses unbounded_send.
The disconnect-handshake fix in this file looks good, but it does not bound inbound frame memory or define overflow behavior. Could we keep this thread open and either bound the funnel with overflow treated as a transport failure or explicitly document why unbounded browser-owned delivery is accepted?
There was a problem hiding this comment.
Agreed. I bounded to 1024 frames. An overflow now closes the stream instead of dropping frames, which the engine reads as a disconnect (so pending calls reject rather than hang) and the re-subscribe re-syncs from scratch — which I'd argue isn't really a regression, since after an overflow there's no way to tell what was lost.
| match futures_util::future::select(call, timeout).await { | ||
| futures_util::future::Either::Left((reply, _)) => reply, | ||
| futures_util::future::Either::Right(((), _)) => { | ||
| return Err(JsValue::from_str("Request timed out")) |
There was a problem hiding this comment.
When this timeout wins, dropping call drops only its oneshot receiver. If the request was already sent, drive_connection has stored the corresponding sender in its pending map and that entry is not removed until a reply or disconnect arrives.
A peer that keeps the connection open but never replies therefore retains one pending entry per timed-out request. Could the engine prune canceled oneshot senders or support explicit call cancellation? A repeated-timeout regression would be useful here.
There was a problem hiding this comment.
Fixed: the engine now prunes canceled oneshot senders before each insert
There was a problem hiding this comment.
This bounds the sequential-timeout case, but cancellation still does not wake the engine.
I tested a concurrent batch where 1k calls are all tracked while their receivers are live, then all receivers are dropped after the final insert. With keep-alive disabled and no later call, all 1k cancelled entries remain until disconnect because there is no subsequent path that invokes prune_canceled.
There is also a performance side effect track_call runs HashMap::retain before every live insert, making a burst of N outstanding calls O(N^2).
Could we cover concurrent timeouts with keepalive disabled and use an explicit cancellation/wakeup, an amortized reclaim strategy or a bounded in-flight table? I think the current sequential regression does not fully close the original lifetime issue.
There was a problem hiding this comment.
Both fair. I replaced the sweep with explicit cancellation: ClientHandle::call now emits ClientCmd::CancelCall { id } when its future drops, so the engine removes the entry by id in O(1) with no keepalive or later call needed (retain is gone, so a burst of N is O(N)) and the 1k concurrent batch with keepalive disabled is now a unit test that asserts every entry is freed.
…DB client and CLI
… for transform join
… the client engine
… final frame flag
…prevent pending entry accumulation
…ve connection stability
…-chromedriver version compatibility
Retire
aimdb-ws-protocol— every transport speaks AimXDeletes the WebSocket-only wire protocol and converges the entire workspace on
the AimX-v3 envelope (
aimdb-core::session::aimx). One codec, one errormapping, one client demux — the WebSocket stack and the browser bridge now ride
the same session engine as UDS/serial/TCP and
aimdb-client.Implements design 047 (
docs/design/047-retire-ws-protocol-converge-on-aimx.md).Why
The workspace shipped two wire protocols for one feature. Every capability —
snapshots, acks, writes, queries, subscribe — existed twice, with two codecs and
two error vocabularies, plus a hand-rolled ~835-line browser demux duplicating
run_client. Both protocols already rode the same session engine, so the forkwas pure duplication.
Convergence removes a 353-line protocol crate, a 507-line codec, two error
mappings, and the browser demux, against a small additive AimX extension
(wildcard subscribe + two result-shape changes) that every transport now
inherits (CLI
watch 'temp.#', MCP wildcard reads, future serial/TCP browsers).What changed
Core (additive AimX extension)
#/*matches the (builder-frozen) record set once at subscribe time, merges the
matched records' update streams under one subscription id, and emits one
snapshot per matched record on open.
topic_matchmodule — MQTT-style matching over dot-separated keys, sharedby the core wildcard path and the WS fan-out bus.
SubUpdateitem type — subscription streams now carry{ topic: Option<Arc<str>>, data, skipped }so each event names the concreterecord that fired and threads buffer-lag counts through for drop detection.
topiconevent,subonsnap, an explicitsubscribedack frame (WS only; UDS/serial/TCP stay ack-implicit).record.query→{ records: [{topic, payload, ts}], total }(canonicalQueryRecordrow);RecordMetadatagainsschema_typeandentity.hello— protocol bumped2.0 → 3.0; an incompatible orversion-less peer is refused at the handshake (
RpcError::VersionMismatch).skipped; the server pump doesseq += skipped + 1; the client demux recovers loss as aseqshortfall.WebSocket connector
framing). Dispatch converged on
record.query/record.list.(topic, payload)asArc-shared updates; theper-connection codec envelopes each event (O(1) fan-out preserved).
record.queryfalls back to the persistence-registeredQueryHandlerFnwhenno custom handler is plugged in;
record.listrows are stamped with schemanames from the connector's
StreamableRegistry.WsCodec+ id↔topic maps,split_multi_topic, the pre-serializedData-frame broadcast,with_raw_payload,ClientConfig::topic_routed_subs.Browser bridge (
aimdb-wasm-adapter::ws_bridge)run_client+ClientHandleover aweb_sys::WebSocket-backedConnection/Dialer(single-threaded wasmSend/Syncwrappers). Reply andsubscription correlation now exists exactly once, in core.
write,query,listTopics,onStatusChange, offlinequeue, and reconnect are all delegated to the engine.
Deletions
aimdb-ws-protocolcrate,WsCodec,protocol.rsshims, deadaimdb-clientre-exports.
Breaking changes
2.0 → 3.0— pre-3.x clients are refused athello. Browserclients on the old ws-protocol wire must ship the rebuilt bridge; they fail
closed against a 3.0 server.
with_raw_payload,ClientConfig::topic_routed_subs.record.queryresult shape changed from{values:[{record,value,stored_at}], count}to{records:[{topic,payload,ts}],total}.Lands as one branch in a release already flagged protocol-breaking, one commit
per work item (no stacked PRs).
Testing
make checkandmake allgreen. New/updated coverage: AimX codec roundtripsuite (frames + splice byte-for-byte),
topic_matchtests,topic_leafdual-separator, version-compatibility matrix,
query_handler_shape,UDS
handshake_version, rewritten WebSockete2e.rs(subscribe ack, wildcardfan-out, late-join), and fan-out encode benchmarks.
Known limitations (accepted)
server-chosen sub ids (counted down from
u64::MAX); arun_client-basedconsumer drops events for ids it never issued, so engine clients (including the
new bridge) subscribe explicitly. No user-visible change (the pro hub already
subscribes by exact name).
time against the builder-frozen record set; MQTT-style late registration is
deferred until core grows runtime registration.