Skip to content

feat(asyncapi): one channel per queue; lift the fastapi cap - #184

Merged
lesnik512 merged 1 commit into
mainfrom
feat/asyncapi-channels
Sep 20, 2026
Merged

lesnik512 merged 1 commit into
mainfrom
feat/asyncapi-channels

Conversation

@lesnik512

@lesnik512 lesnik512 commented Sep 20, 2026

Copy link
Copy Markdown
Member

Closes #181.

Two independent changes, bundled by request. Either can be dropped without touching the other: they
share no files except pyproject.toml and the second is four lines.


1. AsyncAPI: one channel per queue (#181)

faststream 0.7.6 reworked the specification model around channel_labels (one label per channel)
and a required SubscriberSpec.address, which lands in channels.<key>.address. The subscriber
kept filing every queue under a single channel keyed by the joined queue list, so the address came
out synthetic.

For @broker.subscriber(["orders", "shipments"]):

-"channels":   { "orders,shipments:Handle": { "address": "orders,shipments" } }
-"operations": ["orders,shipments:HandleSubscribe"]
+"channels":   { "orders:Handle":    { "address": "orders" },
+                "shipments:Handle": { "address": "shipments" } }
+"operations": ["orders:HandleSubscribe", "shipments:HandleSubscribe"]

Dropping the name override in favour of the base's _channel_key also fixes title_, which is a
public, documented kwarg that named only the operation:

channel operation
before orders:Handle OrderIngest
after OrderIngest OrderIngest
after, multi-queue Ingest:orders, Ingest:refunds one each

Queues are deduped through a dict rather than a set, so channel order does not vary per process.

Why one channel per queue, and not one per subscriber

Forced by the spec, not a style preference. From the official JSON Schema
(spec-json-schemas/schemas/3.0.0.json):

channel.address    -> {"type": ["string", "null"]}
operation.channel  -> {"$ref": ".../Reference.json"},  required: ["action", "channel"]
  • A channel carries exactly one address. address is a string or null, never an array; 3.0.0
    describes it as "the 'topic name', 'routing key', 'event type', or 'path'", and null or absent
    "MUST be interpreted as unknown".
  • An operation covers exactly one channel. channel is a single $ref and it is required. Nothing
    lets one operation span several.

A handler draining several queues therefore has no single-channel form that keeps every queue name.
The alternatives are to drop all but one queue, or set address: null and publish "unknown", both
of which lose more than the split. Address expressions do not help: they are {param} substitution
for dynamic segments, not lists.

The joined form was not invalid. Validated against the official schema, the old and new
documents both pass with zero errors. The old one was schema-valid and semantically false: it
asserted an address, "orders,shipments", that no consumer can resolve and no reader can tell was
meant as two. That is the worse failure mode, because tooling accepts it in silence. This PR is not
fixing a validation error; it is fixing a document that lies.

What the split does lose. AsyncAPI 3.0 cannot say "these operations are served by one consumer
sharing a lease and a single fetch", so two queues now read as two independent subscriptions. That
fidelity is genuinely gone and nothing standard recovers it: operation.tags could group them, but
no tooling reads a tag as "same consumer", so it would be decoration. Every alternative loses more.

Queue is the right granularity. The spec documents address as the topic name or routing key,
which is exactly what the queue column is: what a publisher addresses and a subscriber filters on.
The outbox table is closer to a server than a channel, and is already modelled as the server URL.
The built-ins agree: Kafka keys on the topic, RabbitMQ on the queue and exchange, Redis on the
channel or stream name.

Checked against both output versions faststream supports, 2.6.0 and 3.0.0.

Scope

Document-shape only. .name and .get_schema() have no consumers outside AsyncAPI generation;
nothing in logging, metrics, or dispatch reads them. No runtime behaviour moves.

Breaking for consumers of the generated document, specifically multi-queue and titled
subscribers. Worth a release note.

2. Lift the fastapi <0.140 cap

The cap existed because fastapi 0.140 made Dependant a slotted dataclass and broke faststream's
FastAPI integration (ag2ai/faststream#2959). That
issue closed as completed on 2026-07-28; faststream 0.7.4 shipped 2026-08-07, so the fix is at or
below 0.7.4 and therefore below the >=0.7.6 floor #182 established. The pairing can no longer
regress anywhere in the supported range, which is what the cap was protecting, so the cap and its
note go.

Verified by resolving to fastapi 0.141.1 and running tests/test_fastapi.py: 10 passed.

Vocabulary

CONTEXT.md reserves channel for the outbox_<table> LISTEN/NOTIFY channel and lists it under
avoid. channel_labels is upstream's name and cannot be renamed, so CONTEXT.md now records the
exception rather than leaving the next reader to trip on it.

Verification

Run against Postgres 17 via compose, so test_integration.py was not skipped.

  • Written test-first: both new tests were failing against the old behaviour before the change
  • Generated document validated against the official AsyncAPI 3.0.0 JSON Schema
  • eof-fixer --check, ruff format --check, ruff check --no-fix, ty check clean
  • 624 passed, coverage 100.00% (gate holds)
  • mkdocs build --strict clean
  • python -m benchmarks check: gate OK

Two independent changes that both fell out of the faststream 0.7.6 bump.

AsyncAPI channels, closing #181. 0.7.6 reworked the specification model
around `channel_labels` (one label per channel) and a required
`SubscriberSpec.address`. The subscriber kept filing every queue under a
single channel keyed by the joined queue list, which forced a synthetic
address: `"orders,shipments"` names nothing a consumer can subscribe to.
Dropping the `name` override in favour of the base's `_channel_key` also
fixes `title_`, which is public and documented but named only the
operation, unlike the publisher and every built-in broker.

The fastapi cap. It existed because fastapi 0.140 made `Dependant` a
slotted dataclass and broke faststream's integration
(ag2ai/faststream#2959). That issue closed completed on 2026-07-28 and the
fix shipped no later than faststream 0.7.4, which is below the 0.7.6 floor
this package already requires, so the pairing can no longer regress within
the supported range. Verified against fastapi 0.141.1.

`CONTEXT.md` reserves *channel* for the LISTEN/NOTIFY channel, so it now
records the one place upstream's vocabulary overrides ours.
@github-actions

Copy link
Copy Markdown

Benchmark gate

✅ gate passed

scenario msg/s delete/msg WALrec/msg WALB/msg fpi upd del dead_tup
consumer/w1/b10 952 1.000 6.79 917 242 5000 5000 10000
consumer/w1/b100 956 1.000 6.83 919 243 5000 5000 10000
consumer/w2/b10 1102 1.000 6.79 961 242 5000 5000 10000
consumer/w2/b100 1314 1.000 6.73 955 243 5000 5000 10000
consumer/w4/b10 1006 1.000 6.81 989 261 5000 5000 10000
consumer/w4/b100 1460 1.000 6.86 1037 244 5000 5000 10000
consumer/w1/b100/tfbs100 4865 0.010 6.07 1116 243 5000 5000 10000
producer/w1/b100 2093 0.000 3.04 584 0 0 0 0

Gated (fails the build): delete_calls + tuple counters (upd/del/ins) + the producer's insert_calls, exact; select_calls within +2; wal_records within a 10% band. msg/s, WAL bytes and total calls are informational (timing/FPI noise).

@lesnik512
lesnik512 merged commit dcfe313 into main Sep 20, 2026
9 checks passed
@lesnik512
lesnik512 deleted the feat/asyncapi-channels branch September 20, 2026 10:52
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.

AsyncAPI: emit one channel per queue, not one comma-joined channel per subscriber

1 participant