Skip to content

Fix permanent shard loss when RevokeShards times out - #3766

Open
Aditya1404Sal wants to merge 3 commits into
golemcloud:mainfrom
Aditya1404Sal:fix/revoke-shards-background-drain
Open

Fix permanent shard loss when RevokeShards times out#3766
Aditya1404Sal wants to merge 3 commits into
golemcloud:mainfrom
Aditya1404Sal:fix/revoke-shards-background-drain

Conversation

@Aditya1404Sal

@Aditya1404Sal Aditya1404Sal commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

fixes GOL-474

Problem

Chaos run on golem-dev (2026-08-18): 460 of 1024 shards had no owner for 43 minutes and the cluster never repaired itself.

RevokeShards on the executor removed the shards from its local assignment and then drained the affected agents one at a time, each step paying the agent's teardown (oplog commit + status flush + checkpoint). With enough agents that exceeded the shard manager's 5 s deadline. On failure the shard manager called Rebalance::remove_shards, which dropped the shards from both halves of the plan, so the routing table kept crediting the old executor (which had already dropped them locally) and the loop replayed the identical plan forever.

A first version of this PR detached the drain into a background task. That was wrong (thanks @vigoo): returning before the agents are stopped lets an agent keep running on the old executor while the new owner recovers it, and both write the same oplog. Without an epoch fence the synchronous drain is the ordering barrier.

Change

Executor (grpc/mod.rs) — the drain stays on the RPC, but:

  • every lost agent is signalled at once and awaited together, so the wait is the slowest agent's teardown rather than the sum of all teardowns;
  • the barrier is Worker::is_loaded() turning false, not the interrupt acknowledgement. The ack is sent from set_suspended before the invocation loop commits the oplog and flushes status; is_loaded() only flips inside stop_internal_locked after that commit and flush, on every exit path (including the ones that never remove the worker from ActiveAgents). Loaded-but-idle agents get no ack receiver but are woken and unload through the same path, so the same barrier covers them;
  • the drain runs as a spawned task that the RPC awaits, so a client deadline that drops the request cannot leave agents un-signalled; a retried call converges on whatever is still loaded;
  • it loops until a snapshot finds nothing loaded in a lost shard (agents whose creation was in flight are invisible to the first snapshot);
  • SetShardAssignment gets the same synchronous drain for the shards it removes (it is the reconcile the shard manager sends after a failed revoke); recovery of gained shards stays detached for both AssignShards and SetShardAssignment;
  • the invocation loop unloads an agent whose shard is no longer owned instead of restarting it in place (InterruptKind::RestartRetryDecision::Immediate used to re-instantiate on the losing executor), and on_shard_assignment_changed re-checks ownership before each restart.

Shard manager

  • A failed revoke leaves the shards unassigned (remove_assignment_shards, one-sided) and sends the old pod its authoritative SetShardAssignment in the same pass, before the next pass hands the shards to another pod. A failed assignment also queues the pod for reconciliation.
  • revoke_shards_timeout 5 s → 60 s. It bounds a drain whose duration is one agent's suspend, not the agent count; with retries it is a liveness backstop (5 × 60 s is the longest a wedged-but-alive executor can hold one pass before its shards are released).
  • SetShardAssignment is wrapped in revoke_shards_timeout (it shared the 5 s assign_shards_timeout).

Tests

  • golem-worker-executor/tests/sharding.rs (new, group2, sequential): revoke_shards_returns_only_after_lost_agents_are_unloaded, revoke_shards_drains_lost_agents_concurrently (per-agent teardown commits delayed through a one-shot test-utils seam in TestOplog::commit; asserts the teardowns overlap), revoke_shards_unloads_idle_agents, set_shard_assignment_drains_lost_agents_before_returning. All four fail against the background drain (the RPC returned in <1 ms with the agents still loaded).
  • golem-shard-manager/tests/shard_management.rs: revoke_timeout_after_executor_applied_it_does_not_strand_shards (the production failure shape; never converges on main), failed_revoke_reconciles_old_executor_before_reassigning, unreachable_executor_still_releases_its_revoked_shards.

