Skip to content

feat(l1): support runtime capacity resizing #39

Description

@leiysky

L1 capacity is currently fixed when the cache opens. Applications cannot adjust the memory devoted to cached values as their working set or memory needs change without reopening the cache. Support growing and shrinking L1 at runtime while preserving bounded request-path work and the lifetime of returned values.

Proposed API

Keep with_l1_capacity_bytes as the initial capacity and add an optional with_l1_max_capacity_bytes to define the maximum for the current open:

const MIB: usize = 1024 * 1024;

let runtime = RuntimeConfig::default()
    .with_l1_capacity_bytes(256 * MIB)
    .with_l1_max_capacity_bytes(1024 * MIB);

Add Cache::resize_l1(&self, target_bytes: usize) -> Result<()>:

cache.resize_l1(512 * MIB)?; // Grow to 512 MiB.
cache.resize_l1(128 * MIB)?; // Shrink to 128 MiB asynchronously.
cache.resize_l1(0)?;        // Stop admission and empty L1 asynchronously.

Use an absolute target so repeating a request is idempotent. Success means the target was accepted; eviction and allocation release may still be pending. Concurrent requests follow target-publication order, with the last publication taking effect. Keep one target and coalesce worker notifications.

The maximum defaults to the initial capacity. Validate initial <= maximum and reject targets above the maximum with InvalidInput, preserving the previous target. Calls after close starts return Unavailable. Add ErrorOperation::ResizeL1 using the existing error taxonomy.

An initial capacity of zero with a positive maximum supports later enablement. When both are zero, positive resize targets are invalid.

Capacity and memory accounting

Keep shard routing fixed. Allocate entry slots, directories, free lists, and eviction metadata at open using the maximum capacity, including when the initial capacity is zero. This gives grow usable storage without rebuilding metadata or moving entries. Existing entry-density, collision-chain, and slot limits still apply independently of the byte target.

Distribute the target across shards using the existing quotient/remainder split:

target_i = target / shards + (i < target % shards ? 1 : 0)
used_i = resident_bytes_i + detached_retained_bytes_i
over_target_bytes = sum(max(used_i - target_i, 0))

Entry charges continue to include the key, value, and fixed ownership charge. Each allocation is charged once, regardless of how many Value handles reference it. The per-shard maximum remains a hard bound; actual occupancy may exceed a newly reduced target while shrink is pending.

Reserve the maximum L1 byte allowance and its metadata in the managed-memory plan. Include the resizer's fixed worker resources in open-time validation. Shrinking releases unreferenced entry allocations but preserves this allowance for future growth. It does not increase the memory allowance available to L2 read buffers, and managed_memory_bytes continues to include the maximum L1 reservation. Fixed metadata remains allocated for the lifetime of the store.

Returning an allocation to the heap does not guarantee an immediate RSS decrease; page return depends on the allocator.

Grow and foreground admission

Grow raises the target. Subsequent writes and L2 promotions fill the additional capacity through the existing admission path.

Admission reads the target under its existing shard guard and uses that shard's share for budget checks and policy thresholds. A shard already above its target bypasses new admission immediately; the background worker handles the shrink deficit. Preserve the existing CAS, scan, and victim bounds.

An admission already in progress may finish using its previously observed target. There is at most one such admission per shard, each candidate remains capped at 256 KiB, and the hard maximum still applies. Subsequent admissions observe the updated target.

Keep L1 hits on their existing lookup path. Existing residents remain readable until removed, including during a shrink to zero. L1 misses continue through the existing L2 path, accepted writes retain their Region publication behavior, and sequence preference remains unchanged.

Background shrink

Use one background resizer for a cache with positive maximum capacity, independent of write and reclaim I/O. It visits shards with a persistent round-robin cursor, takes one shard try-lock at a time, and skips contention. It must make progress without foreground traffic.

Starting work limits, subject to benchmark qualification:

Work Bound
Shards visited per round 8
Policy scan steps per shard visit 64 total
Entries removed per shard visit At most 16 and at most 1 MiB of charged bytes
Ghost entries removed per shard visit 64
Active round cadence At most one round per 10 ms

