.data: isolate cached feed stream consumers

Keep provider root streams private to the cached `Feed` and give every
`maybe_open_feed()` caller, including the first owner, a local
subscription view with copied `Flume` descriptors.

Also,
- key cached resources by the normalized full symbol set and stream
  policy instead of only the first FQME.
- subscribe each `Feed.open_multi_stream()` provider relay and wait for
  all relays to start before yielding the merged channel.
- give each provider relay an independent send-channel clone so one
  provider EOC cannot terminate its siblings.
- document `Feed.pause()` and `Feed.resume()` as shared remote-provider
  controls rather than caller-local cursor operations.
- cover miss/hit ownership, multi-market provider mapping, relay
  fan-out, lexical teardown and cache-key separation.

Prompt-IO: ai/prompt-io/opencode/20260813T202402Z_12b0d66e_prompt_io.md

(this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
wkt/fix_broadcast_consumers
Gud Boi 2026-08-13 18:53:44 -04:00
parent 12b0d66e85
commit b691422b26
4 changed files with 580 additions and 26 deletions

View File

@ -0,0 +1,34 @@
---
model: openai/gpt-5.6-sol
service: opencode
session: unavailable
timestamp: 2026-08-13T20:24:02Z
git_ref: 12b0d66e
scope: code
substantive: true
raw_file: 20260813T202402Z_12b0d66e_prompt_io.raw.md
---
## Prompt
Continue the paired piker broadcast-consumer work after Tractor PR 490
stabilization. Stop cached feed callers from mutating or consuming shared
quote-stream roots, then review, verify and prepare a commit boundary.
## Response summary
Made provider root streams private to the cached `Feed`, gave every caller a
local subscribed view, corrected feed cache identity, and made multi-provider
relays own synchronized child subscriptions and independent send-channel
clones.
## Files changed
- `piker/data/feed.py` - caller-local feed views, complete cache keys, and
subscribed multi-provider relays with independent channel lifetimes.
- `tests/test_feed_broadcast.py` - real broadcast ownership, lifecycle and
cache-identity regressions, including sibling survival after provider EOC.
## Human edits
None - generated output follows the requested cached-feed ownership item.

View File

@ -0,0 +1,51 @@
---
model: openai/gpt-5.6-sol
service: opencode
timestamp: 2026-08-13T20:24:02Z
git_ref: 12b0d66e
diff_cmd: git diff HEAD~1..HEAD
---
The user asked to continue the paired piker broadcast-consumer work after
Tractor PR 490 stabilization. The next item was to stop cached feed callers
from mutating or consuming shared `Feed`/`Flume` quote-stream roots, then
review, verify and prepare a commit boundary.
> `git diff HEAD~1..HEAD -- piker/data/feed.py`
Changed `maybe_open_feed()` so the cached `Feed` exclusively owns provider
root streams and every caller, including the first cache owner, receives a
caller-local `Feed`/`Flume` view backed by one child subscription per provider.
The cache identity now includes normalized full symbol shape and stream policy.
Changed `Feed.open_multi_stream()` so every provider relay subscribes instead
of directly consuming a shared stream, and synchronizes relay startup before
yielding the merged channel. Each relay owns a cloned send channel so one
provider EOC cannot terminate its siblings. The module-level relay returns its
live child receiver through `TaskStatus` for typed retention and introspection.
Documented `Feed.pause()`/`Feed.resume()` as provider-context-wide controls
shared by local cache users.
> `git diff HEAD~1..HEAD -- tests/test_feed_broadcast.py`
Added real `Feed`, `Flume`, and Tractor broadcast regressions covering both
cache-miss owner and cache-hit paths, one-provider-many-market mapping,
multi-provider relay fan-out and startup, duplex controls, lexical child
closure, sibling survival after provider EOC, cached-root immutability, and
full cache-key differentiation.
Verification output:
```text
... [100%]
3 passed in 0.02s
. [100%]
1 passed in 0.01s
```
The second result is the existing EMS broadcast ownership regression. Ruff,
E501, compilation and whitespace checks passed. Repeated adversarial review
found and resolved shared `Feed.streams`, incomplete cache-key, fake-lifecycle,
relay-startup and first-owner asymmetry issues; final review reported no
findings.

View File

@ -30,6 +30,7 @@ from collections import (
defaultdict, defaultdict,
abc, abc,
) )
from copy import copy
from contextlib import asynccontextmanager as acm from contextlib import asynccontextmanager as acm
from functools import partial from functools import partial
import time import time
@ -677,6 +678,36 @@ async def open_feed_bus(
bus.remove_subs(bs_fqme, subs) bus.remove_subs(bs_fqme, subs)
async def relay_to_common_memchan(
stream: (
tractor.MsgStream
|trionics.BroadcastReceiver
),
tx: trio.MemorySendChannel[dict[str, Any]],
task_status: TaskStatus[
trionics.BroadcastReceiver,
] = trio.TASK_STATUS_IGNORED,
) -> None:
'''
Relay one provider subscription into a common memory channel.
Deliver the live child receiver through `task_status` so the
caller can retain and inspect each provider relay's cursor.
'''
bstream: trionics.BroadcastReceiver
async with (
stream.subscribe() as bstream,
tx,
):
task_status.started(bstream)
msg: dict[str, Any]
async for msg in bstream:
await tx.send(msg)
class Feed(Struct): class Feed(Struct):
''' '''
A per-provider API for client-side consumption from real-time data A per-provider API for client-side consumption from real-time data
@ -726,29 +757,51 @@ class Feed(Struct):
if len(mods) == 1: if len(mods) == 1:
# just pass the datad stream directly if only one provider # just pass the datad stream directly if only one provider
# was detected. # was detected.
stream = self.streams[list(brokers)[0]] stream: (
tractor.MsgStream
|trionics.BroadcastReceiver
) = self.streams[list(brokers)[0]]
bstream: trionics.BroadcastReceiver
async with stream.subscribe() as bstream: async with stream.subscribe() as bstream:
yield bstream yield bstream
return return
# start multiplexing task tree # start multiplexing task tree
tx: trio.MemorySendChannel[dict[str, Any]]
rx: trio.MemoryReceiveChannel[dict[str, Any]]
tx, rx = trio.open_memory_channel(616) tx, rx = trio.open_memory_channel(616)
async def relay_to_common_memchan(stream: tractor.MsgStream):
async with tx:
async for msg in stream:
await tx.send(msg)
async with ( async with (
tractor.trionics.collapse_eg(), tractor.trionics.collapse_eg(),
trio.open_nursery() as nurse trio.open_nursery() as nurse
): ):
# spawn a relay task for each stream so that they all # spawn a relay task for each stream so that they all
# multiplex to a common channel. # multiplex to a common channel.
for brokername in mods: broker2bstreams: dict[
stream = self.streams[brokername] str,
nurse.start_soon(relay_to_common_memchan, stream) trionics.BroadcastReceiver,
] = {}
broker: str
for broker in mods:
stream: (
tractor.MsgStream
|trionics.BroadcastReceiver
) = self.streams[broker]
relay_tx: trio.MemorySendChannel[
dict[str, Any]
] = tx.clone()
bstream: trionics.BroadcastReceiver = await nurse.start(
relay_to_common_memchan,
stream,
relay_tx,
)
broker2bstreams[broker] = bstream
# Each relay now owns one `tx` clone. Closing the original
# lets `rx` reach EOC only after every provider relay exits.
await tx.aclose()
try: try:
yield rx yield rx
finally: finally:
@ -757,10 +810,23 @@ class Feed(Struct):
_max_sample_rate: int = 1 _max_sample_rate: int = 1
async def pause(self) -> None: async def pause(self) -> None:
'''
Pause the shared remote provider context for this cached feed.
This controls provider publication for every local cache user;
it does not pause only this caller's receive cursor.
'''
stream: tractor.MsgStream|trionics.BroadcastReceiver
for stream in set(self.streams.values()): for stream in set(self.streams.values()):
await stream.send('pause') await stream.send('pause')
async def resume(self) -> None: async def resume(self) -> None:
'''
Resume the shared remote provider context for this cached feed.
'''
stream: tractor.MsgStream|trionics.BroadcastReceiver
for stream in set(self.streams.values()): for stream in set(self.streams.values()):
await stream.send('resume') await stream.send('resume')
@ -820,7 +886,17 @@ async def maybe_open_feed(
in a tractor broadcast receiver. in a tractor broadcast receiver.
''' '''
fqme = fqmes[0] cache_key: tuple[
tuple[str, ...],
float|None,
bool,
bool,
] = (
tuple(sorted(fqmes)),
kwargs.get('tick_throttle'),
kwargs.get('allow_overruns', True),
kwargs.get('start_stream', True),
)
async with trionics.maybe_open_context( async with trionics.maybe_open_context(
acm_func=open_feed, acm_func=open_feed,
@ -833,27 +909,73 @@ async def maybe_open_feed(
'allow_overruns': kwargs.get('allow_overruns', True), 'allow_overruns': kwargs.get('allow_overruns', True),
'start_stream': kwargs.get('start_stream', True), 'start_stream': kwargs.get('start_stream', True),
}, },
key=fqme, key=cache_key,
) as (cache_hit, feed): ) as (cache_hit, feed):
feed: Feed
if cache_hit: if (
log.info(f'Using cached feed for {fqme}') cache_hit
# add a new broadcast subscription for the quote stream and
# if this feed is likely already in use log.at_least_level('info')
):
log.info(
f'Using cached feed for key:\n'
f'{cache_key!r}'
)
async with trionics.gather_contexts( # The cached `Feed` exclusively owns provider root streams.
mngrs=[stream.subscribe() for stream in feed.streams.values()] # Every caller, including the first cache owner, gets a local
) as bstreams: # child cursor and copied descriptors for its lexical lifetime.
for bstream, flume in zip(bstreams, feed.flumes.values()): broker_names: tuple[str, ...] = tuple(feed.streams)
# XXX: TODO: horrible hackery that needs fixing.. bstreams: tuple[
# i guess we have to create context proxies? trionics.BroadcastReceiver,
bstream._ctx = flume.stream._ctx ...,
flume.stream = bstream ]
async with trionics.gather_contexts(
mngrs=[
feed.streams[broker].subscribe()
for broker in broker_names
]
) as bstreams:
broker2bstreams: dict[
str,
trionics.BroadcastReceiver,
] = dict(zip(
broker_names,
bstreams,
strict=True,
))
flumes: dict[str, Flume] = {}
fqme: str
flume: Flume
for fqme, flume in feed.flumes.items():
broker: str = flume.mkt.broker
yield feed bstream: trionics.BroadcastReceiver = (
else: broker2bstreams[broker]
yield feed )
# XXX: TODO: horrible hackery that needs fixing..
# i guess we have to create context proxies?
root_stream: tractor.MsgStream = feed.streams[broker]
ctx: tractor.Context = root_stream.ctx
bstream._ctx = ctx
local_flume: Flume = copy(flume)
local_flume.stream = bstream
flumes[fqme] = local_flume
# `feed` is the cache-owned resource and must retain only
# provider root streams. A shallow `Feed` copy gives this
# caller its own descriptor mappings while intentionally
# sharing immutable market metadata, SHM handles and
# provider controls for the cache resource's lifetime.
local_feed: Feed = copy(feed)
local_feed.flumes = flumes
local_feed.streams = broker2bstreams
yield local_feed
@acm @acm

View File

@ -0,0 +1,347 @@
'''
Cached feed broadcast ownership regressions.
'''
from contextlib import asynccontextmanager as acm
from types import SimpleNamespace
import tractor
import trio
import pytest
from piker.data import feed as feed_mod
from piker.data.flows import Flume
class FakeMsgStream:
'''
Duplex test stream backed by a real Tractor broadcaster.
'''
def __init__(self, broker: str) -> None:
self.broker: str = broker
self._ctx: object = object()
self._tx, rx = trio.open_memory_channel(8)
self._broadcaster = tractor.trionics.broadcast_receiver(
rx,
8,
)
self.children: list[
tractor.trionics.BroadcastReceiver,
] = []
self.controls: list[str] = []
@property
def ctx(self) -> object:
'''
Expose the public context ref provided by `MsgStream.ctx`.
'''
return self._ctx
@acm
async def subscribe(self):
'''
Yield a real child receiver with duplex send delegation.
'''
child: tractor.trionics.BroadcastReceiver
async with self._broadcaster.subscribe() as child:
child.send = self.send
self.children.append(child)
yield child
async def send(self, msg: str) -> None:
'''
Record a pause or resume control sent through a child.
'''
self.controls.append(msg)
async def push(self, msg: dict) -> None:
'''
Send one provider quote into the broadcast source.
'''
await self._tx.send(msg)
async def close_source(self) -> None:
'''
End this provider without closing sibling relay channels.
'''
await self._tx.aclose()
def make_feed() -> tuple[
feed_mod.Feed,
dict[str, FakeMsgStream],
]:
'''
Build a real `Feed` and `Flume` graph for two providers.
'''
roots: dict[str, FakeMsgStream] = {
broker: FakeMsgStream(broker)
for broker in ('ib', 'deribit')
}
flumes: dict[str, Flume] = {
fqme: Flume(
mkt=SimpleNamespace(broker=broker),
first_quote={},
_rt_shm_token=SimpleNamespace(),
stream=roots[broker],
)
for fqme, broker in (
('nvda.nasdaq.ib', 'ib'),
('aapl.nasdaq.ib', 'ib'),
('btc-usd.deribit', 'deribit'),
)
}
return (
feed_mod.Feed(
mods={
broker: SimpleNamespace(name=broker)
for broker in roots
},
portals={},
flumes=flumes,
streams=roots,
status={},
),
roots,
)
@pytest.mark.parametrize('cache_hit', [False, True])
def test_cached_feed_returns_local_streams(
monkeypatch,
cache_hit: bool,
) -> None:
'''
A cached feed caller must not consume or replace shared streams.
`maybe_open_feed()` previously paired provider subscriptions with
`Feed.flumes.values()` using `zip()` and installed each child on the
cached `Flume`. One caller therefore exposed its lexical receiver
to every holder of the shared `Feed`; after exit those descriptors
pointed at closed children. The positional pairing also dropped a
market whenever one provider supplied multiple flumes.
Model two real provider broadcasters with two IB markets and one
Deribit market. Enter both first-owner and cache-hit paths, then
prove the yielded `Feed`, its `Feed.streams` mapping and all
`Flume`s are caller-local views. Exercise provider-context-wide
pause/resume and `Feed.open_multi_stream()` to show duplex sends
still reach the roots while each relay owns another child. Finally
prove every lexical child closes and all cached descriptors still
point at live roots.
'''
cached_feed: feed_mod.Feed
roots: dict[str, FakeMsgStream]
cached_feed, roots = make_feed()
cached_flumes: dict[str, Flume] = cached_feed.flumes
@acm
async def maybe_open_context(**kwargs):
'''
Return the controlled shared feed as a cache hit.
'''
yield cache_hit, cached_feed
monkeypatch.setattr(
feed_mod.trionics,
'maybe_open_context',
maybe_open_context,
)
local_feed: feed_mod.Feed|None = None
local_streams: dict[
str,
tractor.trionics.BroadcastReceiver,
]|None = None
async def main() -> None:
nonlocal local_feed, local_streams
feed: feed_mod.Feed
async with feed_mod.maybe_open_feed(
list(cached_flumes),
) as feed:
local_feed = feed
local_streams = feed.streams
assert feed is not cached_feed
assert feed.streams is not roots
assert feed.flumes is not cached_flumes
broker: str
stream: tractor.trionics.BroadcastReceiver
for broker, stream in feed.streams.items():
assert stream is roots[broker].children[0]
assert stream._ctx is roots[broker].ctx
fqme: str
flume: Flume
for fqme, flume in feed.flumes.items():
cached: Flume = cached_flumes[fqme]
stream: tractor.trionics.BroadcastReceiver = (
feed.streams[flume.mkt.broker]
)
assert flume is not cached
assert flume.stream is stream
assert cached.stream is roots[flume.mkt.broker]
assert (
feed.flumes['nvda.nasdaq.ib'].stream
is feed.flumes['aapl.nasdaq.ib'].stream
)
await feed.pause()
await feed.resume()
assert all(
root.controls == ['pause', 'resume']
for root in roots.values()
)
stream: trio.MemoryReceiveChannel
async with feed.open_multi_stream() as stream:
assert all(
len(child._state.subs) == 3
for child in feed.streams.values()
)
await roots['ib'].push({'provider': 'ib'})
await roots['deribit'].push({
'provider': 'deribit',
})
with trio.fail_after(1):
msgs = {
(await stream.receive())['provider'],
(await stream.receive())['provider'],
}
assert msgs == {'ib', 'deribit'}
# One provider's EOC must close only its `tx` clone;
# the sibling relay continues publishing to `stream`.
await roots['ib'].close_source()
with trio.fail_after(1):
while (
len(feed.streams['ib']._state.subs)
!= 2
):
await trio.lowlevel.checkpoint()
await roots['deribit'].push({
'provider': 'deribit-after-ib-eoc',
})
msg: dict
msg = await stream.receive()
assert msg['provider'] == 'deribit-after-ib-eoc'
assert all(
len(child._state.subs) == 2
for child in feed.streams.values()
)
trio.run(main)
assert local_feed is not None
assert local_streams is not None
assert all(stream._closed for stream in local_streams.values())
assert all(
flume.stream is roots[flume.mkt.broker]
for flume in cached_flumes.values()
)
assert all(
len(root._broadcaster._state.subs) == 1
for root in roots.values()
)
def test_feed_cache_key_includes_shape_and_policy(
monkeypatch,
) -> None:
'''
Feed cache identity must include every resource-defining input.
The old `fqmes[0]` key aliased `[A]` with `[A, B]` and ignored
`tick_throttle`, `allow_overruns` and `start_stream`. Whichever
caller entered first silently determined later feed shape and
stream behavior, including reuse of a history-only remote feed for
a live quote request.
Capture the key passed to `maybe_open_context()` for reordered and
varied requests. Symbol order must normalize to one key, while the
full symbol set and each stream policy must produce distinct keys.
'''
feed: feed_mod.Feed = feed_mod.Feed(
mods={},
portals={},
flumes={},
streams={},
status={},
)
keys: list[tuple] = []
@acm
async def maybe_open_context(**kwargs):
'''
Capture cache identity without opening a remote feed.
'''
keys.append(kwargs['key'])
yield False, feed
@acm
async def gather_contexts(mngrs):
'''
Permit the metadata-only empty feed used by this key probe.
'''
assert not mngrs
yield ()
monkeypatch.setattr(
feed_mod.trionics,
'maybe_open_context',
maybe_open_context,
)
monkeypatch.setattr(
feed_mod.trionics,
'gather_contexts',
gather_contexts,
)
async def open_once(
fqmes: list[str],
**kwargs,
) -> None:
async with feed_mod.maybe_open_feed(
fqmes,
**kwargs,
):
pass
async def main() -> None:
await open_once(['a.ib', 'b.ib'])
await open_once(['b.ib', 'a.ib'])
await open_once(['a.ib'])
await open_once(['a.ib', 'b.ib'], tick_throttle=10)
await open_once(
['a.ib', 'b.ib'],
allow_overruns=False,
)
await open_once(
['a.ib', 'b.ib'],
start_stream=False,
)
trio.run(main)
assert keys[0] == keys[1]
assert len(set(keys[2:])) == 4
assert keys[0] not in keys[2:]