Skip to content

perf(object-cache): find expired entries with a deadline heap, not a scan (LAB-7106) - #658

Merged
27Bslash6 merged 2 commits into
mainfrom
perf/lab-7106-objectcache-expiry-heap
Oct 28, 2026
Merged

27Bslash6 merged 2 commits into
mainfrom
perf/lab-7106-objectcache-expiry-heap

Conversation

@27Bslash6

@27Bslash6 27Bslash6 commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Once an ObjectCache is at its bound, every put() used to scan all entries for expired ones before it evicted the LRU head, holding the lock that every get() on that function also takes. An L1-only @cache(backend=None) function builds one with a 100MB byte budget. With ttl=None its entries are stored for a year, so at a full budget nothing ever expired and every miss paid a scan of the whole cache for nothing. At 100,000 entries that scan took longer than everything else a put does by about 200 times.

The cache now keeps a min-heap of (deadline, key). A put over the bound pops only the tuples that are due. When nothing has expired that is one comparison, and each expired entry it removes costs O(log n). Expired entries still go before live LRU entries, wherever they sit in LRU order.

How

  • _ObjectCacheState.expiry holds the heap. put() and complete_refresh() push the entry's deadline after they write it. Deletion is lazy: a removal, a replacement or a refresh leaves its old tuple. A tuple is live while its key holds an entry with that exact deadline, so a dead one is dropped when it comes due and can never remove a newer entry.
  • push() rebuilds the heap from the store once it holds twice as many tuples as there are entries. That costs one pass and at least halves the heap, so it amortises to O(1) per push.
  • complete_refresh() restores the byte bound through the same _evict(), so its eviction also removes expired entries first via the heap.
  • clear() empties the heap. The at-fork reset already swaps the whole state object, heap included.

Why a heap and not a min-expiry watermark. A watermark adds no memory, so I simulated it first: a Zipf workload over 10 times the cache's capacity, at capacity, with misses filled by put(). With one TTL close to the time the cache takes to turn over, it still scanned on 18-27% of the puts that hit the bound. With half the keys on an hour and half on a short TTL (2-5 s against a 200 s run), it scanned on 45-51%. A dead heap tuple costs nothing until it comes due, so the heap never scans in either case.

Memory. The heap is memory the byte budget does not count. Each tuple shares its float and key with the entry, so it costs 64 bytes per entry on CPython 3.12 (a 56-byte 2-tuple and its 8-byte list slot) and 72 on 3.14, where tuples are 8 bytes larger. Dead tuples add up to the same again before a rebuild.

The rebuild pause. One put in about every n puts at capacity rebuilds the heap. In the interleaved run below, at 100,000 entries that put took about 3 times as long as one put scanning the cache did before this change. Before the change, every put scanned.

Measurements

