generated from coulomb/repo-seed
feat(ACTIVITY-WP-0014): idempotency-keyed State Hub writes (T05, in-repo part)
Add activity_core/state_hub_write: every State Hub write (report-sink, ops-evidence, schedule-miss) now sends a stable Idempotency-Key header derived from run_id:instruction_id:event_type. Makes writes safe to buffer/replay under the future state-hub beachhead without duplicate progress/triage events. The read-based _progress_exists dedup is now best-effort (returns False on connection error instead of hard-failing), so the guarantee lives on the keyed write rather than a live read. Tests + runbook note. Endpoint adoption / proxy retirement stays blocked on the state-hub beachhead capability. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,7 @@ from typing import Any
|
||||
import httpx
|
||||
|
||||
from activity_core.context_resolvers.ops_inventory import _sanitize_url
|
||||
from activity_core.state_hub_write import idempotency_headers
|
||||
|
||||
_DEFAULT_STATE_HUB_URL = "http://127.0.0.1:8000"
|
||||
_INTER_HUB_SINK_TYPES = {
|
||||
@@ -121,6 +122,7 @@ def _post_state_hub_progress(
|
||||
resp = httpx.post(
|
||||
f"{base_url}/progress/",
|
||||
json=body,
|
||||
headers=idempotency_headers(run_id, context_key, event_type),
|
||||
timeout=float(sink.get("timeout_seconds", 10.0)),
|
||||
)
|
||||
resp.raise_for_status()
|
||||
@@ -136,12 +138,17 @@ def _post_state_hub_progress(
|
||||
|
||||
|
||||
def _progress_exists(base_url: str, event_type: str, idempotency_key: str) -> bool:
|
||||
resp = httpx.get(
|
||||
f"{base_url}/progress/",
|
||||
params={"limit": 100},
|
||||
timeout=10.0,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
# Best-effort optimisation only; the Idempotency-Key header on the write is the
|
||||
# real dedup guarantee. Do not hard-fail if State Hub is unreachable here.
|
||||
try:
|
||||
resp = httpx.get(
|
||||
f"{base_url}/progress/",
|
||||
params={"limit": 100},
|
||||
timeout=10.0,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
except httpx.HTTPError:
|
||||
return False
|
||||
for item in resp.json():
|
||||
detail = item.get("detail") or {}
|
||||
if (
|
||||
|
||||
@@ -11,6 +11,8 @@ from zoneinfo import ZoneInfo
|
||||
|
||||
import httpx
|
||||
|
||||
from activity_core.state_hub_write import idempotency_headers
|
||||
|
||||
_DEFAULT_STATE_HUB_URL = "http://127.0.0.1:8000"
|
||||
_THE_CUSTODIAN_ROOT = Path("/home/worsch/the-custodian")
|
||||
_FORBIDDEN_CUSTODIAN_ROOTS = (
|
||||
@@ -149,6 +151,7 @@ def _post_state_hub_progress(
|
||||
resp = httpx.post(
|
||||
f"{base_url}/progress/",
|
||||
json=body,
|
||||
headers=idempotency_headers(run_id, instruction_id, event_type),
|
||||
timeout=float(sink.get("timeout_seconds", 10.0)),
|
||||
)
|
||||
resp.raise_for_status()
|
||||
@@ -167,12 +170,18 @@ def _progress_exists(
|
||||
instruction_id: str,
|
||||
event_type: str,
|
||||
) -> bool:
|
||||
resp = httpx.get(
|
||||
f"{base_url}/progress/",
|
||||
params={"limit": 100},
|
||||
timeout=10.0,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
# Best-effort read-dedup optimisation only. The Idempotency-Key header on the
|
||||
# write is the real guarantee; if State Hub is unreachable here we must not
|
||||
# hard-fail — proceed to the (keyed) write rather than raising.
|
||||
try:
|
||||
resp = httpx.get(
|
||||
f"{base_url}/progress/",
|
||||
params={"limit": 100},
|
||||
timeout=10.0,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
except httpx.HTTPError:
|
||||
return False
|
||||
for item in resp.json():
|
||||
detail = item.get("detail") or {}
|
||||
if (
|
||||
|
||||
@@ -24,6 +24,7 @@ from uuid import UUID
|
||||
import httpx
|
||||
|
||||
from activity_core.schedule_manager import schedule_id
|
||||
from activity_core.state_hub_write import idempotency_headers
|
||||
|
||||
_DEFAULT_STATE_HUB_URL = "http://127.0.0.1:8000"
|
||||
|
||||
@@ -176,7 +177,14 @@ def post_missed_fire_alert(
|
||||
if workstream_id:
|
||||
body["workstream_id"] = workstream_id
|
||||
|
||||
resp = httpx.post(f"{base_url}/progress/", json=body, timeout=timeout_seconds)
|
||||
# Dedup repeated alerts for the same missed window (same schedule + last fire).
|
||||
last_fired = health.last_fired_at.isoformat() if health.last_fired_at else "none"
|
||||
resp = httpx.post(
|
||||
f"{base_url}/progress/",
|
||||
json=body,
|
||||
headers=idempotency_headers("schedule_miss", health.activity_id, last_fired),
|
||||
timeout=timeout_seconds,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
return {
|
||||
|
||||
34
src/activity_core/state_hub_write.py
Normal file
34
src/activity_core/state_hub_write.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""Idempotency-keyed State Hub writes (ACTIVITY-WP-0014 T05).
|
||||
|
||||
Under the State Hub *beachhead* model, a write may be buffered locally while
|
||||
central State Hub is unreachable and **flushed later, possibly with retries**.
|
||||
To keep that flush safe — no duplicate progress / triage events — every write
|
||||
carries a stable ``Idempotency-Key`` header derived deterministically from the
|
||||
write's identity. The guarantee lives on the write itself and does **not** depend
|
||||
on a live dedup read, so it holds even when the beachhead is serving offline.
|
||||
|
||||
activity-core does not implement the queue/cache (that is state-hub's beachhead);
|
||||
it only emits the key so the beachhead / State Hub can dedup on flush. The header
|
||||
passes untouched through the existing ``actcore-state-hub-bridge`` proxy and is
|
||||
ignored by State Hub versions that do not yet honour it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
IDEMPOTENCY_HEADER = "Idempotency-Key"
|
||||
|
||||
|
||||
def idempotency_key(*parts: str | None) -> str:
|
||||
"""Build a stable, header-safe idempotency key from identity parts.
|
||||
|
||||
Empty/None parts are kept as empty segments so the key shape is stable across
|
||||
calls. Whitespace and control characters are collapsed to keep the value a
|
||||
valid single-line HTTP header.
|
||||
"""
|
||||
raw = ":".join((p or "") for p in parts)
|
||||
return "".join(ch if 0x20 < ord(ch) < 0x7F else "_" for ch in raw) or "_"
|
||||
|
||||
|
||||
def idempotency_headers(*parts: str | None) -> dict[str, str]:
|
||||
"""Return the header dict to attach to a State Hub write."""
|
||||
return {IDEMPOTENCY_HEADER: idempotency_key(*parts)}
|
||||
Reference in New Issue
Block a user