diff --git a/docs/concepts/jobs.md b/docs/concepts/jobs.md
index d36603f3c..7ec572776 100644
--- a/docs/concepts/jobs.md
+++ b/docs/concepts/jobs.md
@@ -485,9 +485,42 @@ such jobs run unbounded, subject only to lease recovery.
For a job that runs for hours: set `max_duration` to its real
per-attempt runtime (this sizes the lease AND floors the stale
-ceiling) and/or emit periodic progress to keep the lease renewed. Set
-`total_deadline` if you want a hard total-runtime bound across all
-attempts, or to opt out of the registry-wide stale ceiling entirely.
+ceiling). A handler sitting in `recv_event` gates renews the lease on
+every poll; one that computes silently for long stretches should emit
+periodic progress to keep the lease renewed. Set `total_deadline` if you
+want a hard total-runtime bound across all attempts, or to opt out of the
+registry-wide stale ceiling entirely.
+
+`MCP_MESH_JOB_STALE_TIMEOUT` is a **reap ceiling, not the lease window** —
+it caps total runtime from submission but does not size or extend the
+per-attempt lease. The lease window is derived per-job from `max_duration`
+(300s default) and is renewed by progress deltas and executor `recv_event`
+polls; tuning the stale timeout does not change how quickly a quiet
+handler's lease expires.
+
+### Multi-replica fencing and poll-liveness
+
+Several replicas can declare the same `task=true` capability. Each job is
+claimed by exactly one replica per attempt (the guarded atomic `UPDATE`
+above, so concurrent claimers race and one wins). The owner's lease is
+renewed by **any accepted non-terminal delta and any executor `recv_event`
+poll from the current claim** — a handler parked in a legitimate
+`recv_event` gate is provably alive, so no artificial `update_progress`
+keepalives are needed. Poll-liveness is capped so it never pushes the lease
+past `total_deadline` or the stale ceiling.
+
+If a lease genuinely expires (a handler wedged, neither progressing nor
+polling), the sweep returns the job to the pool and a peer re-claims it.
+Every claim — including a same-replica re-claim — carries a monotonically
+increasing **claim epoch**. The registry fences the superseded execution:
+its writes are rejected `claim_superseded` and its cancel token fires, so a
+parked `recv_event` breaks out with the cancelled error and the handler
+observes cancellation exactly as it would a user cancel. Handlers should
+honor cancellation promptly; the read-only `claimEpoch` accessor on the job
+context (`job.claim_epoch` in Python, `currentJob()?.claimEpoch` in
+TypeScript, `JobContext.current().claimEpoch` in Java) can be stamped onto
+external side effects so downstream can dedupe a fenced re-execution's
+duplicate write.
The **agent runtime** is a thin client. On the producer side, it
maintains a claim queue per `task=true` capability (`UPDATE jobs SET
@@ -564,7 +597,7 @@ sequenceDiagram
Note over R: Append-only log (per-job seq)
H->>R: GET /jobs/{id}/events?after=N (long-poll)
R-->>H: event dict (or empty after timeout)
- Note over H: Cursor advances per-controller
+ Note over H: Cursor advances per-controller, per-filter
```
### Receiving events inside a handler
@@ -640,9 +673,15 @@ The producer-side recv loop. Filter by `types` to drop noise, set a
```
-The handler's `recv_event` cursor is per-`JobController` instance — a
-fresh controller for the same `job_id` replays from `seq=0`. The
-running handler always sees events in monotonic seq order.
+The handler's `recv_event` cursor is per-`JobController` instance **and
+per-`types` filter**: each distinct filter is an independent stream with
+its own cursor, so interleaving `recv_event(types=["A"])` and
+`recv_event(types=["B"])` never lets one filter's consumption skip the
+other's earlier events. Delivery is exactly-once within a filter stream
+and at-least-once across different filters. A fresh controller for the
+same `job_id` (e.g. a re-claim) replays every filter from `seq=0`; the
+running handler always sees events in monotonic seq order within a
+stream.
### Posting events from outside the handler
diff --git a/src/core/cli/man/content/environment.md b/src/core/cli/man/content/environment.md
index 93e047fbc..d3b6dec7d 100644
--- a/src/core/cli/man/content/environment.md
+++ b/src/core/cli/man/content/environment.md
@@ -531,6 +531,12 @@ export MCP_MESH_SWEEP_INTERVAL=5m
# set their own total_deadline are fully exempt. Default: off (unset /
# "0") — jobs without an explicit total_deadline run unbounded, subject
# only to lease recovery.
+#
+# NOT the lease window. This is a total-runtime REAP CEILING (measured
+# from submission); it does not size or extend the per-attempt lease. The
+# lease window is derived per-job from max_duration (300s default) and is
+# renewed by progress deltas / recvEvent polls — tuning this variable does
+# NOT change how quickly a quiet handler's lease expires.
export MCP_MESH_JOB_STALE_TIMEOUT=2h
# CORS
diff --git a/src/core/cli/man/content/jobs.md b/src/core/cli/man/content/jobs.md
index b1761ce2d..6d5976fd5 100644
--- a/src/core/cli/man/content/jobs.md
+++ b/src/core/cli/man/content/jobs.md
@@ -237,6 +237,17 @@ elif event["type"] == "cancelled":
return {"status": "cancelled", "reason": event["payload"].get("reason")}
```
+Each distinct `types` filter is an **independent event stream** with its
+own cursor, so interleaving `recv_event(types=["A"])` and
+`recv_event(types=["B"])` never lets one filter's consumption skip the
+other's earlier events. Delivery is **exactly-once within a filter stream**
+and **at-least-once across different filters** (an event matching two
+filters can surface once per stream). The cursor is per-handler and starts
+at the beginning of the log on every (re-)claim — a re-claimed handler
+replays from the start. Handlers doing **non-idempotent** work per event
+should checkpoint (e.g. persist their own event cursor) or design
+idempotent phases.
+
**Outside the handler, with a `job_id` in scope — fire an event:**
```python
@@ -415,13 +426,22 @@ intervention:
the matching capability picks it up. Jobs parked in `input_required`
are covered too — a job waiting on a consumer answer whose owner then
dies is reclaimed, not stranded.
-- **Lease recovery.** Every accepted progress/heartbeat delta extends
- the owner's lease. A job whose lease expires with no further deltas —
- a wedged or silently-crashed handler — is reset to claimable while
- retries remain, or marked `failed` once the retry budget is spent.
- This includes jobs parked in `input_required`: if the producer stops
- extending the lease while waiting for an answer that never comes, the
- job is reclaimed rather than held forever.
+- **Lease recovery.** The lease window is derived from `max_duration`
+ (the 300s claim default when undeclared) — declare `max_duration` ≈
+ the job's real per-attempt ceiling for long-running work. The lease
+ renews on **any accepted non-terminal delta AND any `recv_event` poll
+ from the current claim**: a handler parked in a legitimate `recv_event`
+ gate is provably alive, so no artificial `update_progress` keepalives
+ are needed. Poll-liveness never pushes the lease past the job's
+ `total_deadline` or stale ceiling, so an actively-polling handler
+ cannot outlive the point where the sweep would legitimately reap it. A
+ job whose lease expires with no renewal — a genuinely wedged handler,
+ neither progressing nor polling — is reset to claimable while retries
+ remain, or marked `failed` once the retry budget is spent. This
+ includes jobs parked in `input_required`: a handler that crashes while
+ awaiting an answer stops renewing and is reclaimed rather than held
+ forever (a live handler polling `recv_event` for the answer keeps its
+ lease renewed).
- **Total-deadline ceiling.** A job that set `total_deadline` is failed
with `deadline_exceeded` once that wall-clock deadline passes.
- **Default stale ceiling (opt-in).** Set `MCP_MESH_JOB_STALE_TIMEOUT`
@@ -441,10 +461,45 @@ Reaping is observable in-handler via a synthetic `stale` event — see
**Long-running (hours) jobs.** Set `max_duration` to the job's real
per-attempt runtime — this sizes the lease window AND floors the stale
-ceiling — and/or emit periodic progress to keep the lease renewed. Set
+ceiling. A handler sitting in `recv_event` gates renews the lease on
+every poll; one that computes silently for long stretches should emit
+periodic `update_progress` to keep the lease renewed. Set
`total_deadline` if you want a hard total-runtime bound across all
attempts, or to opt out of the registry-wide stale ceiling entirely.
+## Multi-replica execution and fencing
+
+Several replicas may declare the same `task=True` capability. Each job is
+claimed by **exactly one** replica per attempt — the claim is a guarded
+atomic update, so concurrent claimers race and only one wins. If a lease
+genuinely expires (a handler wedged, neither progressing nor polling), the
+sweep returns the job to the pool and a peer re-claims it.
+
+Every claim — including a re-claim of the same job, even by the same
+replica — carries a **monotonically increasing claim epoch**. The registry
+fences the superseded execution: its writes are rejected
+(`claim_superseded`) and it observes cancellation through the **same
+surface as a user cancel** — the handler's cancel token fires, so a parked
+`recv_event` raises the cancelled error and the next `await` point raises
+`asyncio.CancelledError`. Honor cancellation promptly (return / stop side
+effects) instead of running to completion under a lost claim.
+
+For a side effect a fenced re-execution might repeat, stamp it with the
+claim epoch so downstream can dedupe:
+
+```python
+@app.tool()
+@mesh.tool(capability="charge_card", task=True)
+async def charge_card(order_id: str, amount: float, job: mesh.MeshJob = None) -> dict:
+ # job.claim_epoch is the generation this attempt runs under (int, or
+ # None for a push-mode inbound job / an old registry). Read-only and
+ # additive — supersession works without it (it rides the cancellation
+ # path); the epoch exists only so downstream can distinguish a fenced
+ # re-execution's duplicate write.
+ await ledger.upsert(key=order_id, claim_epoch=job.claim_epoch, amount=amount)
+ ...
+```
+
## Out-of-band inspection
Three SDK-managed helper tools are auto-registered on every mesh
diff --git a/src/core/cli/man/content/jobs_java.md b/src/core/cli/man/content/jobs_java.md
index 71fee49f7..5936e1c63 100644
--- a/src/core/cli/man/content/jobs_java.md
+++ b/src/core/cli/man/content/jobs_java.md
@@ -345,6 +345,17 @@ public Map runWorkflow(
}
```
+Each distinct `types` filter is an **independent event stream** with its
+own cursor, so interleaving `recvEvent(List.of("A"), ...)` and
+`recvEvent(List.of("B"), ...)` never lets one filter's consumption skip
+the other's earlier events. Delivery is **exactly-once within a filter
+stream** and **at-least-once across different filters** (an event matching
+two filters can surface once per stream). The cursor is per-handler and
+starts at the beginning of the log on every (re-)claim — a re-claimed
+handler replays from the start. Handlers doing **non-idempotent** work per
+event should checkpoint (e.g. persist their own event cursor) or design
+idempotent phases.
+
**Outside the handler, with a `jobId` in scope — fire an event:**
```java
@@ -601,13 +612,22 @@ intervention:
the matching capability picks it up. Jobs parked in `input_required`
are covered too — a job waiting on a consumer answer whose owner then
dies is reclaimed, not stranded.
-- **Lease recovery.** Every accepted progress/heartbeat delta extends
- the owner's lease. A job whose lease expires with no further deltas —
- a wedged or silently-crashed handler — is reset to claimable while
- retries remain, or marked `failed` once the retry budget is spent.
- This includes jobs parked in `input_required`: if the producer stops
- extending the lease while waiting for an answer that never comes, the
- job is reclaimed rather than held forever.
+- **Lease recovery.** The lease window is derived from `maxDuration`
+ (the 300s claim default when undeclared) — declare `maxDuration` ≈
+ the job's real per-attempt ceiling for long-running work. The lease
+ renews on **any accepted non-terminal delta AND any `recvEvent` poll
+ from the current claim**: a handler parked in a legitimate `recvEvent`
+ gate is provably alive, so no artificial `updateProgress` keepalives
+ are needed. Poll-liveness never pushes the lease past the job's
+ `totalDeadline` or stale ceiling, so an actively-polling handler
+ cannot outlive the point where the sweep would legitimately reap it. A
+ job whose lease expires with no renewal — a genuinely wedged handler,
+ neither progressing nor polling — is reset to claimable while retries
+ remain, or marked `failed` once the retry budget is spent. This
+ includes jobs parked in `input_required`: a handler that crashes while
+ awaiting an answer stops renewing and is reclaimed rather than held
+ forever (a live handler polling `recvEvent` for the answer keeps its
+ lease renewed).
- **Total-deadline ceiling.** A job that set `totalDeadline` is failed
with `deadline_exceeded` once that wall-clock deadline passes.
- **Default stale ceiling (opt-in).** Set `MCP_MESH_JOB_STALE_TIMEOUT`
@@ -627,9 +647,44 @@ Reaping is observable in-handler via a synthetic `stale` event — see
**Long-running (hours) jobs.** Set `maxDuration` to the job's real
per-attempt runtime — this sizes the lease window AND floors the stale
-ceiling — and/or emit periodic progress to keep the lease renewed. Set
-`totalDeadline` if you want a hard total-runtime bound across all
-attempts, or to opt out of the registry-wide stale ceiling entirely.
+ceiling. A handler sitting in `recvEvent` gates renews the lease on
+every poll; one that computes silently for long stretches should emit
+periodic `updateProgress` to keep the lease renewed. Set `totalDeadline`
+if you want a hard total-runtime bound across all attempts, or to opt out
+of the registry-wide stale ceiling entirely.
+
+## Multi-replica execution and fencing
+
+Several replicas may declare the same `task = true` capability. Each job is
+claimed by **exactly one** replica per attempt — the claim is a guarded
+atomic update, so concurrent claimers race and only one wins. If a lease
+genuinely expires (a handler wedged, neither progressing nor polling), the
+sweep returns the job to the pool and a peer re-claims it.
+
+Every claim — including a re-claim of the same job, even by the same
+replica — carries a **monotonically increasing claim epoch**. The registry
+fences the superseded execution: its writes are rejected
+(`claim_superseded`) and it observes cancellation the same way a user
+cancel surfaces — `controller.isCancelled()` turns `true` (poll it between
+work units) and a parked `recvEvent` breaks out with the cancelled error.
+Honor cancellation promptly (return / stop side effects) instead of running
+to completion under a lost claim.
+
+For a side effect a fenced re-execution might repeat, stamp it with the
+claim epoch so downstream can dedupe. Read the epoch from the active job
+context:
+
+```java
+import io.mcpmesh.JobContext;
+
+// JobContext.current().claimEpoch is the generation this attempt runs
+// under (Long, or null for a push-mode inbound job / an old registry).
+// Read-only and additive — supersession works without it (it rides the
+// cancellation path); the epoch exists only so downstream can distinguish
+// a fenced re-execution's duplicate write.
+Long claimEpoch = JobContext.current().claimEpoch;
+ledger.upsert(orderId, claimEpoch, amount);
+```
## Out-of-band inspection
diff --git a/src/core/cli/man/content/jobs_typescript.md b/src/core/cli/man/content/jobs_typescript.md
index ccc4b46ba..40df43000 100644
--- a/src/core/cli/man/content/jobs_typescript.md
+++ b/src/core/cli/man/content/jobs_typescript.md
@@ -272,6 +272,16 @@ if (event === null) {
}
```
+Each distinct `types` filter is an **independent event stream** with its
+own cursor, so interleaving `recvEvent(["A"])` and `recvEvent(["B"])`
+never lets one filter's consumption skip the other's earlier events.
+Delivery is **exactly-once within a filter stream** and **at-least-once
+across different filters** (an event matching two filters can surface once
+per stream). The cursor is per-handler and starts at the beginning of the
+log on every (re-)claim — a re-claimed handler replays from the start.
+Handlers doing **non-idempotent** work per event should checkpoint (e.g.
+persist their own event cursor) or design idempotent phases.
+
**Outside the handler, with a `jobId` in scope — fire an event:**
```typescript
@@ -463,13 +473,22 @@ intervention:
the matching capability picks it up. Jobs parked in `input_required`
are covered too — a job waiting on a consumer answer whose owner then
dies is reclaimed, not stranded.
-- **Lease recovery.** Every accepted progress/heartbeat delta extends
- the owner's lease. A job whose lease expires with no further deltas —
- a wedged or silently-crashed handler — is reset to claimable while
- retries remain, or marked `failed` once the retry budget is spent.
- This includes jobs parked in `input_required`: if the producer stops
- extending the lease while waiting for an answer that never comes, the
- job is reclaimed rather than held forever.
+- **Lease recovery.** The lease window is derived from `maxDuration`
+ (the 300s claim default when undeclared) — declare `maxDuration` ≈
+ the job's real per-attempt ceiling for long-running work. The lease
+ renews on **any accepted non-terminal delta AND any `recvEvent` poll
+ from the current claim**: a handler parked in a legitimate `recvEvent`
+ gate is provably alive, so no artificial `updateProgress` keepalives
+ are needed. Poll-liveness never pushes the lease past the job's
+ `totalDeadline` or stale ceiling, so an actively-polling handler
+ cannot outlive the point where the sweep would legitimately reap it. A
+ job whose lease expires with no renewal — a genuinely wedged handler,
+ neither progressing nor polling — is reset to claimable while retries
+ remain, or marked `failed` once the retry budget is spent. This
+ includes jobs parked in `input_required`: a handler that crashes while
+ awaiting an answer stops renewing and is reclaimed rather than held
+ forever (a live handler polling `recvEvent` for the answer keeps its
+ lease renewed).
- **Total-deadline ceiling.** A job that set `totalDeadline` is failed
with `deadline_exceeded` once that wall-clock deadline passes.
- **Default stale ceiling (opt-in).** Set `MCP_MESH_JOB_STALE_TIMEOUT`
@@ -489,9 +508,44 @@ Reaping is observable in-handler via a synthetic `stale` event — see
**Long-running (hours) jobs.** Set `maxDuration` to the job's real
per-attempt runtime — this sizes the lease window AND floors the stale
-ceiling — and/or emit periodic progress to keep the lease renewed. Set
-`totalDeadline` if you want a hard total-runtime bound across all
-attempts, or to opt out of the registry-wide stale ceiling entirely.
+ceiling. A handler sitting in `recvEvent` gates renews the lease on
+every poll; one that computes silently for long stretches should emit
+periodic `updateProgress` to keep the lease renewed. Set `totalDeadline`
+if you want a hard total-runtime bound across all attempts, or to opt out
+of the registry-wide stale ceiling entirely.
+
+## Multi-replica execution and fencing
+
+Several replicas may declare the same `task: true` capability. Each job is
+claimed by **exactly one** replica per attempt — the claim is a guarded
+atomic update, so concurrent claimers race and only one wins. If a lease
+genuinely expires (a handler wedged, neither progressing nor polling), the
+sweep returns the job to the pool and a peer re-claims it.
+
+Every claim — including a re-claim of the same job, even by the same
+replica — carries a **monotonically increasing claim epoch**. The registry
+fences the superseded execution: its writes are rejected
+(`claim_superseded`) and it observes cancellation through the **same
+surface as a user cancel** — the handler's `AbortSignal` (`job?.signal`)
+aborts and a parked `recvEvent` rejects with the cancelled error. Honor
+cancellation promptly (return / stop side effects) instead of running to
+completion under a lost claim.
+
+For a side effect a fenced re-execution might repeat, stamp it with the
+claim epoch so downstream can dedupe. Read the epoch from the active job
+context:
+
+```typescript
+import { currentJob } from "@mcpmesh/sdk";
+
+// currentJob()?.claimEpoch is the generation this attempt runs under
+// (number, or null for a push-mode inbound job / an old registry).
+// Read-only and additive — supersession works without it (it rides the
+// cancellation path); the epoch exists only so downstream can distinguish
+// a fenced re-execution's duplicate write.
+const claimEpoch = currentJob()?.claimEpoch ?? null;
+await ledger.upsert({ key: orderId, claimEpoch, amount });
+```
## Out-of-band inspection
diff --git a/src/runtime/core/src/jobs.rs b/src/runtime/core/src/jobs.rs
index 104bec551..fad651d99 100644
--- a/src/runtime/core/src/jobs.rs
+++ b/src/runtime/core/src/jobs.rs
@@ -10,7 +10,7 @@
use std::collections::HashMap;
use std::future::Future;
-use std::sync::atomic::{AtomicI64, Ordering};
+use std::sync::atomic::Ordering;
use std::sync::Arc;
use std::time::Duration;
@@ -296,10 +296,12 @@ async fn flush_once(
///
/// Also exposes [`Self::recv_event`] for handlers running inside a
/// `task=True` job to drain events posted via [`JobProxy::send_event`].
-/// The per-controller `last_seen_seq` cursor is shared across `Clone`s of
-/// the same instance (via `Arc`), so passing the controller
-/// into helper tasks doesn't reset the cursor. A NEW controller created
-/// for the same `job_id` starts from seq=0 — replay is per-instance.
+/// Event delivery uses PER-FILTER cursors (issue #1252 Phase 3): each
+/// distinct type filter is an independent stream with its own cursor,
+/// shared across `Clone`s of the same instance, so consuming a type-A
+/// match at seq N no longer skips a type-B event at seq < N. A NEW
+/// controller for the same `job_id` starts every cursor at seq=0 — replay
+/// is per-instance, per filter.
#[derive(Clone)]
pub struct JobController {
job_id: String,
@@ -324,20 +326,31 @@ pub struct JobController {
/// queue-resident so the shared claim-worker queue doesn't accumulate
/// one entry per completed job forever (issue #1166 LOW).
terminal: Arc,
- /// Last event seq returned by `recv_event` on this controller (or any
- /// of its `Clone`s). `0` means no events consumed yet — the next
- /// `recv_event` will return the first event in the job's event log.
- last_seen_seq: Arc,
- /// Serialises the entire `recv_event` load→list→store window across
- /// `Clone`s of this controller. Without this, two concurrent callers
- /// on clones can both load the same `last_seen_seq`, both call
- /// `list_job_events`, and both return the SAME head event — the
- /// AtomicI64 prevents memory tearing but not the read-fetch-write
- /// race across the `.await`. The `Arc` keeps clones cheap while
- /// pinning every clone to the same lock instance; lock contention
- /// is naturally bounded to the small number of helper tasks a
- /// single handler typically spawns.
- recv_event_lock: Arc>,
+ /// Per-filter event cursors (issue #1252 Phase 3). Keyed by canonical
+ /// filter identity ([`Self::filter_key`]): the sorted+deduped type list,
+ /// with `None`/empty (the unfiltered stream) collapsing to one distinct
+ /// key. Each `recv_event` reads/advances ONLY its own filter's cursor,
+ /// so consuming a type-A match at seq N no longer permanently skips a
+ /// type-B event at seq < N (the shared-cursor defect).
+ ///
+ /// Stream semantics: delivery is exactly-once WITHIN a filter stream; an
+ /// event matching two DIFFERENT filter streams may be observed once per
+ /// stream (intentional at-least-once ACROSS streams — consistent with
+ /// the at-least-once job model). Shared across `Clone`s; a NEW controller
+ /// for the same `job_id` starts every cursor at 0 (replay-from-0 per
+ /// filter on re-claim). Advancement is monotonic per key (never retreats).
+ cursors: Arc>>,
+ /// Per-filter serialization locks. `recv_event` holds ONLY its own
+ /// filter's async lock across the load→list→store window, so concurrent
+ /// calls on DIFFERENT filters make progress independently (a 60s type-A
+ /// long-poll must NOT block a type-B call — the whole point of per-filter
+ /// cursors), while two calls on the SAME filter serialize so they can't
+ /// both load the same cursor, both `list_job_events` with the same
+ /// `after`, and both return the SAME head event (the read-fetch-write
+ /// race across the `.await`). Shared across `Clone`s; the map grows one
+ /// small entry per distinct filter the handler uses (bounded), never per
+ /// event.
+ recv_locks: Arc>>>>,
}
impl JobController {
@@ -374,8 +387,45 @@ impl JobController {
backend,
queue,
terminal: Arc::new(std::sync::atomic::AtomicBool::new(false)),
- last_seen_seq: Arc::new(AtomicI64::new(0)),
- recv_event_lock: Arc::new(Mutex::new(())),
+ cursors: Arc::new(std::sync::Mutex::new(HashMap::new())),
+ recv_locks: Arc::new(std::sync::Mutex::new(HashMap::new())),
+ }
+ }
+
+ /// Canonical identity of a `recv_event` type filter, used as the
+ /// per-filter cursor key (issue #1252 Phase 3). `None`, an empty list, and
+ /// a list of only empty/whitespace strings all mean "all events" and
+ /// collapse to ONE unfiltered key (the empty string): each entry is
+ /// trimmed and empty entries are dropped, mirroring the registry's own
+ /// `types` parsing (`strings.TrimSpace` + drop-empty in
+ /// `ent_handlers_jobs.go`) so `Some(vec!["".into()])` canonicalizes — and
+ /// filters — identically on both backends. The surviving entries are then
+ /// sorted + deduped and joined with a non-printable unit separator, so
+ /// `["B","A"]`, `["A","B"]`, and `["A","A","B"]` share one cursor while
+ /// `["AB"]` and `["A","B"]` do NOT collide.
+ ///
+ /// Unsupported edge inputs (documented, not defended — no known caller
+ /// passes them): a type name that itself contains the U+001F unit
+ /// separator collides with the joined form of a multi-type filter (e.g.
+ /// `["A\u{1f}B"]` shares a key with `["A","B"]`), and a type name
+ /// containing a comma (`["a,b"]`) is a single filter on the client but,
+ /// once serialized as the comma-joined `types` query param and re-split by
+ /// the registry, becomes the server-identical filter `["a","b"]` — so two
+ /// distinct client cursors map to one server-side filter. Callers pass
+ /// plain event-type identifiers, which never contain these bytes.
+ fn filter_key(types: &Option>) -> String {
+ match types {
+ None => String::new(),
+ Some(ts) => {
+ let mut v: Vec<&str> = ts
+ .iter()
+ .map(|s| s.trim())
+ .filter(|s| !s.is_empty())
+ .collect();
+ v.sort_unstable();
+ v.dedup();
+ v.join("\u{1f}")
+ }
}
}
@@ -651,14 +701,19 @@ impl JobController {
///
/// On first call (or for a freshly-constructed controller) starts from
/// `seq=0` and returns the first matching event in the log. Subsequent
- /// calls return events strictly newer than the last consumed — the
- /// internal cursor is incremented on every successful return. The
- /// cursor is per-`JobController`-instance (shared across `Clone`s of
- /// the same instance via `Arc`); a NEW controller for the
- /// same `job_id` starts replay from `seq=0`.
+ /// calls return events strictly newer than the last consumed FOR THE
+ /// SAME FILTER — each distinct `types` filter has its OWN cursor (issue
+ /// #1252 Phase 3), so interleaving `recv_event(A)` and `recv_event(B)`
+ /// never lets one filter's consumption skip the other's earlier events.
+ /// Cursors are per-`JobController`-instance (shared across `Clone`s); a
+ /// NEW controller for the same `job_id` replays every filter from
+ /// `seq=0`.
///
/// `types`: if `Some`, only events whose `event_type` matches one of
/// the provided strings are returned. If `None`, all event types match.
+ /// The filter identity is canonical (order- and duplicate-insensitive;
+ /// `None` and `Some([])` are the same unfiltered stream) — see
+ /// [`Self::filter_key`].
///
/// `timeout` bounds the long-poll. `None` waits indefinitely (loops
/// the registry's 60s cap until an event arrives). `Some(d)` returns
@@ -689,16 +744,65 @@ impl JobController {
// Server-side limit (matches OpenAPI `limit` maximum).
const FETCH_LIMIT: usize = 100;
- // Serialise the load→list→store window across `Clone`s of this
- // controller. Without this, two concurrent callers can both load
- // the same `last_seen_seq` cursor, both issue `list_job_events`
- // with the same `after`, and both return the SAME head event.
- // The AtomicI64 prevents tearing but not the read-fetch-write
- // race across the `.await`. Acquire BEFORE the deadline check
- // so a fast clone-vs-clone race ordering is deterministic.
- let _guard = self.recv_event_lock.lock().await;
+ // Per-filter cursor + serialization (issue #1252 Phase 3). This
+ // filter's identity keys BOTH its cursor and its serialization lock.
+ // Hold ONLY this filter's lock across the load→list→store window:
+ // two calls on the SAME filter serialize (so they can't both load the
+ // same cursor, both `list_job_events` with the same `after`, and both
+ // return the SAME head event across the `.await`), while calls on
+ // DIFFERENT filters run concurrently — a 60s type-A long-poll must not
+ // block a type-B call.
+ let key = Self::filter_key(&types);
+ let filter_lock = {
+ let mut locks = self.recv_locks.lock().expect("recv_locks poisoned");
+ locks
+ .entry(key.clone())
+ .or_insert_with(|| Arc::new(Mutex::new(())))
+ .clone()
+ };
+ // Compute the deadline at ENTRY — BEFORE awaiting the per-filter lock
+ // — so time spent QUEUED behind a same-filter long-poll counts against
+ // the caller's OWN budget (issue #1252 review). Acquiring the lock
+ // first and only THEN starting the clock let a queued same-filter
+ // caller run for lock-wait + full-budget, blowing past its `timeout`.
let deadline = timeout.map(|t| Instant::now() + t);
+
+ // Snapshot this execution's cancel/supersession token so the lock
+ // acquisition below can be raced against it: a cancelled execution
+ // must NOT sit parked on the per-filter lock waiting out a sibling's
+ // 60s long-poll. Watch THIS execution's OWN frame (keyed by claim
+ // epoch); a legacy controller (no epoch) watches the top-of-stack.
+ let queue_cancel_token = match self.claim_epoch {
+ Some(epoch) => cancel_registry::get_state_for_epoch(&self.job_id, epoch),
+ None => cancel_registry::get_state(&self.job_id),
+ }
+ .map(|(cancel, _ended)| cancel);
+
+ // Acquire the per-filter serialization lock, racing it against BOTH
+ // the caller's remaining budget (return `Ok(None)` on exhaustion — the
+ // same shape a normal long-poll timeout takes) AND the cancel token
+ // (return `Cancelled`). `wait_on_optional_cancel` never completes when
+ // there is no token; the deadline branch never completes when
+ // `timeout` is `None` — so an untimed, uncancellable call simply awaits
+ // the lock. Once acquired, the loop below derives every poll's budget
+ // from `deadline`, so wait-on-lock time is already subtracted.
+ let _guard = {
+ let deadline_sleep = async {
+ match deadline {
+ Some(d) => tokio::time::sleep_until(d).await,
+ None => std::future::pending::<()>().await,
+ }
+ };
+ tokio::select! {
+ biased;
+ _ = wait_on_optional_cancel(&queue_cancel_token) => {
+ return Err(JobError::Cancelled)
+ }
+ _ = deadline_sleep => return Ok(None),
+ g = filter_lock.lock() => g,
+ }
+ };
// Tracks the start of the current contiguous transient-failure
// window for the same recovery semantics as `JobProxy::wait`.
let mut transient_failure_started: Option = None;
@@ -758,7 +862,15 @@ impl JobController {
}
}
- let after = self.last_seen_seq.load(Ordering::SeqCst);
+ // Read THIS filter's cursor (default 0 for a never-consumed
+ // filter). The per-filter lock above serializes same-filter
+ // callers, so the load→list→store window is race-free.
+ let after = *self
+ .cursors
+ .lock()
+ .expect("cursors poisoned")
+ .get(&key)
+ .unwrap_or(&0);
let types_ref = types.as_deref();
// Executor identity: the OWNER's controller supplies
// `(instance_id, claim_epoch)` so the registry fences this read
@@ -793,19 +905,40 @@ impl JobController {
backoff_ms = POLL_INITIAL_MS;
// First matching event wins. The registry already
// filters by `types` and orders ascending; we return
- // the head and advance the cursor to its seq so the
- // next call picks up strictly after it.
+ // the head and advance THIS filter's cursor to its seq so
+ // the next same-filter call picks up strictly after it.
+ // Advance monotonically (never retreat).
if let Some(ev) = resp.events.into_iter().next() {
- self.last_seen_seq.store(ev.seq, Ordering::SeqCst);
+ let mut cursors = self.cursors.lock().expect("cursors poisoned");
+ let cur = cursors.entry(key.clone()).or_insert(0);
+ if ev.seq > *cur {
+ *cur = ev.seq;
+ }
return Ok(Some(ev));
}
- // Empty page: keep next_after as the cursor (matches
- // the registry's contract — the watermark may have
- // advanced even though no matching event arrived).
- // We only ever advance, never retreat.
- let cur = self.last_seen_seq.load(Ordering::SeqCst);
- if resp.next_after > cur {
- self.last_seen_seq.store(resp.next_after, Ordering::SeqCst);
+ // Empty (no-match) page. Under the registry's contract a
+ // no-match page returns `next_after == after` — the
+ // watermark does NOT leap past events this filter didn't
+ // return. The `list_events_mock_matches_registry_contract`
+ // test pins that the in-process mock reproduces this
+ // contract AND that RegistryHttpBackend sends the right
+ // request (`after` + `types`) and parses the reply the same
+ // way; the registry's own adherence to the contract is
+ // pinned Go-side (see
+ // `src/core/registry/ent_handlers_job_events_test.go`). So
+ // this branch is a DEFENSIVE no-op under that
+ // contract: it advances this filter's cursor only if a
+ // backend ever reported `next_after` strictly ahead of the
+ // cursor, and it never retreats. It must NOT advance past
+ // unreturned events for OTHER filters — the cursor is
+ // per-filter, so an unfiltered `next_after` can never
+ // clobber a type filter's position.
+ if resp.next_after > after {
+ let mut cursors = self.cursors.lock().expect("cursors poisoned");
+ let cur = cursors.entry(key.clone()).or_insert(0);
+ if resp.next_after > *cur {
+ *cur = resp.next_after;
+ }
}
// No event yet — loop. The deadline check at the top
// of the next iteration returns Ok(None) if expired.
@@ -1324,10 +1457,22 @@ mod tests {
use crate::task_backend::{
CancelJobResponse, ClaimedJob, JobBatchResponse, JobEventListResponse,
+ RegistryHttpBackend,
};
// ---- mock backend -------------------------------------------------------
+ /// Test gate for [`MockBackend::list_job_events`]. When installed, an
+ /// EMPTY result page fires `entered` and then parks on `release` — letting
+ /// a test PROVE a `recv_event` long-poll is in-flight (and thus holding
+ /// its per-filter lock) before a second call starts, closing the "did the
+ /// two calls actually overlap?" gap. `release` is level-triggered: once
+ /// cancelled, subsequent empty pages return without blocking.
+ struct ListGate {
+ entered: tokio::sync::Notify,
+ release: CancellationToken,
+ }
+
/// What `get_job` should return for the next call.
enum GetJobInjection {
/// Return the in-memory job state normally.
@@ -1381,6 +1526,10 @@ mod tests {
always_transient: AtomicUsize, // 0 = false, 1 = true
/// If set, every `get_job` returns NotFound (non-transient).
always_not_found: AtomicUsize,
+ /// Optional test gate: when installed, `list_job_events` parks on an
+ /// EMPTY result page so a test can hold a `recv_event` long-poll open
+ /// deterministically. See [`ListGate`].
+ list_gate: StdMutex