Instructions (main thread, tests/performance/ir_budget.py's method, median of the 5 heap layouts), for the new object_cache_put_full path: a put of a new key into a full 10,000-entry byte budget, nothing expired. The path did not exist before this change, so both arms ran this branch's ir_workload.py, one with object_cache.py at main swapped in. Under callgrind the main arm grew past 8 GiB at the gate's 1,000/3,000 loop sizes, so both arms are measured at 100/300:

CPython 3.12.12 CPython 3.14.3
main 5,730,341 5,641,918
this PR 161,365 160,088
change -97.2% -97.2%

At the gate's own loop sizes this PR's figure is 164,507 on 3.12 and 160,678 on 3.14, the budgets committed here. Most of what remains is _estimate_object_size on the stored value, which the byte bound needs.

Wall clock, interleaved in one process: main, main again as the A/A arm, and this PR, with puts round-robined across three identically built caches holding a 1,308-byte (estimated) dict value. The two main arms stayed within 1.5% of each other at p50. This PR's p50 put was faster at every size, so the sign agrees with the instruction counts:

Entries in the full budget p50, main ÷ this PR p95, main ÷ this PR
1,000 3.4× 3.2×
10,000 27× 22×
100,000 221× 223×

Indicative saving per put at 10,000 entries, from the instruction counts: 5.48 million fewer instructions × 0.068 ns per instruction (the main arm's wall p50 over its count, 3.14) ≈ 0.37 ms, which matches the measured p50 difference of 0.367 ms. The saving grows with the entry count, and it lands on a miss: hits gain only by waiting less for the lock.

Other paths. Editing ir_workload.py moves the heap layout of every measured process, so every budget is re-recorded on both interpreters (--update --allow-increase). They all move by ±0.7% or less except serializer_orjson, which drops by 4.6-6.8%. That path is gated at 2% because its figure moves with heap layout (TOLERANCE_PCT). No other path stores into an ObjectCache. l1_hit reads one and moves by -0.14% on 3.14 and +0.11% on 3.12. The Instruction budget check compares against the base, which has no object_cache_put_full yet, so that path reports as new.

Tests

In tests/unit/test_object_cache.py:

  • test_put_evicts_expired_before_lru is unchanged and passes.
  • test_put_evicts_expired_entry_deep_in_lru_order_before_live_head: an entry read just before it expired sits at the MRU end, and it is still evicted before the live LRU head.
  • test_put_at_capacity_scans_no_entry_when_none_has_expired: 50 puts at capacity over fresh entries never iterate the store. Fails on main.
  • test_refreshed_entry_is_not_evicted_at_its_old_deadline and test_replaced_entry_is_not_evicted_at_the_old_entrys_deadline: a dead tuple that comes due does not remove the live entry under its key.
  • test_complete_refresh_over_the_byte_bound_evicts_expired_before_lru: a refresh that grows its value evicts the expired entry, not the live LRU head.
  • test_expiry_heap_stays_bounded_at_capacity: with a hot key holding the heap top, the heap reaches twice the entry count, is rebuilt there and never passes it. clear() empties it.
  • _consistent(), which the fork tests use, now also checks that every entry's current deadline is in the heap.

Each new test was checked against a mutant. Dropping the deadline match from the liveness check fails both dead-tuple tests. Restoring the scan fails the no-scan test.

Locally on CPython 3.14.3: pytest tests --ignore=tests/fuzzing -m "not slow" gives 7,573 passed and 109 skipped. ruff check, ruff format --check and basedpyright report nothing new.

Docs

  • object_cache.py: the class, put() and _evict() docstrings describe the heap, its per-entry memory and the rebuild.
  • docs/performance.md: a new "L1-Only Miss at a Full Budget" section, and the new budget row plus the re-recorded figures.
  • docs/free-threading.md: the ObjectCache row lists the heap in the state object. The single-flight row no longer says an expiry sweep can hold the cache lock for a scan of the whole cache.

…scan

At a full bound, ObjectCache.put scanned every entry for expired ones
before it evicted the LRU head, under the lock every hit takes. With
ttl=None nothing expires, so every L1-only miss at a full budget paid a
full scan for nothing.

A min-heap of (deadline, key) now finds expired entries: one comparison
when none is due, O(log n) per due entry, and expired entries still go
before live LRU ones. Deletion is lazy; a put rebuilds the heap from the
store once dead tuples outnumber entries. complete_refresh pushes its new
deadline and evicts through the same path.

A min-expiry watermark was tried first and rejected: on a Zipf workload
at capacity it still scanned on 18-27% of over-bound puts with one TTL
near the turnover time, and on 45-51% with mixed TTLs.

Adds the object_cache_put_full instruction-budget path and re-records
every budget, since editing ir_workload.py moves the heap layout.
@kodus-27b

kodus-27b Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

Code Review Completed! 🔥

The code review was successfully completed based on your current configurations.

Kody Guide: Usage and Configuration
Interacting with Kody
  • Request a Review: Ask Kody to review your PR manually by adding a comment with the `@kody start-review` command at the root of your PR.

  • Provide Feedback: Help Kody learn and improve by reacting to its comments with a 👍 for helpful suggestions or a 👎 if improvements are needed.

Providing Context (Files & MCPs)

Add these hints in your PR description (or a comment) to unlock deeper checks:

  • Ticket / Acceptance Criteria: `Refs: ABC-123` (Linear/Jira/Asana/ClickUp/Trello) or a direct ticket link.
  • Bugfix Validation: a Sentry/Datadog/Bugsnag event link (or paste the stack trace/error message).
  • Endpoint Risk: mention the route (e.g., `POST /api/payments`) or controller/action name.
  • Attach a repo file as context: use an explicit marker like `@file:docs/guide.mdx#L10-L50` (replace with your real path).
  • API Contract Docs: include `@file:openapi.yaml` or `@file:swagger.json` when changing routes/schemas.
  • Definition of Done / Standards: include `@file:DOD.md` or `@file:CONTRIBUTING.md` if your repo has them.
  • Design System Source of Truth: include `@file:ui/index.ts` (replace with your DS entrypoint path).
  • Feature Flags: include the flag key/name and `@file:flags.ts` / `@file:config.json` (and optionally the PostHog flag name).
  • Edge/CDN Rules: link the Cloudflare rule/zone or describe the intended redirect/header behavior.
  • Attach an MCP tool output: use `@mcp<provider|tool>` (replace with an installed MCP provider + tool, e.g., `@mcp<sentry|events.search>`).
Current Kody Configuration
Review Options

The following review options are enabled or disabled:

Options Enabled
Bug ✅
Performance ✅
Security ✅
Business Logic ✅

Access your configuration settings here.

@coderabbitai

coderabbitai Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 12 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Learn how review limits work.

Review configuration:

Run configuration
Commits

Reviewing files that changed from the base of the PR and between 6fb59fc and 083847b.

Files selected for processing: 2
  • docs/performance.md
  • src/cachekit/object_cache.py

Summary by CodeRabbit

  • Performance
    • Cache capacity handling now removes expired entries before evicting least-recently-used entries, with less work when no expired entries need removal.
    • Refreshing or replacing entries no longer risks eviction based on outdated expiry records.
  • Documentation
    • Updated performance guidance and instruction-budget figures, including measurements for inserting into a full cache.
    • Clarified the documented lock order for single-flight operations.

Walkthrough

The object cache now tracks expiry deadlines in a heap and uses them to remove expired entries during capacity eviction. Unit tests cover eviction order, refreshed and replaced entries, heap bounds and clearing. Performance workloads, instruction budgets and documentation cover puts into a full cache.

Changes

Object-cache expiry eviction

Layer / File(s) Summary
Track deadlines and evict expired entries
src/cachekit/object_cache.py, tests/unit/test_object_cache.py, docs/free-threading.md
The cache tracks expiry deadlines in a heap. Eviction removes due entries from the heap before applying LRU eviction. Tests cover stale deadlines, eviction order, capacity bounds and heap consistency. The concurrency audit entry retains the map-lock-then-cache-state-lock order.
Measure and document full-cache puts
tests/performance/ir_workload.py, tests/performance/ir_budget.py, tests/performance/ir_baselines.json, docs/performance.md
A workload measures inserts into a full 10,000-entry byte budget. The instruction budgets and performance documentation include this path and updated figures.

Priority: Low

Estimated code review effort: 3 (Moderate) | ~20 minutes

Change: Refactor

Suggested reviewers: kodus-27b

Merge Risk: Minimal · previous review 6fb59

The change replaces the full-cache expiry scan on puts into a full cache with a deadline heap. Expiry ordering, stale-record handling and heap bounds are covered by tests, and the performance budgets and docs are updated. No merge-blocking risk remains; only a minor documentation wording point is outstanding.

Pre-merge checks: Passed 5
Check name Status Explanation
Title check Passed The title clearly and concisely describes the main change: replacing the full expiry scan with a deadline heap. The issue reference is relevant.
Description check Passed The description is detailed and covers the change, motivation, implementation, performance results, tests, documentation, and compatibility impact. It does not reproduce every template heading or chec…
Docstring Coverage Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff with cyclomatic complexity of at least 2. Documentat…
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Full details: Description check

Explanation

The description is detailed and covers the change, motivation, implementation, performance results, tests, documentation, and compatibility impact. It does not reproduce every template heading or checkbox, but it provides the critical information required for review.

Full details: Docstring Coverage

Explanation

Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff with cyclomatic complexity of at least 2. Documentation-only edits and deletions within surviving functions are included; unchanged diff context and fully deleted functions are excluded. Analyzed 9 functions across 4 files. (3 skipped: 3 unsupported.)

Finishing Touches
📝 Generate docstrings
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codecov

codecov Bot commented Oct 28, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ All tests successful. No failed tests found.

📢 Thoughts on this report? Let us know!

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔇 Additional comments: 1
src/cachekit/object_cache.py-145-146: 1

145-146: Minor · Maintainability & Code Quality · Quick win

⚠️ Unverified finding
Verification ran but could not confirm this finding. It is shown for review, not as a verified issue.

Qualify the no-scan claim.

When push() rebuilds the heap, it scans s.store.items() even if no entry has expired. State that an ordinary capacity eviction avoids the scan, but a rebuild performs one. Make the same qualification in docs/performance.md Line 98.


Review info
Run configuration
Commits

Reviewing files that changed from the base of the PR and between fe2536b and 6fb59fc.

Files selected for processing: 7
  • docs/free-threading.md
  • docs/performance.md
  • src/cachekit/object_cache.py
  • tests/performance/ir_baselines.json
  • tests/performance/ir_budget.py
  • tests/performance/ir_workload.py
  • tests/unit/test_object_cache.py

Included review availability: This review used your included allowance. Your plan provides up to 1 included review per hour; 0 remain after this review.

kodus-27b[bot]
kodus-27b Bot previously approved these changes Oct 28, 2026
@github-actions

github-actions Bot commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

Instruction budget: PASS

Head 083847b7c808b85028d94aa0c08e0e10c951e144 measured against base fe2536b08667271f043f29286f53450d5293d229 on the same runner, on cpython-3.12-x86_64 (3.12.3). Workflow run

Verdict Path Head Ir/op Base Ir/op Delta
new object_cache_put_full – – –
ok byte_storage_store_40b 2,756 2,757 -0.04%
ok byte_storage_store_64k 724,750 724,751 -0.00%
ok file_set 86,999 86,999 +0.00%
ok l1_hit 13,130 13,125 +0.04%
ok l2_hit 215,944 216,275 -0.15%
ok l2_hit_async_metrics 204,461 204,287 +0.09%
ok minimal_l1_hit 11,989 12,008 -0.16%
ok miss 252,900 252,996 -0.04%
ok secure_l1_hit 176,952 176,849 +0.06%
ok serializer_arrow 2,214,548 2,214,818 -0.01%
ok serializer_auto 72,480 72,478 +0.00%
ok serializer_default 64,373 64,422 -0.08%
ok serializer_default_records 1,482,817 1,481,624 +0.08%
ok serializer_encrypted 109,281 109,178 +0.09%
ok serializer_orjson 24,872 24,885 -0.05%

A path fails at +1% or more against the base (+2% for serializer_orjson). This check is not required: a deliberate cost increase is accepted at merge. Method: docs/performance.md, Instruction budgets.

@kodus-27b

kodus-27b Bot commented Oct 28, 2026

Copy link
Copy Markdown

Code Review Completed! 🔥

The code review was successfully completed based on your current configurations.

Kody Guide: Usage and Configuration
Interacting with Kody
  • Request a Review: Ask Kody to review your PR manually by adding a comment with the `@kody start-review` command at the root of your PR.

  • Provide Feedback: Help Kody learn and improve by reacting to its comments with a 👍 for helpful suggestions or a 👎 if improvements are needed.

Providing Context (Files & MCPs)

Add these hints in your PR description (or a comment) to unlock deeper checks:

  • Ticket / Acceptance Criteria: `Refs: ABC-123` (Linear/Jira/Asana/ClickUp/Trello) or a direct ticket link.
  • Bugfix Validation: a Sentry/Datadog/Bugsnag event link (or paste the stack trace/error message).
  • Endpoint Risk: mention the route (e.g., `POST /api/payments`) or controller/action name.
  • Attach a repo file as context: use an explicit marker like `@file:docs/guide.mdx#L10-L50` (replace with your real path).
  • API Contract Docs: include `@file:openapi.yaml` or `@file:swagger.json` when changing routes/schemas.
  • Definition of Done / Standards: include `@file:DOD.md` or `@file:CONTRIBUTING.md` if your repo has them.
  • Design System Source of Truth: include `@file:ui/index.ts` (replace with your DS entrypoint path).
  • Feature Flags: include the flag key/name and `@file:flags.ts` / `@file:config.json` (and optionally the PostHog flag name).
  • Edge/CDN Rules: link the Cloudflare rule/zone or describe the intended redirect/header behavior.
  • Attach an MCP tool output: use `@mcp<provider|tool>` (replace with an installed MCP provider + tool, e.g., `@mcp<sentry|events.search>`).
Current Kody Configuration
Review Options

The following review options are enabled or disabled:

Options Enabled
Bug ✅
Performance ✅
Security ✅
Business Logic ✅

Access your configuration settings here.

@27Bslash6
27Bslash6 merged commit 99047e1 into main Oct 28, 2026
57 checks passed
@27Bslash6
27Bslash6 deleted the perf/lab-7106-objectcache-expiry-heap branch October 28, 2026 23:42
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.

1 participant