Skip to content

Feat/retire aimdb ws protocol - #201

Open
lxsaah wants to merge 76 commits into
mainfrom
feat/retire-aimdb-ws-protocol
Open

Feat/retire aimdb ws protocol#201
lxsaah wants to merge 76 commits into
mainfrom
feat/retire-aimdb-ws-protocol

Conversation

@lxsaah

@lxsaah lxsaah commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Retire aimdb-ws-protocol — every transport speaks AimX

Deletes the WebSocket-only wire protocol and converges the entire workspace on
the AimX-v3 envelope (aimdb-core::session::aimx). One codec, one error
mapping, 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 fork
was 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)

  • Wildcard / multi-record live subscribe. A pattern containing #/*
    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_match module — MQTT-style matching over dot-separated keys, shared
    by the core wildcard path and the WS fan-out bus.
  • SubUpdate item type — subscription streams now carry
    { topic: Option<Arc<str>>, data, skipped } so each event names the concrete
    record that fired and threads buffer-lag counts through for drop detection.
  • Wire additions: optional topic on event, sub on snap, an explicit
    subscribed ack frame (WS only; UDS/serial/TCP stay ack-implicit).
  • Query/list result shapes: record.query{ records: [{topic, payload, ts}], total } (canonical QueryRecord row); RecordMetadata gains
    schema_type and entity.
  • Version-gated hello — protocol bumped 2.0 → 3.0; an incompatible or
    version-less peer is refused at the handshake (RpcError::VersionMismatch).
  • Loss accounting — buffer lag folds into skipped; the server pump does
    seq += skipped + 1; the client demux recovers loss as a seq shortfall.

WebSocket connector

  • Speaks AimX on the transport (one codec blob per WS text frame — no extra
    framing). Dispatch converged on record.query / record.list.
  • The fan-out bus carries (topic, payload) as Arc-shared updates; the
    per-connection codec envelopes each event (O(1) fan-out preserved).
  • record.query falls back to the persistence-registered QueryHandlerFn when
    no custom handler is plugged in; record.list rows are stamped with schema
    names from the connector's StreamableRegistry.
  • Removed: WsCodec + id↔topic maps, split_multi_topic, the pre-serialized
    Data-frame broadcast, with_raw_payload, ClientConfig::topic_routed_subs.

Browser bridge (aimdb-wasm-adapter::ws_bridge)

  • Rewritten on run_client + ClientHandle over a web_sys::WebSocket-backed
    Connection/Dialer (single-threaded wasm Send/Sync wrappers). Reply and
    subscription correlation now exists exactly once, in core.
  • JS API preserved: write, query, listTopics, onStatusChange, offline
    queue, and reconnect are all delegated to the engine.

Deletions

  • aimdb-ws-protocol crate, WsCodec, protocol.rs shims, dead aimdb-client
    re-exports.

Breaking changes

  • Wire protocol 2.0 → 3.0 — pre-3.x clients are refused at hello. Browser
    clients on the old ws-protocol wire must ship the rebuilt bridge; they fail
    closed against a 3.0 server.
  • Removed builder knobs: with_raw_payload, ClientConfig::topic_routed_subs.
  • record.query result 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 check and make all green. New/updated coverage: AimX codec roundtrip
suite (frames + splice byte-for-byte), topic_match tests, topic_leaf
dual-separator, version-compatibility matrix, query_handler_shape,
UDS handshake_version, rewritten WebSocket e2e.rs (subscribe ack, wildcard
fan-out, late-join), and fan-out encode benchmarks.

Known limitations (accepted)

  • Auto-subscribe under engine demux — server-seeded subscriptions carry
    server-chosen sub ids (counted down from u64::MAX); a run_client-based
    consumer 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).
  • Dynamic record membership — wildcard matches are resolved once at subscribe
    time against the builder-frozen record set; MQTT-style late registration is
    deferred until core grows runtime registration.

test and others added 25 commits July 17, 2026 14:04
…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>
…ancelToken, CancelHandle, WasmWsConnection, WasmWsDialer and WsBridge
- 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>
@lxsaah
lxsaah requested a review from thaodt July 24, 2026 13:28
@lxsaah
lxsaah marked this pull request as ready for review July 24, 2026 13:28