Recheck the latest target on each shard visit. Select victims using CLOCK or S3-FIFO and prefer cold values that can release their allocation. If normal selection cannot progress because hits keep refreshing recency or frequency, subsequent background passes may ignore those hints within the same work bounds. These forced passes still skip externally held values for positive targets.

Remove selected entries from the directory and policy under the shard lock, then drop their owners outside the lock using a fixed-size batch. Keep their charges armed until final drop; background eviction does not transfer charges to another entry. Use projected released bytes to avoid scheduling an entire batch when a smaller eviction satisfies the deficit.

Externally held values may keep a positive-target shrink pending. Revisit pending shards with timed waits so final handle drops can enable progress; avoid adding notifications to every value drop. Idle workers wait for a target change, with a wake predicate that prevents lost notifications.

At a zero shard target, remove residents regardless of external references or frequency hints, within the same batch bounds. Returned values remain valid and charged until their final drop. This empties the directory even when callers continue holding values.

A grow can supersede an unfinished shrink. Only an already-running bounded batch may finish against its earlier target. Resize itself performs no L2 I/O, prefill, or sequence-number allocation.

Eviction policies

CLOCK retains its existing resident storage and hand. S3-FIFO updates its main target to target_i - target_i / 10 and its ghost weight target to the same value, preserving queue order and frequency state. Trim excess ghost history in background batches; updating a target must not trigger a full queue clear. Ghost weights are historical entry sizes and are excluded from value-byte debt.

Shrink to zero incrementally empties resident and ghost containers. Grow from zero reuses their preallocated storage.

Observability and lifecycle

Add max_capacity_bytes, target_capacity_bytes, and over_target_bytes to CacheL1Snapshot. Compute over-target bytes per shard so an underfilled shard does not hide another shard's deficit. Keep operational gauges available when statistics are disabled. With statistics enabled, count resize evictions in the existing total and expose a resize-specific subset.

Retain the existing resident/retained accounting calculation. Document that retained_bytes counts allocations outside the resident directory, including caller-held values and briefly the worker's detached batch. It does not include external references to values that are still resident.

Snapshots remain sampled observations, not a resize completion barrier. drain() continues to fence accepted writes. Close rejects new resize requests, wakes and stops the worker between bounded batches, and joins it without waiting for shrink completion or returned values. Runtime targets are process-local; reopen uses the supplied configuration.

Acceptance criteria

  • Grow within the maximum preserves existing hits and enables additional admission; invalid targets leave the current target unchanged.
  • Initial zero capacity with a positive maximum supports grow; shrink to zero empties L1 and allows subsequent reuse of the fixed storage.
  • CLOCK and S3-FIFO converge under a stable target when values are reclaimable, both without foreground traffic and while hits refresh policy hints.
  • Pinned resident and already-evicted values remain valid and charged until final drop, including across zero-capacity transitions and close.
  • Concurrent reads, writes, promotion, deletion, and resizing preserve full-key validation, sequence behavior, directory integrity, and hard memory bounds.
  • Superseding targets do not accumulate work requests; only in-progress bounded operations may finish using an older target.
  • Contended shards do not stop other shards; mixed entry sizes and ghost cleanup respect all scan, victim, and byte limits.
  • Shard rounding and skew produce correct targets and over-target metrics.
  • Close and startup failure clean up worker ownership without waiting for external handles; closed resize calls report Unavailable.
  • Add private step/accounting tests beside the implementation and public API tests in tests-integration/tests, using deterministic coordination.
  • Run cargo x check, cargo x test, and cargo x lint. Compare steady-state and resizing workloads for hit rate, bypasses, throughput, get/put p99, worker CPU, and reclamation progress before finalizing batch limits.
  • Update configuration, architecture, error documentation, and the changelog.

Automatic memory-pressure control, transferring unused allowance between L1 and L2, reclaiming fixed metadata, and growth beyond the configured maximum are separate follow-up work.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions