Task: One lifecycle controller for all feed kinds
Table of Contents
This page documents a task in the Feed lifecycle harmonization: asset-class-agnostic feed control-plane story. It captures the goal, current status, acceptance, and any notes or results.
Goal
One lifecycle controller replaces feed_controller and curve_feed_controller: a running map keyed by source_name, one thread per feed, start/stop/list/join. The per-kind extras are expressed on the common interface (the feed's own conflict key for qualifier+role semantics) or become uniform behaviour — not a second class.
Status
| Field | Value |
|---|---|
| State | DONE |
| Parent story | Feed lifecycle harmonization: asset-class-agnostic feed control-plane |
| Now | Nothing. |
| Waiting on | Nothing. |
| Next | Nothing. |
| Last touched | 2026-08-11 |
Acceptance
- One controller class serves both kinds; feed_controller and curve_feed_controller are deleted.
- Qualifier+role conflict semantics come from the feed's conflict key on the common interface; the conflict is reported with the running source name.
- FX-only extras become uniform: feed-binding auto-creation, sandbox subject derivation, and the status loop run for every feed kind (or are explicitly dropped as FX specifics, with the difference documented on the interface).
- start/stop/list/running_count behaviour is unchanged for existing callers.
Plan
One controller: feed_controller absorbs curve_feed_controller
(deleted). The running map stays keyed by source_name, one thread per
feed, start/stop/list/running_count/join — the shared shape. The two
per-kind differences are resolved as follows.
- Conflict semantics: both
add()andstart()reject a feed whose conflict key — the feed's ownIFeed::conflict_key()(feed_conflict_key(qualifier, role); empty role for FX, so the key is qualifier-only) — is already held by a different running feed, reporting the holder's source name. The pureir_curve_feeds_conflicthelper becomesfeeds_conflictand now covers FX too: two FX feeds on the same pair cannot both run, because publishing into the same observation series is the same hazard the IR rule guards against (the old controller's permissiveness was the pre-seam behaviour; the IFeed contract from the seam task already declares the uniform rule). A feed with an empty qualifier (an unparseable ORE key) has no published key to protect and conflicts with nothing. The conflict is reported with the running source name, viarunning_source_name_for_conflict_key(). - FX extras:
- Feed-binding auto-creation and vintage availability stay only in
the client-supplied-params
start()overload — the FX-only ad-hoc surface that the per-config control-plane task deletes. The config-drivenadd()path never auto-created bindings and keeps that behaviour. - The status loop becomes uniform: it logs source_name and publish_count per feed (the ore_key/subject columns were FX-shaped; the publish subject is per-kind and lives in the feed).
- Dead code dropped:
resolve_series()and its members (md_client_,tenant_id_,uuid_gen_) are never called.
- Feed-binding auto-creation and vintage availability stay only in
the client-supplied-params
- Callers collapse to one
ctrl: registrar/application pass one controller; the folder handler's two loops keep their per-kind build inputs (the accepted cost) but share oneadd()pattern with the conflicting-source-name out param — the IR pre-check (running_source_name_for_qualifier) is dropped becauseadd()'s out param distinguishes the same cases; the IR on-demand handler usesstart()plusrunning_source_name_for_conflict_key()for the "stop it first" message.
Notes
Test Scenarios
Manual QA scenarios (scaffolded via compass add test_scenario, run
through the QA Validation Runner panel) that verify this task. Link
new ones here as they're created; the scenario doc itself links back
via its "Verifies task" field.
| Scenario | State | Notes |
|---|---|---|
PRs
| PR | Title |
|---|---|
| #1969 | [synthetic] Collapse curve_feed_controller into the unified feed_controller |
Review
Round 1 (2026-08-11): three Claude review passes, no line comments. All six findings accepted; no correctness bug found by any reviewer.
| # | Comment summary | File | Decision | Notes |
|---|---|---|---|---|
| 1 | list() no longer scoped by feed kind — a shared controller leaks both kinds into each handler's list | feed_controller.hpp | Accepted | IFeed gains kind() (the factory kind string, defined in each producer header); list(kind) filters; both handlers pass their own kind, restoring the per-kind list behaviour |
| 2 | FX auto-start silently drops a conflict (no conflicting-source-name capture, no log) | application.cpp | Accepted | Mirrors the IR walk's error log; an already-running same-source row stays silent as before |
| 3 | Class doc still says several feeds on the same pair run concurrently, contradicting feeds_conflict |
feed_controller.hpp | Accepted | Reworded: at most one per (qualifier, role) pair — two FX feeds included |
| 4 | Doc says conflicts flow through conflict_key(); find_conflict() compares the pair via feeds_conflict |
feed_controller.hpp | Accepted | Clarified: the pair is compared, not the key string, because the empty-qualifier carve-out is key-string-blind |
| 5 | running_source_name_for_conflict_key() has no empty-qualifier carve-out (string equality can false-positive) |
feed_controller.hpp | Accepted | Skips empty-qualifier feeds; unreachable today, guard makes the lookup agree with feeds_conflict |
| 6 | No end-to-end test drives the qualifier_conflict rejection |
folder_feed_control_handler_tests.cpp | Accepted | Cascade test seeds a second enabled config on the same EUR/USD pair (own container — the schema forbids two on the same pair per container); only one starts, the other is skipped |
Result
Implemented per the Plan, with one deviation noted below.
One feed_controller now owns every feed kind; curve_feed_controller
and its test file are deleted. The shared shape (running map keyed by
source_name, thread-per-feed, start/stop/list/running_count/join) is
unchanged; the IR qualifier+role conflict semantics flow through the
feed's own IFeed::conflict_key() on all three registration paths
(add(), start(IFeed), and the FX client-supplied-params start()),
reported with the holding source_name via
running_source_name_for_conflict_key(). ir_curve_feeds_conflict
became the uniform feeds_conflict helper with the empty-qualifier
guard; it now also rejects two FX feeds on the same pair. The FX extras
stayed FX-shaped: binding auto-creation and vintage availability only in
the ad-hoc start() overload, and a uniform status loop logging
source_name + publish_count. Dead code dropped: resolve_series() and
its members (md_client_, tenant_id_, uuid_gen_); the ctor now
takes (nats, auth_nats) only.
Callers collapsed to one ctrl: registrar and application pass a single
controller (auto-start walk now runs both kinds through it); the folder
handler's two loops share one add() pattern with the
conflicting-source-name out param (the IR pre-check is gone — add()'s
out param distinguishes the same cases); the IR on-demand handler uses
start() plus running_source_name_for_conflict_key(); the FX ad-hoc
handler's start gained the qualifier_conflict case with the same out
param. Stale comments naming the deleted class were updated.
Deviation from the Plan: add() keeps the binding_mode parameter (as
two overloads) instead of dropping it. The Plan's "drops binding_mode"
reading would have stored the bound default for every config-driven
feed, changing the ad-hoc start()'s should_ensure_feed_binding gate
for sandboxed feeds (a later ad-hoc restart would create a feed_binding
for a sandboxed feed) — a behaviour change, violating the acceptance
criterion "behaviour is unchanged for existing callers". The parameter
is stored purely as that restart gate.
Verification: full build green; DB recreated (schema 0.0.25); rat green on the fresh DB. Component tests: 198 assertions in 39 test cases, including the conflict tests moved from the deleted test file and the review-round conflict-rejection coverage added to the folder cascade.