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
- 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.
- Ensure threads unregister from Thread::id() / reset their epoch table entry before exiting, so no stale local_current_epoch blocks ComputeNewSafeToReclaimEpoch.
- Keep protected critical sections short and non-blocking — never sleep, do disk I/O, or take coarse locks while holding an epoch.
- 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
- Always pair Protect/ProtectAndDrain/ReentrantProtect with the matching Unprotect on every exit path; use an RAII guard so exceptions can't skip it.
- Keep protected sections short and never blocking (no I/O, sleeps, or coarse locks inside).
- Unregister/reset the thread's epoch table entry before thread exit so safe_to_reclaim_epoch can advance.
- Watch for the 'Slowdown: Unable to add trigger to epoch' line as the early signal of a leaked Protect; snapshot table_ entries to find the oldest local_current_epoch.
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
- Exceeded maximum number of active LightEpoch instances {Acti
- LightEpoch
- Async operations not supported over protected epoch
- Getting handle in disposed device
- Failed to schedule work: {}
AI-assisted analysis of microsoft/garnet@951b0fc683 (2026-08-13).
Data as JSON: /api/errors/285f360bf7e8f375.
Report an issue: GitHub.