Skip to content

Commit 55ec2fd

Browse files
brownjuly2003-codeJuliaEdomclaude
authored
feat(serving): ControlPlaneStore port + embedded adapter — webhook queue/log behind it (ADR 0010 slice 1) (#131)
First rollout slice of ADR 0010: the webhook durable delivery queue and the delivery attempt log move behind a ControlPlaneStore port. Pure extraction — table DDL, SQL shapes, catalog-DDL-lock behavior and the dispatcher's public method signatures are byte-compatible with the pre-port code; no behavior change on the embedded (default) profile. - NEW src/serving/control_plane/: store.py (port ABC + WebhookQueueRow + get_control_plane_store resolver), embedded.py (EmbeddedControlPlaneStore over the serving DuckDB; ensure_* DDL helpers moved here verbatim, including the shared catalog_ddl_lock discipline). - Claim semantics are part of the port contract, not adapter detail: enqueue returns True only for the inserting caller (inline delivery belongs to the enqueue winner), claim_due returns rows this worker owns (embedded: degenerate single-process exclusivity; the PG adapter in slice 5 implements the same contract with FOR UPDATE SKIP LOCKED + lease). - webhook_dispatcher and routers/webhooks.py no longer reach into query_engine._conn; the single sanctioned reach is the composition seam in control_plane/store.py (documented), pinned by a structural ratchet test. The outcome state machine (delivered / backoff-pending / dead at max attempts) moved into the store; retry policy stays dispatcher config. - get_control_plane_store: lazy per-app resolution (mirrors ensure_alert_dispatcher so unit stubs keep working), eager boot-time resolution in the lifespan. AGENTFLOW_CONTROLPLANE_STORE env knob: 'embedded' (default) runs; 'postgres' raises NotImplementedError until slice 5 ships the adapter (fail-closed ratchet, pinned by test); unknown values are a boot error. - Tests: NEW tests/unit/test_control_plane_store.py (13 tests: enqueue-win, claim ordering/bounds/due-ness, outcome transitions, park, log roundtrip, resolver caching + ratchets + live-engine rebinding, structural _conn ratchet); test_webhook_dispatcher_unit / test_catalog_ddl_concurrency / integration test_webhooks rewired to the new module; the DDL-lock identity pin now covers control_plane.embedded. Verified: unit 42 (store+dispatcher+ddl) + integration webhooks 12 + full unit suite green locally; ruff check+format clean; mypy strict clean on all touched src files. Co-authored-by: JuliaEdom <uedomskikh@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 88e726b commit 55ec2fd

11 files changed

Lines changed: 819 additions & 235 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,25 @@ All notable changes to AgentFlow are documented in this file.
44

55
## [Unreleased]
66

7+
### Added — ControlPlaneStore port + embedded adapter: webhook queue/log behind it (ADR 0010 slice 1, 2026-07-02)
8+
9+
- **New `src/serving/control_plane/`** — the `ControlPlaneStore` port and its
10+
`EmbeddedControlPlaneStore` (DuckDB) adapter; the webhook durable delivery
11+
queue and the delivery attempt log are the first subsystem behind it. Pure
12+
extraction: DDL, SQL shapes, the catalog-DDL-lock discipline and the
13+
dispatcher's pinned method signatures are byte-compatible — no behavior
14+
change on the embedded (default) profile.
15+
- Claim semantics are part of the port contract (enqueue-winner-only inline
16+
delivery; `claim_due` ownership — degenerate in one process, `FOR UPDATE
17+
SKIP LOCKED` + lease in the slice-5 PostgreSQL adapter); the outcome state
18+
machine moved into the store so it can be one transaction on PostgreSQL,
19+
while retry policy stays dispatcher configuration.
20+
- `webhook_dispatcher` and `routers/webhooks.py` no longer reach into
21+
`query_engine._conn` — pinned by a structural ratchet test; the
22+
`AGENTFLOW_CONTROLPLANE_STORE` knob resolves the adapter (`postgres` raises
23+
until slice 5 ships — fail-closed, pinned by test; unknown values fail the
24+
boot). New `tests/unit/test_control_plane_store.py` (12 tests).
25+
726
### Added — Control-plane externalization decided; scaling gate enforced at render time (ADR 0010, 2026-07-02)
827

928
- **New [ADR 0010](docs/decisions/0010-control-plane-externalization-postgres.md)**

‎src/serving/api/main.py‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
)
5454
from src.serving.api.webhook_dispatcher import WebhookDispatcher
5555
from src.serving.cache import QueryCache
56+
from src.serving.control_plane import get_control_plane_store
5657
from src.serving.db_pool import DuckDBPool
5758
from src.serving.semantic_layer.catalog import DataCatalog
5859
from src.serving.semantic_layer.query_engine import QueryEngine
@@ -195,6 +196,12 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
195196
app.state.auth_manager.register_signal_handlers()
196197
app.state.auth_manager.ensure_usage_table()
197198
ensure_analytics_table(app.state.auth_manager.db_path)
199+
# Control-plane store (ADR 0010): resolve eagerly so a misconfigured
200+
# AGENTFLOW_CONTROLPLANE_STORE fails the boot, not the first delivery.
201+
# Reset any instance cached by a previous lifespan of this process-wide
202+
# app — the query engine above is fresh, the store must bind to it.
203+
app.state.control_plane_store = None
204+
get_control_plane_store(app)
198205
app.state.webhook_dispatcher = WebhookDispatcher(app)
199206
original_dispatch_new_events = app.state.webhook_dispatcher.dispatch_new_events
200207

‎src/serving/api/routers/webhooks.py‎

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,11 @@
1212
WebhookFilters,
1313
create_webhook,
1414
deactivate_webhook,
15-
get_delivery_logs,
1615
get_webhook,
1716
get_webhook_config_path,
1817
list_webhooks,
1918
)
19+
from src.serving.control_plane import get_control_plane_store
2020

2121
router = APIRouter(prefix="/v1/webhooks", tags=["webhooks"])
2222

@@ -106,11 +106,8 @@ async def webhook_logs(webhook_id: str, request: Request) -> dict[str, object]:
106106

107107

108108
def _read_delivery_logs(request: Request, webhook_id: str) -> list[dict]:
109-
# The log scan runs on a worker thread (run_in_threadpool); a dedicated
110-
# cursor — not the shared connection — keeps concurrent reads on different
111-
# threads from colliding on the connection. (audit_30_06_26.md A2)
112-
cursor = request.app.state.query_engine._conn.cursor()
113-
try:
114-
return get_delivery_logs(cursor, webhook_id)
115-
finally:
116-
cursor.close()
109+
# Runs on a worker thread (run_in_threadpool); the control-plane store
110+
# isolates the read (a dedicated cursor per call in the embedded adapter)
111+
# so concurrent reads on different threads don't collide on the shared
112+
# connection. (audit_30_06_26.md A2)
113+
return get_control_plane_store(request.app).get_webhook_delivery_logs(webhook_id)

0 commit comments

Comments
 (0)