@thaodt thaodt left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The previous correctness blockers are fixed: snapshot loss is now observable through the public API, request-ID exhaustion is refused rather than wrapped, pending calls are canceled in O(1), the in-flight WASM dial is interruptible, terminal subscription errors are preserved and the browser tests are running successfully in CI.

Again im requesting changes for one confirmed migration blocker: WsBridge.maxOfflineQueue is no longer a hard bound during an initial pending dial or reconnect backoff, despite the preserved public option and design contract.

I also left:

  • a security-contract question about the newly automatic persistence query and full record.list exposure
  • two lower-priority WASM findings covering synchronous constructor failure and frame-byte budgeting
  • a few documentation/counting corrections.

I intentionally did not charge the generic duplicate-subscription-ID issue to this PR because the retired ws codec could already trigger it by reusing the synthesized ID for a repeated topic subscription. Likewise, the unchecked serializer splice follows design 048's explicitly documented trusted-payload contract and is not being treated as a blocker here.

max_reconnect_delay: 8_000,
max_reconnect_attempts: 0,
keepalive_interval: (config.keepalive_ms > 0).then_some(config.keepalive_ms as u64),
max_offline_queue: config.max_offline_queue,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

run_client uses an unbounded command channel, while bound_offline_queue() runs only once before reconnect backoff. Commands accepted during an initial pending dial or during that sleep can exceed the configured cap; if the next dial succeeds, drive_connection flushes them without another trim.

I reproduced max_offline_queue = 2 returning from backoff with 102 queued commands. The retired bridge enforced this limit at enqueue time and design 047 says the JS offline-queue behavior is preserved.

Please enforce the cap continuously while offline, define whether the oldest or newest commands are dropped, and cover both a blocked initial dial and reconnect backoff.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: max_offline_queue is now enforced in ClientHandle::enqueue. The handle holds an MPMC receiver clone and trims to cap - 1 before the send, so the bound also covers the initial pending dial and the backoff sleep, the command being enqueued is never the one dropped and the reconnect_after trim is gone.

The drop policy is documented as oldest-first and is caller-visible — a trimmed call resolves Internal, a trimmed subscribe ends its stream — with tests for both the bound and the caller-visible drop.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moving the trim to ClientHandle::enqueue fixes the original initial-dial/backoff bypass.

Two hard-bound cases still remain I think:

  1. With max_offline_queue = 0, saturating_sub(1) produces keep = 0, the empty queue is not trimmed and the following try_send retains one command. A focused regression test observes cmd_rx.len() == 1. The retired wasm bridge retained no commands when the configured cap was zero.

  2. ClientHandle is cloneable, but len/trim followed by try_send is not one atomic admission operation. Concurrent handles can all observe room before sending. With cap 1, a focused multi-producer test reproducibly left a queue length of 2.

Could we serialize the trim-and-send admission across handle clones, or use a genuinely bounded queue with explicit eviction semantics? Please also cover cap 0 and concurrent cloned handles in the regression tests.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Took your second suggestion. The command channel is now async_channel::bounded and enqueue uses force_send. Cap 0 is clamped to 1 rather than honored, since every command reaches the engine through this channel and a zero-capacity one could never deliver. The default also drops from usize::MAX to 256 as the ring is preallocated, with regressions for the clamp at both ends.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The bounded command queue can evict the only CancelCall for an RPC that is already in pending, reopening the pending-call leak under saturation.

CancelOnDrop sends CancelCall through the same force_send queue as ordinary calls and writes, while enqueue discards the evicted item. I reproduced this deterministically with capacity 1, let the engine consume a call and insert its reply sender into pending, queue its CancelCall, then enqueue a write before the engine can receive again. The write evicts the cancellation after the engine sends the write, the canceled call's reply sender is still retained in pending.

The existing 1000-cancellation regression uses an unbounded channel, so it cannot cover this failure mode.

