fix(issue-18): admit remote attempts atomically, end timed-out commands with their group, fence heartbeats off the terminal, renew liveness only on news
Review round 2 (planner-reviewer-codex-01, NOT PASS at d09c162):
- R1 remote.mirror_job: admission under the registry lock. The same
dispatch_token is idempotent (a finished attempt never runs again), a
lower attempt or the same attempt under another token is refused before
any write, and only a strictly newer attempt replaces the record
(register_job(supersede_older_attempt=True); events.log kept, seqs stay
monotonic).
- R2 remote.handle_dispatch: the --exec command runs in its own session;
at timeout_sec, or when the runner is interrupted (SIGTERM now unwinds
it), the whole process group is terminated (TERM, then KILL) before the
error is published.
- R3 terminal barrier: a terminal publish raises an attempt-scoped barrier
under the lock that allocates seqs, stops the heartbeat timer, gives it
MAM_HEARTBEAT_DRAIN_S (default 1 s) to exit and then kills it, and only
then takes its own seq. Beats are admitted under that lock
(next_seq(admit=...)); a beat still on the wire is cancelled with the
timer rather than holding the terminal back; and a late non-terminal
event never publishes a running state over a terminal record.
- R4 job_subscriber: a retained state renews the idle clock only when its
seq is newer than everything already accepted; re-delivered or older
snapshots still recover a terminal status.
- tests: five regressions, all failing on 77f2829; T1-14's wall clock
15 -> 30 s against public-broker latency.
- docs: MESSAGING 2.5 (barrier, state renewal, detection vs final close),
heartbeat docstring, loop SKILL.md (remote rows need the Python runner),
VERSIONS v4.5.0 items 1/4/5 and the test count (25).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGpJXhSZasJLU38ksDghe
This commit is contained in:
1 parent
77f2829b8e
commit
135d76f903
11 files changed
+577
-67
No files matched your search
@@ -122,6 +122,10 @@ def build_state_payload(job: Dict[str, Any], prepared: "PreparedEvent") -> Dict[
|
||||
event so a forged state cannot close a job.
|
||||
"""
|
||||
status = EVENT_TO_STATUS.get(prepared.event) or "running"
|
||||
if prepared.event not in TERMINAL_EVENTS and job.get("status") in TERMINAL_EVENTS:
|
||||
# A late non-terminal event must not publish a retained `running` over
|
||||
# the terminal snapshot (review round 2, codex R3).
|
||||
status = job["status"]
|
||||
data = {k: v for k, v in (prepared.payload.get("data") or {}).items()
|
||||
if k in ("dispatch_token", "attempt_id")}
|
||||
payload = build_payload(job_id=prepared.job_id, seq=prepared.seq, event="state",
|
||||
@@ -164,18 +168,20 @@ class PreparedEvent(object):
|
||||
|
||||
def prepare_event(job_id, event, detail="", data=None,
|
||||
registry_dir=DEFAULT_REGISTRY_DIR, retained=False,
|
||||
dispatch_token=None, attempt_id=None) -> PreparedEvent:
|
||||
dispatch_token=None, attempt_id=None, admit=None) -> PreparedEvent:
|
||||
"""Load the job, consume the next seq, and build the signed payload.
|
||||
|
||||
Consuming the seq here (before any transport runs) is deliberate: a failed
|
||||
publish must still burn the number, so a replayed packet can never carry a
|
||||
seq the subscriber would accept.
|
||||
seq the subscriber would accept. ``admit`` is checked under the lock that
|
||||
allocates the seq (``mqtt_common.next_seq``); a refusal raises
|
||||
``AdmissionRefused`` and consumes nothing.
|
||||
"""
|
||||
job = load_job(job_id, registry_dir)
|
||||
config = broker_config_from_job(job)
|
||||
topic = job.get("topic_prefix")
|
||||
topic = f"{topic}/events" if topic else events_topic_for(job_id)
|
||||
seq = next_seq(job_id, registry_dir)
|
||||
seq = next_seq(job_id, registry_dir) if admit is None else next_seq(job_id, registry_dir, admit=admit)
|
||||
|
||||
payload_data = dict(data) if data else {}
|
||||
eff_dispatch_token = dispatch_token
|
||||
@@ -265,7 +271,28 @@ def _resolve_job_id(args) -> Optional[str]:
|
||||
return args.job
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
def _beat_admission(job_id, registry_dir, dispatch_token, attempt_id):
|
||||
"""Admission for one heartbeat beat, evaluated under the lock that allocates
|
||||
its seq (review round 2, codex R3): never after this attempt's terminal
|
||||
barrier, on a terminal record, or after a re-dispatch."""
|
||||
def admit(record):
|
||||
status = record.get("status")
|
||||
if status in TERMINAL_EVENTS:
|
||||
return "job is {}".format(status)
|
||||
if heartbeat_timer.barrier_blocks(job_id, registry_dir, dispatch_token, attempt_id):
|
||||
return "the terminal barrier for this attempt is raised"
|
||||
if dispatch_token and record.get("dispatch_token") != dispatch_token:
|
||||
return "dispatch token changed (re-dispatched)"
|
||||
if attempt_id is not None and record.get("attempt_id", record.get("iteration", 1)) != attempt_id:
|
||||
return "attempt changed (re-dispatched)"
|
||||
return None
|
||||
return admit
|
||||
|
||||
|
||||
def main(argv=None, _beat: bool = False) -> int:
|
||||
"""CLI entry point. ``_beat`` is set only by the heartbeat timer
|
||||
(``heartbeat.run``): the beat is admitted under the seq lock
|
||||
(``_beat_admission``) and a refusal returns 4, which stops the timer."""
|
||||
parser = argparse.ArgumentParser(description="Publish a Job event to MQTT")
|
||||
target = parser.add_mutually_exclusive_group(required=True)
|
||||
target.add_argument("--job", help="job id to publish for")
|
||||
@@ -389,10 +416,19 @@ def main(argv=None) -> int:
|
||||
check_token, actual_tok)
|
||||
return 4
|
||||
|
||||
# Issue #18: a terminal event ends the heartbeat obligation. Stop the timer
|
||||
# before taking this event's seq so no heartbeat can follow `completed`.
|
||||
# Issue #18: a terminal event ends the heartbeat obligation. Review round 2
|
||||
# (codex R3): first raise this attempt's barrier - under the lock that
|
||||
# allocates seqs, so no beat can take a seq after it - then stop the timer
|
||||
# and wait for it to drain its in-flight beats (killing it if it does not),
|
||||
# and only then take this event's seq. No heartbeat can follow the terminal
|
||||
# event, on disk or on the wire.
|
||||
if args.event in TERMINAL_EVENTS:
|
||||
if heartbeat_timer.stop_timer(job_id, args.registry_dir):
|
||||
if rec is not None:
|
||||
heartbeat_timer.raise_barrier(
|
||||
job_id, args.registry_dir,
|
||||
dispatch_token=args.expect_dispatch_token or actual_tok,
|
||||
attempt_id=args.attempt_id if args.attempt_id is not None else rec_attempt)
|
||||
if heartbeat_timer.stop_timer(job_id, args.registry_dir, drain=heartbeat_timer.drain_seconds()):
|
||||
logger.info("stopped the heartbeat timer for job %s", job_id)
|
||||
|
||||
try:
|
||||
@@ -405,7 +441,11 @@ def main(argv=None) -> int:
|
||||
retained=args.retained,
|
||||
dispatch_token=args.expect_dispatch_token,
|
||||
attempt_id=args.attempt_id,
|
||||
admit=_beat_admission(job_id, args.registry_dir, check_token, check_attempt) if _beat else None,
|
||||
)
|
||||
except mqtt_common.AdmissionRefused as exc:
|
||||
logger.info("heartbeat for job %s not published: %s", job_id, exc)
|
||||
return 4
|
||||
except FileNotFoundError as exc:
|
||||
logger.error("%s", exc)
|
||||
return 1
|
||||
|
||||
Reference in new issue
Block a user