Skip to content

Fix invalid timestamp comparison order for updates on unevicted expired entries - #581

Merged
tatsuya6502 merged 3 commits into
moka-rs:chore/add-tests-from-pr581from
atrocities:expiration-bug
Mar 15, 2026
Merged

tatsuya6502 merged 3 commits into
moka-rs:chore/add-tests-from-pr581from
atrocities:expiration-bug

Conversation

@atrocities

@atrocities atrocities commented Mar 2, 2026 •

Copy link
Copy Markdown
Contributor

Demonstrates and potentially fixes issue #575

When calling optionally_get_with on a key that is expired but not yet evicted by housekeeping, the subsequent 'update' operation compares two out-of-order timestamps, resulting in an inadvertent 'None' value. This causes the cache to keep the affected entry forever.

Exposed by PR #564, published in 0.12.13, which sets a None expiration time when the expire_after_update callback returns None. The underlying problem - comparing non sequential timestamps, has existed for some time, but the buggy behavior was introduced by that PR.

Test case reproduces a bug where optionally_get_with on an expired but not yet evicted entry causes the new value's expiration to be cleared, making it never expire.

  1. optionally_get_with misses at time 0, inserts value 1 with 2s expr
  2. Housekeeping will happen at 0.3, 0.6, 0.9, 1.2, 1.5, 1.8s
  3. optionally_get_with hits (entry not expired yet) at time 1.98s.
  4. optionally_get_with misses (expired) at 2.01s
    • Since housekeeping has not happened, the entry is not evicted, meaning that this is an UPDATE operation in do_insert_with_hash.
    • In do_insert_with_hash, the 'current' time (ts) is after the entry expiration
    • In expire_after_read_or_update (line 694) called from do_post_update_steps, the expiration time is before the current time, resulting in a None value
    • None is passed as the current_time to the default expire_after_update, which returns None
    • duration is now None and current_per_entry_exp_time is Some, resulting in no expiration
  5. optionally_get_with HITS value 2 at time 12.01s, and indefinitely after that

Open with Devin

Summary by CodeRabbit

  • Bug Fixes

    • Ensure per-entry expiration always yields a defined duration (clamped to zero when otherwise undefined), preventing ambiguous expiry behavior after reads/updates.
  • Tests

    • Added coverage for the edge case where entries are expired but not yet evicted, validating correct expiration and reinsertion behavior.

Exposed by moka-rs#564, which sets a None expiration time when the
expire_after_update callback returns None. The underlying problem -
comparing non sequential timestamps, has existed for some time, but the
buggy behavior was introduced by that PR.

Reproduces a bug where `optionally_get_with` on an expired but not yet evicted
entry causes the new value's expiration to be cleared, making it never expire.

1. optionally_get_with misses at time 0, inserts value 1 with 2s expr
2. Housekeeping will happen at 0.3, 0.6, 0.9, 1.2, 1.5, 1.8s
2. optionally_get_with hits (entry not expired yet) at time 1.98s.
3. optionally_get_with misses (expired) at 2.01s
   - Since housekeeping has not happened, the entry is not evicted, meaning that this is
     an UPDATE operation in do_insert_with_hash.
   - In do_insert_with_hash, the 'current' time (ts) is _after_ the entry expiration
   - In expire_after_read_or_update (line 694) called from do_post_update_steps, the
     expiration time is before the current time, resulting in a None value
   - None is passed as the current_time to the default expire_after_update, which returns
     None
   - duration is now None and current_per_entry_exp_time is Some, resulting in no expiration
4. optionally_get_with HITS value 2 at time 12.01s, and indefinitely after that
@coderabbitai

coderabbitai Bot commented Mar 2, 2026 •

Copy link
Copy Markdown

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between f311077 and 2a42fab.

📒 Files selected for processing (1)
  • src/future/base_cache.rs

📝 Walkthrough

Walkthrough

Ensure per-entry expiration never yields None by clamping the computed current duration to Some(Duration::ZERO) when the per-entry expiry is in the past; add a unit test exercising an expired-but-not-yet-evicted entry scenario.

Changes

