microsoft/garnet · warning

Slowdown: Unable to add trigger to epoch

Error message

Slowdown: Unable to add trigger to epoch

What it means

Not an exception but a stderr 'Slowdown' diagnostic from LightEpoch::BumpCurrentEpoch(callback, context). After bumping the global epoch it must enqueue a reclaim-trigger into the fixed 256-entry drain_list (kDrainListSize). If every full sweep (256 slots) fails to find a free or already-reclaimable slot, a counter (j) increments; every 500 sweeps (≈128000 iterations) it sleeps 1s and prints this line, then loops forever until a slot opens. It indicates epoch-based reclamation is stalled because safe_to_reclaim_epoch is not advancing.

Source

Thrown at libs/storage/Tsavorite/cc/src/device/light_epoch.h:276

    uint32_t i = 0, j = 0;
    while(true) {
      uint64_t trigger_epoch = drain_list_[i].epoch.load();
      if(trigger_epoch == EpochAction::kFree) {
        if(drain_list_[i].TryPush(prior_epoch, callback, context)) {
          ++drain_count_;
          break;
        }
      } else if(trigger_epoch <= safe_to_reclaim_epoch.load()) {
        if(drain_list_[i].TrySwap(trigger_epoch, prior_epoch, callback, context)) {
          break;
        }
      }
      if(++i == kDrainListSize) {
        i = 0;
        if(++j == 500) {
          j = 0;
          std::this_thread::sleep_for(std::chrono::seconds(1));
          fprintf(stderr, "Slowdown: Unable to add trigger to epoch\n");
        }
      }
    }
    return prior_epoch + 1;
  }

  /// Compute latest epoch that is safe to reclaim, by scanning the epoch table
  uint64_t ComputeNewSafeToReclaimEpoch(uint64_t current_epoch_) {
    uint64_t oldest_ongoing_call = current_epoch_;
    for(uint32_t index = 0; index < kTableSize; ++index) {
      uint64_t entry_epoch = table_[index].local_current_epoch;
      if(entry_epoch != kUnprotected && entry_epoch < oldest_ongoing_call) {
        oldest_ongoing_call = entry_epoch;
      }
    }
    safe_to_reclaim_epoch = oldest_ongoing_call - 1;
    return safe_to_reclaim_epoch;
  }

View on GitHub (pinned to 951b0fc683)

Solutions

  1. Audit every Protect/ProtectAndDrain/ReentrantProtect for a matching Unprotect/ReentrantUnprotect on ALL exit paths (exceptions, early returns, cancellation) — prefer an RAII guard that calls Unprotect in its destructor.
  2. Ensure threads unregister from Thread::id() / reset their epoch table entry before exiting, so no stale local_current_epoch blocks ComputeNewSafeToReclaimEpoch.
  3. Keep protected critical sections short and non-blocking — never sleep, do disk I/O, or take coarse locks while holding an epoch.
  4. If legitimately enqueuing more than ~256 concurrent reclaim triggers, raise kDrainListSize or batch triggers; if the stall is from one stuck thread, attach a debugger to find the thread whose local_current_epoch is oldest.

Example fix

// before:
// epoch.ProtectAndDrain();
// DoWork();            // if this throws, Unprotect never runs -> stall
// epoch.Unprotect();
// after (RAII so every exit path releases the epoch):
struct EpochGuard {
  LightEpoch& e; EpochGuard(LightEpoch& e_) : e(e_) { e.ProtectAndDrain(); }
  ~EpochGuard() { e.Unprotect(); }
};
{ EpochGuard g{epoch}; DoWork(); }
Defensive patterns

Strategy: validation

Validate before calling

// Debug-only invariant: every Protect must have a matching Unprotect; assert the
// thread is not already protected and that no protected section blocks.
#ifdef DEBUG
struct EpochUse {
  LightEpoch& e; const char* tag;
  EpochUse(LightEpoch& e_, const char* t) : e(e_), tag(t) {
    assert(!e.IsProtected() && "re-protect without unprotect"); e.ProtectAndDrain();
  }
  ~EpochUse() { e.Unprotect(); }
};
#endif

Prevention

When it happens

Trigger: A thread is stuck inside a protected critical section (Protect/ProtectAndDrain/ReentrantProtect called without the matching Unprotect/ReentrantUnprotect), so ComputeNewSafeToReclaimEpoch cannot advance safe_to_reclaim_epoch, so drain_list entries never become reclaimable and the list saturates. Also triggered by a stale entry in the thread table: a thread that exited (or was killed) without resetting its local_current_epoch to kUnprotected leaves an artificially old epoch blocking reclamation.

Common situations: Long-running operations holding an epoch through blocking I/O or locks; an exception path that skipped Unprotect(); thread teardown that forgot to unregister; exceeding the intended concurrency so many threads hold epochs simultaneously; a checkpoint/grow/compaction storm that enqueues reclaim triggers faster than threads drain them.

Related errors


AI-assisted analysis of microsoft/garnet@951b0fc683 (2026-08-13). Data as JSON: /api/errors/285f360bf7e8f375. Report an issue: GitHub.