Since this is a correctness hole in the bounded-queue change itself, I think the specific eviction case should be fixed in this PR rather than deferred. A contained fix could either make cancellation non-evictable or detectan evicted CancelCall and trigger an engine-side prune of canceled pending senders, the reply sender already exposes is_canceled(). Please also add a saturated capacity-1 regression.

If the preferred long-term solution requires a larger control/data queue redesign, that redesign can be tracked in a follow-up issue, but the current cancellation-loss should be closed before merge.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in this PR with both halves of your suggestion combined: CancelOnDrop now uses a plain try_send so a cancellation never evicts caller work and enqueue detects an evicted CancelCall and raises a prune signal that wakes the engine to sweep pending by is_canceled(). Also added a saturated capacity-1 end-to-end regression reproducing your exact interleaving (call filed in pending, its queued cancellation evicted by a write) and verified it fails against the pre-fix behavior.

Comment thread aimdb-websocket-connector/src/server/dispatch.rs
Comment thread aimdb-wasm-adapter/src/ws_bridge.rs Outdated
Comment thread aimdb-wasm-adapter/src/ws_bridge.rs Outdated
Comment thread docs/design/047-retire-ws-protocol-converge-on-aimx.md Outdated
Comment thread docs/design/047-retire-ws-protocol-converge-on-aimx.md Outdated
Comment thread Makefile Outdated
@lxsaah

lxsaah commented Aug 2, 2026

Copy link
Copy Markdown
Contributor Author

Thanks, as I am on leave this week - I'll need some time to get back to it.

@lxsaah
lxsaah requested a review from thaodt August 6, 2026 21:17
@lxsaah

lxsaah commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

@thaodt I implemented the changes and the PR is ready for review again. Thanks!

@thaodt

thaodt commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

@thaodt I implemented the changes and the PR is ready for review again. Thanks!

hey @lxsaah sorry I missed your ping. I'll take a look tmr morning, provided this doesn't block your work. Otherwise, plz go ahead and merge it. We can handle any additions later in a separate PR.

@lxsaah

lxsaah commented Aug 8, 2026

Copy link
Copy Markdown
Contributor Author

@thaodt I implemented the changes and the PR is ready for review again. Thanks!

hey @lxsaah sorry I missed your ping. I'll take a look tmr morning, provided this doesn't block your work. Otherwise, plz go ahead and merge it. We can handle any additions later in a separate PR.

no issue! take your time

Comment thread aimdb-core/src/session/client.rs Outdated
Comment thread aimdb-websocket-connector/src/server/auth.rs Outdated
@lxsaah

lxsaah commented Aug 9, 2026

Copy link
Copy Markdown
Contributor Author

@thaodt I implemented your suggested changes. Let's go for one additional review round, but add follow-up issues for the remaining tasks. What do you think?

@lxsaah
lxsaah requested a review from thaodt August 9, 2026 20:22

@thaodt thaodt left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey I've jusst completed one final review pass.

Most of the previous concerns now look addressed, however I found two remaining code issues, see my inline comments.

I think these should be addressed before merge. I also found 2 documentation migrations that are suitable for linked follow-up issues.
Im fine with tracking those documentation sweeps as follow-up issues, provided they are linked here.

Comment thread aimdb-core/src/session/aimx/dispatch.rs Outdated

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The MQTT query-grammar fix was applied to WsSession, but the generic AimxDispatch still maps an omitted record.query.name to "*".

Under the new grammar, * covers exactly one dot-separated segment, only # means all records. I reproduced this through the real generic dispatch. {} reached the registered query handler as name == "*", while the ws dispatch now sends "#".

This makes the same AimX request behave differently over WebSocket versus UDS/serial/TCP and silently omits nested records on the generic transports. Please change this default to "#" and add a transport-neutral regression.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Both the generic AimxDispatch and the WS dispatch now default an omitted record.query.name through the shared remote::QUERY_ALL_PATTERN ("#") constant and a transport-neutral regression drives the real generic dispatch (Dispatch::openSession::call) asserting the handler receives #.

Comment thread aimdb-persistence/src/lib.rs Outdated
Comment thread aimdb-wasm-adapter/src/ws_bridge.rs Outdated
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.

2 participants