Accepted behaviour / not in scope

  • A wedged-but-alive executor (an agent that never reaches an interrupt point, or a very long replay) holds the loop for up to 5 × 60 s on the revoke and again on the same-pass Set, then its shards are released. That wait is the point.
  • An executor that becomes unreachable mid-revoke costs more than before because of the longer deadline: the client's retries_on_unavailable loop retries the 10 s connect timeout inside each of the 5 attempts, so a black-holed pod can hold one pass for several minutes (before: ~25 s, cut off by the 5 s deadline). Left as is to keep this change minimal; lowering the client's connect timeout or its inner retries is a config-only knob if it shows up.
  • After a blown deadline the shard manager still releases the shards: same exposure the eviction path has today; the redesign's epoch fence is the structural fix, and without the release the 43-minute loop comes back.
  • Pre-existing, separate follow-ups: the scheduler does not re-check shard ownership between claim_due and processing; the rebalance loop has no backoff.

Verification

  • cargo test -p golem-shard-manager --lib: 51 passed; --test integration (Postgres testcontainer): 15 passed, including the three revoke-failure tests.
  • cargo test -p golem-worker-executor --test integration -- sharding::: 4 passed. Against the previous background drain the same four fail (... was still loaded when revoke_shards returned (after 0.5–0.8 ms)).
  • Drain log on the test executor: 5 busy Clocks agents drain in ~12 ms; 5 idle agents in ~260 ms; with a 200 ms artificial teardown commit per agent the concurrent drain takes ~330 ms (sequential would be ≥ 1 s).
  • Interruption-adjacent slices (api::interruption, api::simulated_crash, wasi::sleep_*): 7 passed.
  • cargo clippy --all-targets on golem-shard-manager, golem-worker-executor, golem-worker-executor-test-utils: clean; cargo fmt --all -- --check: clean.
  • golem-shard-manager/config/* regenerated from the binary: only revoke_shards_timeout = "1m" changes.
  • e2e integration-tests --test sharding (coordinated_scenario_01_02, service_is_responsive_to_shard_changes) against rebuilt service binaries: 2 passed (81 s).

@netlify

netlify Bot commented Aug 25, 2026

Copy link
Copy Markdown

Deploy Preview for golemcloud canceled.

Name Link
🔨 Latest commit 11a3064
🔍 Latest deploy log https://app.netlify.com/projects/golemcloud/deploys/6a968f91a5af160008428ec1

@Aditya1404Sal
Aditya1404Sal force-pushed the fix/revoke-shards-background-drain branch 3 times, most recently from 9267d90 to 4849499 Compare August 26, 2026 07:09
@Aditya1404Sal
Aditya1404Sal marked this pull request as ready for review August 26, 2026 08:12
@Aditya1404Sal
Aditya1404Sal requested a review from a team August 26, 2026 08:12
@Aditya1404Sal
Aditya1404Sal force-pushed the fix/revoke-shards-background-drain branch from 4849499 to 84e0086 Compare August 26, 2026 19:58
@vigoo

vigoo commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

I've asked some detailed explanation from the agent to understand the findings, I think they are useful so copying all 3 findings with detailed explanation in the next 3 comments.

@vigoo

vigoo commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

In-flight worker creation is invisible to the drain

Intended invariant

After RevokeShards returns:

No operation on the old executor can still create, load, enqueue work for, or persist state for an agent in the revoked shards.

The new drain only proves a narrower statement:

At one instant, the ActiveAgents snapshot contained no cached, loaded worker from a revoked shard.

Those are not equivalent.

1. A request checks ownership before creating the worker

In get_or_create_pending_with_freshness:

let owned_agent_id = self
    .canonicalize_owned_agent_id(&OwnedAgentId::new(environment_id, &agent_id))
    .await?;

self.ensure_worker_belongs_to_this_executor(&agent_id)?;

let metadata = Worker::<Ctx>::get_latest_metadata(self, &owned_agent_id).await;

// More asynchronous work...

Worker::get_or_create_suspended_with_freshness(
    self,
    &owned_agent_id,
    // ...
)
.await

The ownership check is a point-in-time check. There is no guard held between it and the subsequent worker creation.

2. Worker creation is hidden as a pending cache entry

ActiveAgents::get_or_add_with_freshness uses the worker cache like this:

let active_agent = self
    .agents
    .get_or_insert_simple(&cache_key, || {
        Box::pin(async move {
            let worker = Worker::new(
                &deps,
                // ...
            )
            .await;

            worker.map(|worker| Arc::new(ActiveAgent::new(Arc::new(worker))))
        })
    })
    .await?;

While Worker::new(...).await is running, the cache entry is Item::Pending, not Item::Cached.

But ActiveAgents::snapshot() ultimately uses Cache::iter(), which explicitly omits pending entries:

match value {
    Item::Cached { value, .. } => {
        snapshotted_pairs.push((key.clone(), value.clone()));
    }
    Item::Pending { .. } => {}
}

Therefore, a worker creation can be actively running while being completely invisible to the drain.

3. Revoke treats an empty snapshot as completion

The PR’s drain loop does:

let mut lost = Vec::new();

for (agent_id, worker) in self.active_agents().snapshot().await {
    if self.shard_service().check_worker(&agent_id).is_err()
        && worker.is_loaded().await
    {
        lost.push((agent_id, worker));
    }
}

if lost.is_empty() {
    break;
}

If the only affected worker is still a pending cache computation, snapshot() returns nothing and RevokeShards returns successfully.

The fact that this is inside a loop does not help: it breaks on the first empty observation and does not know that pending creations exist.

4. The pending worker can still perform durable writes

Before Worker::new becomes visible in ActiveAgents, it may create the oplog and cached status:

let oplog = oplog_service
    .create(
        owned_agent_id,
        agent_mode,
        initial_oplog_entry,
        // ...
    )
    .await;

this.worker_service()
    .update_cached_status(
        owned_agent_id,
        None,
        initial_status_value.clone(),
    )
    .await;

It can also enqueue and commit agent initialization:

worker
    .enqueue_worker_invocation(
        AgentInvocation::AgentInitialization {
            // ...
        },
    )
    .await
    .expect("Failed enqueuing initial agent invocations to worker");

enqueue_worker_invocation eventually calls:

self.add_and_commit_oplog_internal(&instance_guard, entry, None)
    .await;

All of this can happen after revoke has returned.

Failing timeline

Old executor                Shard manager               New executor
     │                            │                           │
     │ Request checks ownership  │                           │
     │ S is still owned          │                           │
     ├───────────────┐            │                           │
     │               ▼            │                           │
     │ Worker::new starts         │                           │
     │ Cache entry = Pending      │                           │
     │          [await]           │                           │
     │                            │                           │
     │◀────── Revoke(S) ──────────┤                           │
     │                            │                           │
     │ Remove S locally           │                           │
     │ snapshot() excludes        │                           │
     │ Pending worker             │                           │
     │ lost.is_empty() == true    │                           │
     │                            │                           │
     ├──── Revoke success ───────▶│                           │
     │                            ├────── Assign(S) ─────────▶│
     │                            │                           │
     │                            │                     Open/write oplog
     │                            │                           │
     │ Worker::new resumes        │                           │
     ┣━━ Create/write oplog ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
     ┃             Both executors can now write agent state  ┃
     │                            │                           │

Why the invocation-loop check is too late

The PR adds:

'outer: loop {
    if let Err(err @ WorkerExecutorError::InvalidShardId { .. }) =
        self.parent.shard_service().check_worker(&agent_id)
    {
        self.parent.acknowledge_interruption();
        self.stop_unloaded(Some(err)).await;
        self.parent.remove_from_active_agents().await;
        break;
    }

    let (instance, store) = match self.create_instance().await {
        // ...
    };
}

This prevents the old executor from creating a Wasmtime instance after ownership is lost, which is good. But it runs only after:

  • Worker::new has completed;
  • the oplog may have been created;
  • cached status may have been persisted;
  • initialization may have been committed;
  • the outer request may have enqueued another invocation.

So it prevents guest execution, but not old-owner durable writes.

Required shape of the fix

A plain second ownership check is still racy:

check ownership ── ownership changes ── write oplog

The operation needs an admission permit or epoch fence:

Request                         Revoke
   │                               │
   ├─ acquire shard permit         │
   ├─ validate ownership           │
   ├─ create worker                │
   ├─ persist/enqueue              │
   └─ release permit               │
                                   │
                         fence new permits
                         mark shard unowned
                         wait for existing permits
                         drain loaded workers
                         return success

Alternatively, storage writes need an ownership epoch/fencing token that makes writes from the previous owner fail.

@vigoo

vigoo commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Failed assignment reconciliation happens after reassignment

This is a separate ordering problem in the shard manager.

What happens after an assignment fails

The PR correctly recognizes that a failed RPC may have been applied:

// The executor may have applied the assignment even though the call failed
// (for example a timeout), so it always gets the authoritative assignment next.
{
    let mut updates_guard = updates.lock().await;
    for (pod, _) in &rebalance_failures.failed_assignments {
        updates_guard.retry_full_assignment(*pod);
    }
}

However, retry_full_assignment only schedules reconciliation for the next shard-management pass.

The next pass assigns first and reconciles later

At the beginning of the next pass:

let (new_pods, removed_pods, retry_full_assignment_pods) =
    updates.lock().await.reset();

// ...

for pod in retry_full_assignment_pods {
    if current_routing_table.has_pod(pod) {
        full_assignment_pods.insert(pod);
    }
}

But then the code executes the new rebalance before sending those authoritative assignments:

let rebalance_failures =
    Self::execute_rebalance(worker_executors.clone(), &mut rebalance).await;

execute_rebalance includes calls to assign_shards.

Only afterward does the manager send SetShardAssignment:

let failed_full_assignments = set_shard_assignments(
    worker_executors.clone(),
    routing_table_snapshot.number_of_shards,
    &full_assignments,
)
.await;

Thus the actual pass order is:

1. Compute a new owner for unassigned shards
2. Assign them to that owner
3. Reconcile the executor whose previous assignment may have succeeded

The safe order is 3 before 2.

Concrete reproduced sequence

Suppose:

Executor A owns shard 2
Executor B owns shard 3
Executor C owns shards 0 and 1

C is removed, so shards 0 and 1 need new owners.

First pass:

Assign(A, {0}) → success
Assign(B, {1}) → B applies it, but response is lost

The manager records shard 1 as unassigned because the RPC failed, while B locally owns it.

The PR queues B for reconciliation.

Next pass, I observed:

Assign(A, {1})
Set(B, authoritative assignment without {1})

That creates this interval:

Time ─────────────────────────────────────────────────────────▶

Executor B:  owns shard 1 ━━━━━━━━━━━━━━━━━━━━━━━┓
                                                 ┃ Set(B) removes 1
Executor A:                 ┏━━━━━━━━━━━━━━━━━━━━━┛
                            ┃ Assign(A, 1)
                            ┃
                            ▼
                  ! A and B both own shard 1

During that overlap, B’s background recovery or a routed request can run the agent while A is also starting it.

Why the existing failed-revoke handling is better

For failed revocations, the PR adds the pod directly to the current pass:

for (pod, _) in &rebalance_failures.failed_unassignments {
    full_assignment_pods.insert(*pod);
}

That means the old pod gets SetShardAssignment before the next pass can reassign the shard.

Failed assignments should follow the same pattern: add their pods to the current full_assignment_pods, apply/persist the authoritative routing state, and reconcile them before waking the next rebalance pass.

If that authoritative reconciliation also times out completely, the system must make an explicit safety-versus-liveness decision. Without storage epochs, immediately assigning elsewhere can still create two owners.

@vigoo

vigoo commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

timed-out shard RPCs leave duplicate drain tasks running

The PR deliberately detaches shard draining from the RPC lifetime. That makes draining cancellation-safe, but every retry creates another independent drain task. If an agent never acknowledges interruption, none of those tasks finish.

1. Every RPC spawns a new drain task

Both RevokeShards and SetShardAssignment eventually call:

async fn drain_lost_agents(
    &self,
    change: ShardAssignmentChange,
) -> Result<(), WorkerExecutorError> {
    let origin = TraceOrigin::capture_current();
    let this = self.clone();

    let handle = tokio::spawn(async move {
        let span = related_span!(
            origin,
            Level::INFO,
            "shard_drain",
            trigger = change.trigger()
        );

        this.drain_lost_agents_to_completion(change)
            .instrument(span)
            .await
    });

    handle.await.map_err(|err| {
        WorkerExecutorError::runtime(format!(
            "Draining the agents of the revoked shards failed: {err}"
        ))
    })?
}

The important distinction is:

  • handle.await waits for the task while the RPC is alive.
  • Dropping the JoinHandle does not abort the spawned Tokio task.
  • Therefore, when the client times out and cancels the RPC, drain_lost_agents_to_completion keeps running.

That is intentional: otherwise a timeout could stop draining halfway through. The problem is that the task is spawned once per RPC attempt, rather than once per logical assignment change.

Shard manager                     Worker executor

Revoke RPC ──────────────────────▶ spawn Drain #1
    │                                  │
    │ waits                            │ draining continues
    │                                  ▼
60 s timeout ◀──────────────────── agent has not acknowledged
    │
    └── RPC future cancelled           Drain #1 remains alive
                                       because dropping JoinHandle
                                       does not abort a Tokio task

2. The drain has an unbounded acknowledgement wait

For each lost agent, the drain requests Restart and obtains a broadcast receiver:

let receivers: Vec<_> =
    join_all(lost.iter().map(|(agent_id, worker)| async move {
        worker
            .set_interrupting(InterruptKind::Restart)
            .await
            .map(|receiver| (agent_id.clone(), receiver))
    }))
    .await
    .into_iter()
    .flatten()
    .collect();

Then all receivers are awaited without any deadline:

let acks = join_all(receivers.into_iter().map(
    |(agent_id, mut receiver)| async move {
        match receiver.recv().await {
            Ok(()) => true,
            Err(err) => {
                debug!(
                    agent_id = %agent_id,
                    error = %err,
                    "Agent went away before acknowledging its interruption"
                );
                false
            }
        }
    },
))
.await;

If one agent is stuck somewhere where it cannot process the interrupt, receiver.recv().await can wait for the lifetime of the executor.

There is a shutdown branch around the complete drain:

tokio::select! {
    (...) = drain => {
        // success
    }
    _ = shutdown.cancelled() => {
        Err(WorkerExecutorError::runtime(
            "Executor is shutting down"
        ))
    }
}

So “immortal” here means until one of these occurs:

  • the agent finally acknowledges;
  • the agent disappears and closes the broadcast channel; or
  • the entire worker executor shuts down.

There is no ordinary per-drain timeout or cancellation once the RPC has gone away.

3. A retry subscribes again instead of joining the original drain

Suppose Drain #1 already moved the worker into ExecutionStatus::Interrupting.

A later drain calls set_interrupting(Restart) again. This code creates another subscriber to the same acknowledgement channel:

ExecutionStatus::Interrupting {
    interrupt_kind: current_kind,
    await_interruption,
    agent_mode,
    timestamp,
} => {
    let receiver = await_interruption.subscribe();

    if matches!(current_kind, InterruptKind::Restart)
        && !matches!(interrupt_kind, InterruptKind::Restart)
    {
        *execution_status = ExecutionStatus::Interrupting {
            interrupt_kind,
            await_interruption,
            agent_mode,
            timestamp,
        };
    }

    Some(receiver)
}

The pending interrupt queue also accepts another Restart:

Self::Pending(current) => {
    if matches!(interrupt.kind, InterruptKind::Restart) {
        interrupt.reacquire_permits |= current.reacquire_permits;
    }

    *self = Self::Pending(interrupt);
    true
}

Consequently, repeated Restart requests are not rejected as “already being drained.” Every retry gets another receiver and waits independently:

                         ┌───────────────────────────┐
Drain #1 ───────────────▶│ receiver #1              │
Drain #2 ───────────────▶│ receiver #2              │
Drain #3 ───────────────▶│ receiver #3              │
Drain #4 ───────────────▶│ receiver #4              │
                         │                           │
                         │ broadcast acknowledgement│
                         └─────────────┬─────────────┘
                                       │
                              wedged agent never sends
                                       │
                                       ▼
                              all drains remain blocked

The broadcast channel itself is appropriate for multiple legitimate waiters. What is missing is deduplication at the logical drain operation level.

4. The shard manager retries these calls

RevokeShards is wrapped in the general retry helper:

with_retriable_errors(
    "worker_executor",
    "revoke_shards",
    Some(format!("{pod}")),
    &self.config.retries,
    &(pod, shard_ids),
    |(pod, shard_ids)| {
        Box::pin(self.revoke_shards_internal(pod, shard_ids))
    },
)
.await

Each individual attempt has a 60-second timeout:

let revoke_shards_response = timeout(
    self.config.revoke_shards_timeout,
    self.client.call(
        "revoke_shards",
        pod.uri(self.config.client_config.tls_enabled()),
        move |client| {
            let request = revoke_shards_request.clone();
            Box::pin(client.revoke_shards(request))
        },
    ),
)
.await
.map_err(|_: Elapsed| ShardManagerError::Timeout)?;

The default configuration is:

revoke_shards_timeout: Duration::from_secs(60),
retries: RetryConfig::max_attempts_5(),

SetShardAssignment uses the same retry mechanism and, in this PR, the same timeout:

let set_shard_assignment_response = timeout(
    self.config.revoke_shards_timeout,
    self.client.call(
        "set_shard_assignment",
        // ...
    ),
)
.await
.map_err(|_: Elapsed| ShardManagerError::Timeout)?;

A concrete sequence can therefore look like this:

Shard manager                            Worker executor

Revoke attempt 1 ──────────────────────▶ spawn Drain #1
                  ◀──── 60 s timeout ─── Drain #1 remains

Revoke attempt 2 ──────────────────────▶ spawn Drain #2
                  ◀──── 60 s timeout ─── Drains #1–2 remain

                 ...

Revoke attempt 5 ──────────────────────▶ spawn Drain #5
                  ◀──── 60 s timeout ─── Drains #1–5 remain

Set attempt 1 ─────────────────────────▶ spawn Drain #6
               ◀──── 60 s timeout ───── Drains #1–6 remain

                 ...

Set attempt 5 ─────────────────────────▶ spawn Drain #10
               ◀──── 60 s timeout ───── Drains #1–10 remain

Next reconciliation pass ──────────────▶ more independent drains

The exact count depends on which calls fail and how reconciliation proceeds, but it is not bounded to one logical drain.

5. What each blocked task retains

Every spawned task owns:

let this = self.clone();

It also holds, while waiting:

  • its acknowledgement receivers;
  • the lost vector containing worker references;
  • the surrounding executor/service references reachable through this;
  • one Tokio task allocation and tracing state.

These tasks are asleep rather than busy-looping, so one or two are inexpensive. The issue is unbounded accumulation across retries and reconciliation passes. A persistently wedged but otherwise healthy executor can retain more tasks and worker/service state indefinitely.

@vigoo vigoo left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See my comments

…ound, leave failed-revoke shards unassigned and reconcile the old executor
…ntil each is unloaded, raise the revoke deadline to 60s
@Aditya1404Sal

Copy link
Copy Markdown
Contributor Author

All three findings were real -- thanks, these were sharp. Fixes for each are in the latest commits, plus some results from running the same kind of adversarial pass myself.

  1. In-flight creations invisible to the drain — fixed, with one caveat. The drain now terminates only on a pass that finds neither a loaded agent nor a pending creation in a lost shard (new Cache::pending_keys(); the drain parks on the pending entry's watch, no polling), Worker::new re-checks ownership before its first durable write, and start_if_needed re-checks before loading — so a racing creation is either visible to the drain or writes nothing. A new test seam parks a creation mid-Worker::new and asserts the revoke waits it out and the racing start gets InvalidShardId. The caveat: these are still point-in-time checks. The held guarantee is the storage epoch you sketched, and that is exactly what the shard-manager redesign's executor-side oplog fencing delivers (the shard epoch checked atomically inside the storage write transaction) — that work is in progress. Rather than build an interim admission-permit scheme the fence would immediately replace, I think we wait for the fence as the definitive fix. The redesign spec has already absorbed this PR's drain as normative ("the RPC response is the drain-complete signal"), so the two compose: this PR is the graceful path, the fence is the guarantee.

  2. Failed assignments reconciled after reassignment — fixed. Your "safe order is 3 before 2" is what the pass does now, one step more generally: authoritative Sets go out in two stages. Pods carried over from earlier passes (queued reconciles, returning and startup-seeded pods) are reconciled from the pass-start snapshot before execute_rebalance; failures discovered in the current pass (failed assigns now included, mirroring the existing failed-revoke handling) get theirs after it, from the post-rebalance snapshot. This also fixes the same ordering hole in the restart pass for free. Residual, documented: dual ownership now requires three consecutive failures instead of a single lost response — the same accepted safety-vs-liveness trade as the revoke path, fully closed only by the epoch fence. Two mock tests pin the ordering (Assign(B) < Set(B) < Assign(A)); both were red on the old pass order.

  3. Duplicate immortal drain tasks — fixed. The drain is single-flighted: one owner task; each revoke/Set RPC bumps a generation after its shard-service mutation and waits on a watch for a drain that started at ≥ its generation (every pass re-reads check_worker, so that drain observed the mutation). The owner re-loops while new generations arrive, publishes with send_replace, and a drop guard releases the slot even for failures outside the drain's own catch_unwind, so it can never wedge. The unbounded waits stay by design — a wedged agent must never be reported drained — but now cost exactly one parked task no matter how many times the shard manager retries. Test: two staggered concurrent revokes must both return off one drain, with nothing torn down twice.

Separately, my own adversarial pass over the same blast radius turned up a few more things:

Fixed here: ActiveAgents::remove could delete a pending cache entry (a new creation racing the removal's awaits), hiding an in-flight Worker::new from the drain and letting the finished creation re-insert itself afterwards — removal now uses remove_if_cached, which never touches pending entries. And the pre-existing wedge where a caller cancelled mid-Worker::new orphans its pending entry forever — which the drain would now inherit as a permanently hung revoke — is closed by moving creation to get_or_insert_simple_spawned, so the producer survives caller cancellation and the entry always settles.

Found but deliberately not fixed here:

(i) RPC handlers that write durably to an already-cached agent (invocation enqueue, update, cancel-invocation, revert, plugin activation, card delivery, promise completion, worker deletion) check ownership at RPC entry only — a handler already past its check when the revoke lands can perform its durable write after the drain returns, and the drain cannot see it (the agent is neither loaded nor pending);

(ii) two post-is_loaded status writes — the final suspend checkpoint when an idle/eviction stop races the revoke, and the background status flusher retrying a failed stop-time flush (both self-heal on refold, which validates the baseline). Any fix for these today would be yet another point-in-time check in exactly the territory the oplog fence covers — every one of these paths funnels through the storage sites the fence guards — so the fixes depend on the fence and I'd land them with it rather than half-close them now.

…econcile failed assignments before reassigning, single-flight the drain; survive caller-cancelled creations, never remove pending cache entries, guard the drain owner slot
@Aditya1404Sal
Aditya1404Sal force-pushed the fix/revoke-shards-background-drain branch from e87dcf1 to 11a3064 Compare September 1, 2026 08:40
.collect();

Ok(())
if lost.is_empty() && pending_lost.is_empty() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Keep the revoke barrier open for already-admitted durable operations

This completion condition now handles pending Worker::new and Running workers, but it still misses cached workers in Unloaded or WaitingForPermit state and RPCs already past their ownership check.

For example, start_if_needed can validate ownership and install WaitingForPermit; because is_loaded() only recognizes Running, this drain can then return. The admitted invocation can subsequently reach enqueue_worker_invocation_with_effect and commit its pending oplog entry without another ownership check. Other durable handlers have the same gap.

Please extend the admission/quiescence barrier through durable enqueue/write completion, or add the atomic storage epoch fence here. Deferring that fence leaves RevokeShards able to report success while the old owner can still write.

if !failed_pre_sets.is_empty() {
warn!(
failed_pods = failed_pre_sets.iter().map(|(pod, _)| pod).join(", "),
"Some pods could not receive their authoritative shard assignment before the rebalance; retrying after it"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Do not rebalance shards whose authoritative reconciliation failed

Retrying this SetShardAssignment after the rebalance does not resolve the ownership uncertainty. If Assign(B, {5}) was applied but timed out, the same-pass reconciliation failed, and this pre-rebalance reconciliation also fails, B may still own shard 5. The code nevertheless proceeds to execute_rebalance, which can assign shard 5 to A before any successful Set(B, ...).

vigoo reproduced that sequence deterministically: A received shard 5 while B’s local assignment still contained shard 5. Please keep these in-doubt shards unassigned/suppress their reassignment until the authoritative Set succeeds, unless an atomic ownership epoch makes stale-owner writes impossible.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants