feat(api): GET /api/{claims,remittances,activity}/stream + unsubscribe
Adds three live-tail streaming endpoints that emit an NDJSON snapshot
then forward new event-bus events as they arrive, with a 15s idle
heartbeat (overridable via CYCLONE_TAIL_HEARTBEAT_S for tests).
Each endpoint:
1. yields a snapshot of existing rows as {"type":"item","data":<row>}
2. terminates the snapshot with {"type":"snapshot_end","data":{"count":N}}
3. subscribes to its event kind and forwards each new event as an
{"type":"item","data":<event>} line
4. emits a {"type":"heartbeat","data":{"ts":<iso>}} line every
CYCLONE_TAIL_HEARTBEAT_S seconds when idle
5. checks request.is_disconnected() before each yield and unsubscribes
from the bus on cleanup so a closed stream releases its queue
The shared tail loop lives in api._tail_events, which polls
bus.subscribe_raw()'s queue directly instead of using the bus's
async-iterator wrapper — wait_for on an async generator cancels the
inner future on timeout, which poisons subsequent __anext__ calls with
StopAsyncIteration. Queue.get() is idempotent under cancellation, so
heartbeats don't break the subscription.
EventBus gains an unsubscribe(queue, kinds) method (idempotent) so
the tail loop can release its queue in a try/finally. The disconnect
test asserts the subscriber list is empty after the body iterator is
closed, validating no queue leak per open stream.
Tests in test_api_stream_live.py: 8 tests covering snapshot shape,
post-snapshot publish, heartbeat timing, multi-item snapshots, and
client disconnect cleanup. Plus 2 tests in test_pubsub.py for the
new unsubscribe method.
This commit is contained in:
@@ -132,3 +132,25 @@ def test_get_event_bus_returns_app_state_bus():
|
||||
assert get_event_bus() is bus
|
||||
finally:
|
||||
app.state.event_bus = saved
|
||||
|
||||
|
||||
def test_unsubscribe_removes_queue_from_each_kind():
|
||||
bus = EventBus()
|
||||
q1, _ = bus.subscribe_raw(["a", "b"])
|
||||
q2, _ = bus.subscribe_raw(["a"])
|
||||
|
||||
bus.unsubscribe(q1, ["a", "b"])
|
||||
|
||||
assert q1 not in bus._subscribers.get("a", [])
|
||||
assert q1 not in bus._subscribers.get("b", [])
|
||||
assert q2 in bus._subscribers["a"]
|
||||
|
||||
|
||||
def test_unsubscribe_is_idempotent():
|
||||
bus = EventBus()
|
||||
q, _ = bus.subscribe_raw(["a"])
|
||||
bus.unsubscribe(q, ["a"])
|
||||
# Second call must not raise.
|
||||
bus.unsubscribe(q, ["a"])
|
||||
bus.unsubscribe(q, ["b"]) # wrong kind — also fine
|
||||
assert "a" not in bus._subscribers
|
||||
|
||||
Reference in New Issue
Block a user