Cohort / File(s) Summary
Sync cache expiry change
src/sync/base_cache.rs
Clamp per-entry computed current_duration to Some(Duration::ZERO) instead of returning None when the entry's expiry is in the past, ensuring expiry callbacks receive a concrete zero duration.
Future cache expiry change
src/future/base_cache.rs
Same clamp applied in the async/future path: current_duration now falls back to Some(Duration::ZERO) instead of None for past expiry times.
Expiration test
src/sync/cache.rs
Add test_optionally_get_with_expired_entry_bug to exercise behavior for entries that have expired but are not yet evicted, asserting flows through expire-after-read/update and subsequent insert behavior.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related issues

  • Issue #568: Touches the same expire-after-read/update path and the treatment of per-entry durations for already-expired entries.

Possibly related PRs

  • PR #549: Also modifies expire-after-read/update handling for already-expired entries; closely related to the same logic path.
  • PR #570: Makes related changes to per-entry expiration handling across similar code paths.
  • PR #564: Alters expire-after-read_or_update logic in the same file area, addressing a related expiration behavior.

Suggested labels

bug

Suggested reviewers

  • tatsuya6502

Poem

🐰 I hopped through time where durations hide,
Zero now waits where None once lied,
Expired entries no longer stray,
Tests nibble bugs and chase them away!

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly addresses the core issue: fixing invalid timestamp comparison order for updates on unevicted expired entries, which matches the main technical change and bug fix described in the PR objectives.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Tip

Try Coding Plans. Let us write the prompt for your AI agent so you can ship faster (with fewer bugs).
Share your feedback on Discord.


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 and usage tips.

devin-ai-integration[bot]

This comment was marked as resolved.

@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.

Actionable comments posted: 1

🧹 Nitpick comments (1)
src/sync/cache.rs (1)

5296-5297: Strengthen the final assertion to validate exact behavior.

assert_ne!(value, Some(2)) is a bit loose for a regression test. Asserting Some(3) makes the expected re-initialization path explicit and guards against false positives.

Suggested assertion tightening
-        let value = cache.optionally_get_with("key", || next_value());
-        assert_ne!(value, Some(2), "Access at 12.01s should not still be 2");
+        let value = cache.optionally_get_with("key", || next_value());
+        assert_eq!(value, Some(3), "Access at 12.01s should reinitialize to 3");
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/sync/cache.rs` around lines 5296 - 5297, Replace the loose inequality
assertion with an exact equality check so the test verifies re-initialization:
in the block using cache.optionally_get_with("key", || next_value()) replace the
assert_ne!(value, Some(2), ...) with an assertion that value equals Some(3)
(e.g. assert_eq!(value, Some(3), "...")) so the test explicitly validates the
expected re-initialized value returned by next_value().
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/sync/cache.rs`:
- Around line 5227-5238: Update the test narrative comment to correct numbering,
fix the typo "expr" to "expiry", and replace the incorrect phrase "None is
passed as the current_time" with "None is passed as current_duration"; keep
references to the relevant functions/locations (optionally_get_with,
housekeeping, do_insert_with_hash, do_post_update_steps,
expire_after_read_or_update, and default expire_after_update) so the sequence
reads clearly and accurately that an UPDATE occurs because housekeeping hasn't
evicted the expired entry and the expiration logic receives no current_duration,
causing the duration to be None while current_per_entry_exp_time is Some.

---

Nitpick comments:
In `@src/sync/cache.rs`:
- Around line 5296-5297: Replace the loose inequality assertion with an exact
equality check so the test verifies re-initialization: in the block using
cache.optionally_get_with("key", || next_value()) replace the assert_ne!(value,
Some(2), ...) with an assertion that value equals Some(3) (e.g.
assert_eq!(value, Some(3), "...")) so the test explicitly validates the expected
re-initialized value returned by next_value().

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 2d8289e and f311077.

📒 Files selected for processing (2)
  • src/sync/base_cache.rs
  • src/sync/cache.rs

