feat(s3-l2): make LRU eviction survive process restarts (opt-in) by divyvasal · Pull Request #3909 · LMCache/LMCache · GitHub
Skip to content

feat(s3-l2): make LRU eviction survive process restarts (opt-in) - #3909

Open
divyvasal wants to merge 4 commits into
LMCache:devfrom
divyvasal:feat/s3-l2-eviction-survives-restart
Open

divyvasal wants to merge 4 commits into
LMCache:devfrom
divyvasal:feat/s3-l2-eviction-survives-restart

Conversation

@divyvasal

Copy link
Copy Markdown
Contributor

Summary

Make the S3 L2 cache's LRU eviction survive process restarts. Today the adapter's byte counter and the eviction policy's key index are in-memory — they reset to 0 on every restart / pod replacement / spot reclaim. So a fresh process doesn't "see" the objects already in the bucket, can't evict them, and the bucket grows across process generations until the lifecycle TTL ages it out. The configured LRU cap stops bounding the bucket across restarts. This adds two opt-in mechanisms to fix that. Both default off → no behavior change for existing deployments.

Independent of (and complementary to) the per-cache_salt bucket PR (#3892) — different branch, different files-of-concern.

(a) s3_seed_usage_on_start (default False)

On startup, scan the bucket (paginated ListObjectsV2, progress-logged, non-fatal on error) and seed both:

  • the adapter usage counter (_total_bytes_used), and
  • the eviction policy's key index,

using S3 LastModified as the access-order proxy (oldest-modified = first victim). After seeding, _check_and_evict_global sees the true footprint and evicts down to the cap immediately — across any restart.

The load-bearing detail: seeding usage alone is useless; the policy must get the keys too. The path is _notify_keys_stored → L2EvictionPolicy.on_l2_keys_stored → policy.on_keys_created → LRUEvictionPolicy._order. on_keys_created reverses keys within a call, so seeding is done one key at a time, oldest-LastModified-first, which lands the oldest object at the front of _order (first eviction victim) — verified against the real LRUEvictionPolicy.

(b) s3_persist_lru_index (default False, s3_lru_index_checkpoint_interval_s default 300)

(a) uses LastModified as the order proxy, which mis-ranks a hot-but-old prefix (created first, reused thousands of times, never rewritten) → it could be evicted despite being hot. To preserve true access-order across restarts:

  • A daemon thread periodically serializes the access-order index (key → size + last-access tick) to a compact JSON sidecar (_lmcache_lru_index.json); access ticks are only recorded when the flag is on (hot path untouched otherwise).
  • On startup, restore the index from the sidecar (true LRU order), reconcile against the live listing (drop vanished keys; append new-since-checkpoint keys by LastModified as coldest), and rebuild the tick counter. A missing/partial/corrupt/unknown-version sidecar is treated as absent → falls back to (a)'s scan. close() writes a final checkpoint.

Tests

tests/v1/distributed/test_s3_l2_adapter_seed.py — 18 unit tests (stub awscrt/torch; pure logic runs for real, including against the real LRUEvictionPolicy): LastModified parsing + ordering, size-summing, policy _order population in eviction order, skip-already-known, sidecar serialize/deserialize roundtrip, corrupt/partial/unknown-version → treated as absent, tick recording gated by the flag.

Notes / open questions (flagged, not guessed)

  • Tick-tracking lives in the adapter (it already sees every store/read) rather than reaching into the policy's private _order. If you'd prefer the checkpoint to live with the policy, that's a design call.
  • On restore, objects present in the bucket but absent from the sidecar are placed as coldest (evicted first). If brand-new-since-checkpoint objects should instead be treated as hot, that ordering should flip.
  • Seeding calls _notify_keys_stored once per key to preserve exact order (one-time startup cost); a batched order-preserving insert on the policy would speed huge-bucket seeds but doesn't exist today.

Draft for now — feedback welcome on the config surface and the two ordering questions above.

@gemini-code-assist gemini-code-assist Bot 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.

Code Review

This pull request introduces startup seeding and periodic LRU-index sidecar checkpointing/restoring features to the S3L2Adapter, along with comprehensive unit tests. The review feedback highlights opportunities to optimize startup performance by batching eviction policy notifications in _seed_policy_in_order instead of notifying one key at a time, and to prevent potential shutdown hangs by adding a configurable timeout to _checkpoint_lru_index and using a shorter timeout during adapter closure.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +1659 to +1688

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.

high

The current implementation of _seed_policy_in_order notifies the eviction policy listener one key at a time in a loop. For a large bucket (e.g., 10k to 100k+ keys), this results in $O(N)$ lock acquisitions and listener notifications, causing significant CPU thrashing and startup delays.

Since policy.on_keys_created reverses the input list of keys internally, we can achieve the exact same ordering in a single batch notification by reversing the list of keys and sizes first. This reduces lock acquisitions and listener notifications from $O(N)$ to $O(1)$, dramatically improving startup performance.

Additionally, to ensure that the access ticks are assigned in the correct oldest-to-newest order when _track_access_order is enabled, we can temporarily disable tracking during the batch notification and manually populate _access_ticks in the correct order.

    def _seed_policy_in_order(
        self, ordered: list[tuple[ObjectKey, int]]
    ) -> int:
        """Seed usage + the eviction policy from keys in LRU order.

        ``ordered`` is ``[(ObjectKey, size), ...]`` with the **least
        recently used key first**. The eviction policy is populated via
        ``_notify_keys_stored`` (base class -> ``L2EvictionPolicy`` ->
        ``policy.on_keys_created``); ``on_keys_created`` reverses keys
        within a single call, so we notify one key at a time to make the
        final OrderedDict ordering match ``ordered`` exactly (oldest at
        the front == first eviction victim). ``_key_sizes`` is also
        seeded so a later ``delete`` of a seeded key balances the
        base-class byte accounting.

        Returns the number of keys seeded.
        """
        to_seed_keys = []
        to_seed_sizes = []
        with self._lock:
            for key, size in ordered:
                if key in self._key_sizes:
                    # Already known from a store in this process — don't
                    # double-count or reorder it.
                    continue
                self._key_sizes[key] = size
                self._object_size_cache[_object_key_to_string(key)] = size
                to_seed_keys.append(key)
                to_seed_sizes.append(size)

        if not to_seed_keys:
            return 0

        # Temporarily disable tracking to avoid incorrect tick assignment during batch notification
        was_tracking = self._track_access_order
        self._track_access_order = False
        try:
            # Reverse the lists so that policy.on_keys_created (which processes keys in reverse)
            # reconstructs the original oldest-to-newest order in its internal OrderedDict.
            self._notify_keys_stored(list(reversed(to_seed_keys)), list(reversed(to_seed_sizes)))
        finally:
            self._track_access_order = was_tracking

        # If tracking is enabled, manually initialize the ticks in the correct oldest-to-newest order
        if was_tracking:
            with self._lock:
                for key, size in zip(to_seed_keys, to_seed_sizes, strict=True):
                    self._access_tick_counter += 1
                    self._access_ticks[_object_key_to_string(key)] = (size, self._access_tick_counter)

        return len(to_seed_keys)
References
  1. LMCache does not support Python 3.9 (it requires Python 3.10+). Therefore, Python 3.10 features such as zip(..., strict=True) are fully supported and should not be avoided or removed for Python 3.9 compatibility.

Comment on lines +1825 to +1854
def _checkpoint_lru_index(self) -> bool:
"""Serialize the access-order index and PUT it to the sidecar.

Best-effort: returns ``False`` (logged) on any failure so the
checkpoint loop keeps running.
"""
snapshot = self._snapshot_lru_index()
if not snapshot:
return False
try:
body = _serialize_lru_index(snapshot)
except Exception:
logger.exception("S3L2Adapter failed to serialize LRU index")
return False
fut = asyncio.run_coroutine_threadsafe(
self._execute_put_raw(LRU_INDEX_OBJECT_NAME, body),
self._loop,
)
try:
ok = fut.result(timeout=60.0)
except Exception as exc:
logger.warning("S3L2Adapter LRU checkpoint PUT failed: %s", exc)
return False
if ok:
logger.debug(
"S3L2Adapter checkpointed LRU index (%d entries, %d bytes)",
len(snapshot),
len(body),
)
return ok

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.

medium

The _checkpoint_lru_index method currently has a hardcoded timeout of 60.0 seconds when waiting for the S3 PUT request to complete. During process shutdown (in close()), blocking the calling thread for up to 60 seconds if S3 is slow or unresponsive can exceed the container's termination grace period (typically 30 seconds), leading to a forceful SIGKILL before other resources can clean up gracefully.

Adding a timeout parameter to _checkpoint_lru_index allows close() to use a much shorter timeout (e.g., 5.0 seconds) for the final checkpoint.

    def _checkpoint_lru_index(self, timeout: float = 60.0) -> bool:
        """Serialize the access-order index and PUT it to the sidecar.

        Best-effort: returns ``False`` (logged) on any failure so the
        checkpoint loop keeps running.
        """
        snapshot = self._snapshot_lru_index()
        if not snapshot:
            return False
        try:
            body = _serialize_lru_index(snapshot)
        except Exception:
            logger.exception("S3L2Adapter failed to serialize LRU index")
            return False
        fut = asyncio.run_coroutine_threadsafe(
            self._execute_put_raw(LRU_INDEX_OBJECT_NAME, body),
            self._loop,
        )
        try:
            ok = fut.result(timeout=timeout)
        except Exception as exc:
            logger.warning("S3L2Adapter LRU checkpoint PUT failed: %s", exc)
            return False
        if ok:
            logger.debug(
                "S3L2Adapter checkpointed LRU index (%d entries, %d bytes)",
                len(snapshot),
                len(body),
            )
        return ok

Comment on lines +1022 to +1028
if self._checkpoint_thread is not None:
self._checkpoint_stop.set()
try:
self._checkpoint_lru_index()
except Exception:
logger.exception("S3L2Adapter final LRU checkpoint failed")
self._checkpoint_thread.join(timeout=5)

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.

medium

Use a shorter timeout (e.g., 5.0 seconds) for the final checkpoint in close() to prevent blocking the shutdown process for up to 60 seconds if S3 is slow or unresponsive.

Suggested change
if self._checkpoint_thread is not None:
self._checkpoint_stop.set()
try:
self._checkpoint_lru_index()
except Exception:
logger.exception("S3L2Adapter final LRU checkpoint failed")
self._checkpoint_thread.join(timeout=5)
if self._checkpoint_thread is not None:
self._checkpoint_stop.set()
try:
self._checkpoint_lru_index(timeout=5.0)
except Exception:
logger.exception("S3L2Adapter final LRU checkpoint failed")
self._checkpoint_thread.join(timeout=5)

@divyvasal

Copy link
Copy Markdown
Contributor Author

Thanks — addressed both in the latest push:

  • _seed_policy_in_order O(N) → O(1) (high): replaced the per-key notify loop with a single batch _notify_keys_stored of the reversed key list. Since on_keys_created reverses internally, the reversed batch reconstructs the same oldest-first policy order with one lock acquisition + one listener notification instead of N — a 100k-object bucket no longer thrashes on startup. As you noted, tick tracking is suppressed during the batch and the _access_ticks are then seeded explicitly in oldest→newest order, so access-order stays correct. (The existing test_seed_policy_in_order_populates_policy_lru_order still passes against the real LRUEvictionPolicy.)
  • _checkpoint_lru_index(timeout=60.0) (medium): the PUT-wait bound is now a parameter, and close() passes timeout=5.0 so a slow/unresponsive S3 can't block shutdown past the container's termination grace period (→ SIGKILL). The background loop keeps the default.

Also folded in the earlier fix where a missing sidecar (404) raised RuntimeError in the CRT callback — now recorded + treated as cleanly absent (falls back to scan).

@divyvasal
divyvasal marked this pull request as ready for review June 27, 2026 16:33
@divyvasal

Copy link
Copy Markdown
Contributor Author

Note on the red buildkite/unit-tests-amd check — it's a pre-existing breakage on dev, not this PR.

The failure is in an unrelated ROCm GPU benchmark:

tests/benchmarks/test_benchmark.py::test_store_1GB[False-disk]
  AcceleratorError: HIP error: invalid device function (hipErrorInvalidDeviceFunction)
  -> TimeoutError: Store operation haven't finished in 60 seconds

With --maxfail=1, the suite aborts at this benchmark before the s3_l2_adapter tests are even collected — so nothing in this PR's diff runs in that lane.

It's repo-wide, not PR-specific:

This PR only touches lmcache/v1/distributed/l2_adapters/s3_l2_adapter.py (+ its test). The s3-l2 unit tests pass locally (18/18, incl. the LRU-ordering test against the real LRUEvictionPolicy), and DCO + k3-unit-tests are green here. Happy to rebase once dev's AMD lane is fixed. Marking ready for review on logic.

@divyvasal

Copy link
Copy Markdown
Contributor Author

@ApostaC @maobaolong @chunxiaozheng (and @YaoJiayi @hlin99 for the eviction-policy interaction) — review would be appreciated when convenient.

Opt-in change so the S3 L2 LRU index survives process restarts: a startup bucket-scan seeds usage, and a periodic sidecar checkpoint persists the access order (default off → unchanged behavior otherwise). Gemini's notes are addressed (batched O(1) policy seed, bounded checkpoint timeout, close() flush). s3-l2 unit tests pass locally (18/18, incl. ordering against the real LRUEvictionPolicy); DCO + k3-unit-tests green. The red unit-tests-amd is the pre-existing dev-wide ROCm benchmark failure (test_store_1GB, hipErrorInvalidDeviceFunction), not this PR — --maxfail=1 aborts before the s3 tests even run.

Independent sibling of #3892.

@ApostaC

ApostaC commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

@divyvasal Thanks for contributing to LMCache

@aeon-x can you help take a look? Thanks!

@ApostaC ApostaC self-assigned this Jun 29, 2026
@aeon-x

aeon-x commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Hey @divyvasal , we have a start up resync feature in LMCache coordinator. It does exactly what you have in this PR in the coordinator level. Can you check it out?

https://docs.lmcache.ai/mp/coordinator.html

@divyvasal

Copy link
Copy Markdown
Contributor Author

Thanks @aeon-x — I looked at the coordinator startup-resync. IIUC it backfills the coordinator's usage/eviction trackers from an MP server's GET /cache/objects, i.e. from a server's in-memory view.

The case this PR targets is when that view is itself gone. On spot-reclaim / pod-replacement the whole L2 server process is replaced, so a freshly-started server reports an empty /cache/objects, and there's no surviving peer to resync from — the coordinator would resync to empty and the bucket keeps growing past the cap. s3_seed_usage_on_start instead scans the S3 bucket directly (durable ground truth, LastModified as the access-order proxy) so the LRU footprint + cap survive a full server restart; s3_persist_lru_index checkpoints the order so a clean restart skips the full scan.

It also pairs with #3892 (opt-in per-cache_salt bucket isolation): with one physical bucket per tenant, the durable state is spread across N buckets, and a direct per-bucket scan is the natural per-tenant ground truth on restart — something a coordinator resyncing from a replaced server can't reconstruct.

So I see these as complementary to the coordinator resync rather than duplicating it — different source of truth (S3 bucket vs. server view). Happy to add a docs note cross-referencing the two so users pick the right mechanism for their topology. Does that distinction line up with your read?

@divyvasal

Copy link
Copy Markdown
Contributor Author

Small nudge on this one. CI is green (15/15) and it's opt-in, so no default behavior change. Would really appreciate a review when someone has a few minutes. @ApostaC @sammshen

@github-actions

Copy link
Copy Markdown
Contributor

This pull request has been automatically marked as stale because it has not had activity within 60 days. It will be automatically closed if no further activity occurs within 30 days.

@github-actions github-actions Bot added the stale label Sep 19, 2026
divyvasal and others added 3 commits September 18, 2026 20:08
The S3 L2 adapter's byte counter and the eviction policy's LRU key index
are in-memory and reset to 0 on every process restart / box replacement.
After a restart the adapter can't 'see' objects already in the bucket,
so it never evicts them and the bucket grows across process generations
until the 30-day lifecycle TTL. This makes the LRU cap bind across
restarts via two opt-in, default-OFF features.

(a) s3_seed_usage_on_start: on startup, paginate the bucket and seed both
the aggregate usage counter and the eviction policy's key index (via
_notify_keys_stored -> L2EvictionPolicy.on_l2_keys_stored ->
policy.on_keys_created), ordered oldest-LastModified-first so the oldest
object is the first eviction victim. Seeding is per-key to preserve global
LRU order. Scan failures are logged and non-fatal.

(b) s3_persist_lru_index: periodically checkpoint a compact JSON sidecar
(_lmcache_lru_index.json) capturing true access-order ticks, and restore
it on startup, reconciled against the live listing (drop vanished keys,
add new keys by LastModified). Corrupt / partial / unknown-version
sidecars are treated as absent (fall back to the scan). A permissive
LastModified parser treats missing timestamps as oldest.

Tests under tests/v1/distributed/test_s3_l2_adapter_seed.py cover the pure
parts (LastModified parse, listing parse with mtime, oldest-first
ordering, sidecar roundtrip + robustness, policy population in LRU order)
with awscrt/torch stubbed; no live AWS required.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Divy <divy@coralbricks.ai>
The raw GET for the sidecar raised RuntimeError inside the CRT on_done callback
on any non-200 — including the expected 404 when no sidecar exists yet. That
logged a noisy traceback even though finished_future already surfaces the error
and the caller falls back to a scan. Don't raise in the callback; record status
+ error and let the awaiter return None quietly on 404/non-200.

Signed-off-by: Divy <divy@coralbricks.ai>
- _seed_policy_in_order: replace the per-key notify loop with a SINGLE batch
  _notify_keys_stored of the reversed key list. on_keys_created reverses
  internally, so this reconstructs the same oldest-first policy order with O(1)
  lock/listener traffic instead of O(N) (a 100k-object bucket no longer thrashes
  on startup). Tick tracking is suppressed during the batch and seeded explicitly
  afterwards in oldest->newest order so access ticks stay correct.
- _checkpoint_lru_index(timeout=60.0): make the PUT-wait bound configurable;
  close() now passes timeout=5.0 so a slow/unresponsive S3 can't block shutdown
  past the container termination grace period (-> SIGKILL).

Signed-off-by: Divy <divy@coralbricks.ai>
@divyvasal
divyvasal force-pushed the feat/s3-l2-eviction-survives-restart branch from 66858cf to 7e19316 Compare September 19, 2026 03:08
@divyvasal

Copy link
Copy Markdown
Contributor Author

Signed-off-by: Divy <divy@coralbricks.ai>
@divyvasal
divyvasal force-pushed the feat/s3-l2-eviction-survives-restart branch from fe09b05 to 570d69a Compare September 19, 2026 03:59
@github-actions github-actions Bot removed the stale label Sep 20, 2026
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.

3 participants