Skip to content

Commit d105f1f

Browse files
committed
ack BYOC leases only after callback
1 parent 3bc4f4c commit d105f1f

4 files changed

Lines changed: 71 additions & 15 deletions

File tree

docs/BYOC_CONNECTOR.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ poll -> POST /api/connector/poll long-poll; lease the next queued job
2828
execute -> openadapt-flow run ... the governed admission gate + Replayer,
2929
against the CUSTOMER'S own storage
3030
callback -> POST /api/internal/run-callback PHI-free status/metrics
31-
ack -> POST /api/connector/ack release the lease (done|failed)
31+
ack -> POST /api/connector/ack release only after callback acceptance
3232
```
3333

3434
## Install and run
@@ -86,6 +86,12 @@ refuses any bundle that is not certified, identity-armed, effect-verified, and
8686
encrypted — those engine gates are unchanged, so identity checks, effect
8787
verification, and halt-don't-guess all remain intact.
8888

89+
The callback is retried a bounded three times. The Connector ACKs a job only
90+
after the control plane accepts its outcome. If delivery still fails, the lease
91+
is left unacknowledged; Cloud marks expiry as `lease_expired_uncertain` and
92+
never blindly re-offers it, so an operator checks the real effect before
93+
authorizing a fresh run.
94+
8995
## Enabling the lane (control plane)
9096

9197
The lane is off by default. An operator enables it with

openadapt_flow/connector/client.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -118,17 +118,17 @@ def run_callback(self, body: dict[str, Any], *, run_token: Optional[str]) -> Non
118118
"""POST PHI-free run status/metrics via the existing callback boundary.
119119
120120
Authenticated by the run-scoped ``x-run-token`` delivered in the job
121-
(proves this run; forbids forging another's status). Best-effort:
122-
observability/status must not crash the loop, but a hard transport error
123-
propagates so the caller records it.
121+
(proves this run; forbids forging another's status). Every non-2xx
122+
response propagates: the caller must not ACK a lease whose authoritative
123+
run outcome the control plane rejected or did not receive.
124124
"""
125125
headers = {"content-type": "application/json"}
126126
if run_token:
127127
headers["x-run-token"] = run_token
128128
resp = self._client.post(
129129
"/api/internal/run-callback", json=body, headers=headers
130130
)
131-
if resp.status_code >= 500:
131+
if not 200 <= resp.status_code < 300:
132132
raise ConnectorClientError(
133133
f"run-callback failed: {resp.status_code} {resp.text[:300]}"
134134
)

openadapt_flow/connector/daemon.py

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
from __future__ import annotations
1616

17+
import time
1718
from typing import Any, Callable, Optional
1819

1920
from openadapt_flow.connector.client import ConnectorClient
@@ -25,6 +26,7 @@
2526
#: storage_factory(job) -> CustomerStorage. Injected in tests; the default builds
2627
#: from settings + the job's backend hint.
2728
StorageFactory = Callable[[ByocJob], CustomerStorage]
29+
CALLBACK_ATTEMPTS = 3
2830

2931

3032
def phi_free_callback_body(job: ByocJob, result: ExecutionResult) -> dict[str, Any]:
@@ -81,16 +83,23 @@ def handle_job(
8183
"failed", {}, None, job.report_ref(), f"{type(exc).__name__}"
8284
)
8385

84-
# PHI-free callback (best effort; a transport error is caught so we still ack
85-
# and release the lease rather than stranding the job).
86-
try:
87-
client.run_callback(
88-
phi_free_callback_body(job, result), run_token=job.run_token
89-
)
90-
except Exception: # noqa: BLE001 - callback failure must not strand the lease
91-
pass
92-
93-
if job.lease_job_id:
86+
# The callback is the authoritative run outcome. Retry briefly for a
87+
# transient transport failure, but NEVER ACK the lease unless Cloud accepted
88+
# it. An unacked lease expires to `lease_expired_uncertain` and is not
89+
# automatically re-offered, preventing blind duplicate actuation after a
90+
# crash or partition.
91+
callback_accepted = False
92+
callback_body = phi_free_callback_body(job, result)
93+
for attempt in range(CALLBACK_ATTEMPTS):
94+
try:
95+
client.run_callback(callback_body, run_token=job.run_token)
96+
callback_accepted = True
97+
break
98+
except Exception: # noqa: BLE001 - bounded retry, then leave lease unacked
99+
if attempt + 1 < CALLBACK_ATTEMPTS:
100+
time.sleep(0.25 * (2**attempt))
101+
102+
if job.lease_job_id and callback_accepted:
94103
ack_status = "failed" if result.status == "failed" else "done"
95104
try:
96105
client.ack(job.lease_job_id, ack_status, result.error)
@@ -101,6 +110,7 @@ def handle_job(
101110
"job_id": job.lease_job_id,
102111
"run_id": job.run_id,
103112
"status": result.status,
113+
"callback_accepted": callback_accepted,
104114
}
105115

106116

tests/test_connector.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -408,6 +408,8 @@ def __init__(self):
408408
self.tokens = {} # token -> org_id
409409
self.jobs = [] # queued jobs (each {id, org_id, payload, status, leased_by})
410410
self.callbacks = []
411+
self.callback_attempts = 0
412+
self.callback_status = 200
411413
self.acks = []
412414
self._n = 0
413415

@@ -458,6 +460,9 @@ def handler(self, request: httpx.Request) -> httpx.Response:
458460
return httpx.Response(200, json={"ok": True})
459461

460462
if path == "/api/internal/run-callback":
463+
self.callback_attempts += 1
464+
if self.callback_status != 200:
465+
return httpx.Response(self.callback_status, json={"ok": False})
461466
self.callbacks.append({"headers": dict(request.headers), "body": body})
462467
return httpx.Response(200, json={"ok": True})
463468

@@ -520,6 +525,41 @@ def test_full_loop_dispatch_execute_callback_ack():
520525
assert cp.acks[0]["status"] == "done"
521526

522527

528+
@pytest.mark.parametrize("callback_status", [400, 503])
529+
def test_callback_rejection_is_retried_but_never_acked(callback_status, monkeypatch):
530+
monkeypatch.setattr("openadapt_flow.connector.daemon.time.sleep", lambda _: None)
531+
cp = StubControlPlane()
532+
cp.callback_status = callback_status
533+
cp.enqueue("org_demo", _payload())
534+
transport = httpx.MockTransport(cp.handler)
535+
client = ConnectorClient("https://app.test", transport=transport)
536+
client.enroll(enrollment_secret="s", org_id="org_demo", name="n")
537+
settings = ConnectorSettings(
538+
control_plane_url="https://app.test",
539+
org_id="org_demo",
540+
token=client.token,
541+
poll_wait_s=0,
542+
)
543+
544+
result = run_once(
545+
client,
546+
settings,
547+
runner=_fake_success_runner(SUCCESS_REPORT),
548+
storage_factory=lambda job: InMemoryCustomerStorage(bundle_bytes=_BUNDLE_BYTES),
549+
)
550+
551+
assert result == {
552+
"job_id": "bjob_1",
553+
"run_id": _payload()["run_id"],
554+
"status": "success",
555+
"callback_accepted": False,
556+
}
557+
assert cp.callback_attempts == 3
558+
assert cp.callbacks == []
559+
assert cp.acks == []
560+
assert cp.jobs[0]["status"] == "leased"
561+
562+
523563
def test_cross_org_isolation_connector_never_sees_another_orgs_job():
524564
cp = StubControlPlane()
525565
cp.enqueue("org_B", _payload(org_id="org_B")) # a DIFFERENT org's job

0 commit comments

Comments
 (0)