Comment thread src/sync/cache.rs
Comment on lines +5227 to +5238
/// 1. optionally_get_with misses at time 0, inserts value 1 with 2s expr
/// 2. Housekeeping will happen at 0.3, 0.6, 0.9, 1.2, 1.5, 1.8s
/// 2. optionally_get_with hits (entry not expired yet) at time 1.98s.
/// 3. optionally_get_with misses (expired) at 2.01s
/// - Since housekeeping has not happened, the entry is not evicted, meaning that this is
/// an UPDATE operation in do_insert_with_hash.
/// - In do_insert_with_hash, the 'current' time (ts) is _after_ the entry expiration
/// - In expire_after_read_or_update (line 694) called from do_post_update_steps, the
/// expiration time is before the current time, resulting in a None value
/// - None is passed as the current_time to the default expire_after_update, which returns
/// None
/// - duration is now None and current_per_entry_exp_time is Some, resulting in no expiration

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Fix inaccuracies in the test narrative comment.

The comment has minor correctness/readability issues: duplicated step numbers, typo (expr), and it says None is passed as current_time (should describe current_duration in this context).

Suggested doc comment cleanup
-    /// 1. optionally_get_with misses at time 0, inserts value 1 with 2s expr
-    /// 2. Housekeeping will happen at 0.3, 0.6, 0.9, 1.2, 1.5, 1.8s
-    /// 2. optionally_get_with hits (entry not expired yet) at time 1.98s.
+    /// 1. optionally_get_with misses at time 0, inserts value 1 with 2s expiry
+    /// 2. Housekeeping will happen at 0.3, 0.6, 0.9, 1.2, 1.5, 1.8s
+    /// 3. optionally_get_with hits (entry not expired yet) at time 1.98s.
-    /// 3. optionally_get_with misses (expired) at 2.01s
+    /// 4. optionally_get_with misses (expired) at 2.01s
 ...
-    ///    - None is passed as the current_time to the default expire_after_update, which returns
+    ///    - None is passed as the current_duration to the default expire_after_update, which returns
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/sync/cache.rs` around lines 5227 - 5238, Update the test narrative
comment to correct numbering, fix the typo "expr" to "expiry", and replace the
incorrect phrase "None is passed as the current_time" with "None is passed as
current_duration"; keep references to the relevant functions/locations
(optionally_get_with, housekeeping, do_insert_with_hash, do_post_update_steps,
expire_after_read_or_update, and default expire_after_update) so the sequence
reads clearly and accurately that an UPDATE occurs because housekeeping hasn't
evicted the expired entry and the expiration logic receives no current_duration,
causing the duration to be None while current_per_entry_exp_time is Some.

@tatsuya6502 tatsuya6502 self-assigned this Mar 2, 2026
@tatsuya6502 tatsuya6502 added this to the v0.12.15 milestone Mar 2, 2026
@codecov

codecov Bot commented Mar 2, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
⚠️ Please upload report for BASE (chore/add-tests-from-pr581@28d3eea). Learn more about missing BASE report.

Additional details and impacted files
@@                      Coverage Diff                      @@
##             chore/add-tests-from-pr581     #581   +/-   ##
=============================================================
  Coverage                              ?   93.43%           
=============================================================
  Files                                 ?       44           
  Lines                                 ?    16960           
  Branches                              ?        0           
=============================================================
  Hits                                  ?    15847           
  Misses                                ?     1113           
  Partials                              ?        0           
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@tatsuya6502
tatsuya6502 self-requested a review March 2, 2026 10:29
@jiangzhe

jiangzhe commented Mar 3, 2026

Copy link
Copy Markdown
Contributor

I think there is still one issue left (PR #582 contains both fixes). If this PR is merged, I could rebase accordingly.

@tatsuya6502 tatsuya6502 added the bug Something isn't working label Mar 8, 2026
@tatsuya6502
tatsuya6502 changed the base branch from main to chore/add-tests-from-pr581 March 15, 2026 10:43

@tatsuya6502 tatsuya6502 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thank you for the contribution. I took the fix from #582, but I'll try to take the test from this PR.

@tatsuya6502

Copy link
Copy Markdown
Member

Merging into a branch chore/add-tests-from-pr581.

@tatsuya6502
tatsuya6502 merged commit 24b917e into moka-rs:chore/add-tests-from-pr581 Mar 15, 2026
25 checks passed
tatsuya6502 added a commit that referenced this pull request Mar 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants