Skip to content

rebuild cache on MapRef, fix cancellation defects - #369

Open
stasimus wants to merge 8 commits into
masterfrom
experimenting
Open

rebuild cache on MapRef, fix cancellation defects#369
stasimus wants to merge 8 commits into
masterfrom
experimenting

Conversation

@stasimus

@stasimus stasimus commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Rebuilds LoadingCache on cats.effect.std.MapRef: per-key CAS instead of whole-map Ref, cancelable loads with cleanup, ExpiringCache evicts stale Loading entries. Fixes four proven defects; defect spec now asserts expected behavior and passes. Adds load-test bench (Test/runMain CacheLoadTest). Creation bounds widened Concurrent to Async.

Bench: 8 fibers x 100k ops/fiber, keySpace 10k, median of 3 runs, ops/s.

LoadingCache (single partition) old (Ref[Map]) new (MapRef) ratio
getOrUpdate, insert distinct keys 709k 996k 1.40x
getOrUpdate, hit random keys 1.79M 2.37M 1.32x
getOrUpdate, hit single hot key 11.1M 15.7M 1.41x
put, replace random keys 4.42M 5.56M 1.26x
mixed get/put/remove 1.59M 1.90M 1.19x
Cache.loading (partitioned) old new ratio
getOrUpdate, insert distinct keys 1.31M 1.55M 1.18x
getOrUpdate, hit random keys 9.64M 22.2M 2.31x
getOrUpdate, hit single hot key 9.76M 11.3M 1.15x
put, replace random keys 6.67M 8.99M 1.35x
mixed get/put/remove 3.90M 5.88M 1.51x
Cache.expiring (partitioned) old new ratio
getOrUpdate, insert distinct keys 892k 1.34M 1.50x
getOrUpdate, hit random keys 7.62M 10.3M 1.35x
getOrUpdate, hit single hot key 9.88M 14.0M 1.42x
put, replace random keys 6.30M 8.00M 1.27x
mixed get/put/remove 3.59M 4.49M 1.25x

Summary by CodeRabbit

  • New Features

    • Added configurable cleanup for cache entries that exceed loading time limits.
    • Added clear expiration errors for computations that outlive their cache entry.
    • Improved cancellation handling so waiting operations are reliably released.
  • Documentation

    • Added benchmark instructions, workload details, performance comparisons, and version 7.0 migration notes.
  • Tests

    • Added coverage for cancellation, expiration, concurrency, cleanup, and resource-release scenarios.
    • Added benchmark suites and recorded performance results across cache configurations.

@stasimus
stasimus marked this pull request as draft July 31, 2026 18:23
@stasimus stasimus changed the title Add failing defect tests for LoadingCache and ExpiringCache WIP: rebuild cache on MapRef, fix cancellation defects Jul 31, 2026
@stasimus stasimus closed this Jul 31, 2026
@stasimus stasimus reopened this Jul 31, 2026
@mr-git

mr-git commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Fixes four proven defects

are the following the above-mentioned defects:

  • claim 1: loads are cancelable and cancellation cleans up the Loading entry;
  • claim 2: entries stuck in Loading state are evicted by the expiration routine;
  • claim 3: waiters on a Loading entry are unblocked when the load is cancelled;
  • claim 4: operations on distinct keys are independent, no shared-state CAS retries.

@stasimus stasimus self-assigned this Aug 4, 2026
@stasimus
stasimus requested a review from edubrovski August 4, 2026 20:24
@stasimus
stasimus marked this pull request as ready for review August 4, 2026 20:24
@stasimus

stasimus commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

Yes, exactly those four, declared and asserted in CacheDefectsSpec.scala, one test per claim. They weren't filed as separate GitHub issues, just documented there.

@mr-git

mr-git commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

I am at the beginning of review and I understood that I fail to comprehend what happens in new functionality.

ScalaDocs are really required! With explicitly explaining why? part, overall description of envisioned algorithm would be very welcome too!

@stasimus

stasimus commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

Added scaladocs on LoadingCache, EntryMap, EntryState, the cache operations and the ExpiringCache eviction routine, describing the algorithm and the four defects this change fixes.

P1, contention on shared state. The state used to be one Ref[F, Map[K, EntryRef]], so every insert or removal of any key CAS-ed the same Ref, and a getOrUpdate of one key lost its CAS whenever an unrelated key was written. Sustained writes elsewhere starved it, which is what MaxRetries = 10000 and IllegalStateException("extreme contention") were guarding, i.e. contention turned into a user visible failure. Every write also copied the whole map. Now each key has its own Ref over a ConcurrentHashMap, unrelated keys never interfere and the retry limit is gone.

P2, cancellation poisoned the key. The value computation ran unmasked inside the retry loop with no onCancel, so cancelling getOrUpdate left Loading(deferred) in the map with the deferred never completed: the key stayed unusable and every waiter blocked forever, and a value computed just as cancellation hit was leaked. Now state transitions are masked, and cancellation unlinks the key, completes the deferred with CancelledError and releases the value if one was produced.

P3, expiration ignored Loading. removeExpiredAndCheckSize only inspected Value states, so an entry whose load never completes was never evicted. Now loads that run longer than the expiration interval are evicted and their waiters get ExpiredError.

P4, a stuck load blocked finalization, since clear runs on resource release and waits on Loading entries. P2 and P3 remove both ways of getting stuck there.

CacheDefectsSpec covers all four.

@mr-git

mr-git commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Bench: 8 fibers x 100k ops/fiber, keySpace 10k, median of 3 runs, ops/s.

where is the code for benchmarks?

@mr-git

mr-git commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

@stasimus, could we get the PR with failing (and ignored or explicitly waiting for failures with corresponding comments and verbose printouts) unit-tests against "current" (as in master branch)?

I tried to do the quick rollback of implementation in the branch, but new unit-tests do not compile against old implementation.

It also means that this PR might change public API in incompatible way - we must provide the instructions on how to migrate from "old" to "new" APIs!

Comment thread scache/src/main/scala/com/evolution/scache/Cache.scala
Comment thread scache/src/main/scala/com/evolution/scache/ExpiredError.scala
Comment thread scache/src/main/scala/com/evolution/scache/ExpiringCache.scala Outdated
Comment thread scache/src/main/scala/com/evolution/scache/ExpiringCache.scala Outdated
Comment thread scache/src/main/scala/com/evolution/scache/ExpiringCache.scala Outdated
Comment thread scache/src/test/scala/com/evolution/scache/CacheLoadTest.scala Outdated
Comment thread scache/src/test/scala/com/evolution/scache/CacheLoadTest.scala Outdated
Comment thread scache/src/test/scala/com/evolution/scache/CacheLoadTest.scala Outdated
Comment thread scache/src/test/scala/com/evolution/scache/CacheLoadTest.scala Outdated
Comment thread scache/src/test/scala/com/evolution/scache/CacheLoadTest.scala Outdated
@stasimus

stasimus commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

Benchmarks are in a new benchmark module now, JMH based, at benchmark/src/main/scala/com/evolution/scache/bench/CacheBenchmark.scala. It carries a frozen copy of the pre-MapRef implementation under com.evolution.scache.v1, so old and new are measured in the same run: impl=v1 against impl=v2, across flavor=single, partitioned and expiring, over get, get1, getOrUpdate, put, modify, remove, contains and foldMap.

Run it with: sbt "benchmark/Jmh/run", or narrow it down, e.g. sbt "benchmark/Jmh/run -p impl=v1,v2 -p flavor=single .getOrUpdateHitRandomKeys.". I will refresh the numbers in the description once the full suite has run on a quiet machine.

@stasimus

stasimus commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

Ran the JMH suite twice back to back on the same machine, once on 7c9fa9f and once on this branch, and put the before and after table in the README under Benchmarks, Results. Raw JMH output of both runs is committed in benchmark/results.

Biggest gains are where the old code had to CAS the shared map: put of distinct keys 1.66 to 9.24 M ops/s on the unpartitioned cache, modify of distinct keys 1.88 to 11.44, remove and put 0.84 to 3.87. With partitioning the same operations gain 1.6x to 1.9x, reads gain around 1.2x. foldMap is 2 to 3 percent slower, which is the price of walking a ConcurrentHashMap instead of an atomic snapshot.

@coderabbitai

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The cache now uses per-key entry maps and Async capabilities. Loading expiration reports ExpiredError. Cancellation and concurrency tests cover the new lifecycle behavior. A JMH benchmark project, recorded results, and migration documentation were added.

Changes

Cache storage and public API

Layer / File(s) Summary
Per-key cache state and operations
scache/src/main/scala/com/evolution/scache/LoadingCache.scala, scache/src/main/scala/com/evolution/scache/Cache.scala, scache/src/main/scala/com/evolution/scache/SerialMap.scala, scache/src/main/scala/com/evolution/scache/CancelledError.scala, README.md
LoadingCache now uses EntryMap and per-key operations. Cache factories and SerialMap require Async. The obsolete EntryRefs API is removed.
Loading expiration and expiring cache integration
scache/src/main/scala/com/evolution/scache/ExpiringCache.scala, scache/src/main/scala/com/evolution/scache/ExpiredError.scala
ExpiringCache tracks loading timeouts, evicts stuck loads, completes waiters with ExpiredError, and delegates through EntryMap.

Regression validation

Layer / File(s) Summary
Cancellation and concurrency regression coverage
scache/src/test/scala/com/evolution/scache/CacheDefectsSpec.scala, scache/src/test/scala/com/evolution/scache/CacheSpec.scala, scache/src/test/scala/com/evolution/scache/SerialMapSpec.scala
Tests cover cancellation cleanup, waiter completion, expiration generations, independent-key concurrency, removal races, and resource release.

Benchmark suite

Layer / File(s) Summary
JMH benchmark project and recorded results
project/plugins.sbt, build.sbt, benchmark/src/main/scala/com/evolution/scache/bench/CacheBenchmark.scala, benchmark/results/*.json, README.md
The build adds an sbt-jmh project with concurrent cache workloads, traversal measurements, recorded results, and benchmark execution instructions.

Estimated code review effort: 5 (Critical) | ~90 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Loader
  participant ExpiringCache
  participant EntryMap
  participant WaitingFiber
  ExpiringCache->>EntryMap: track loading entry
  ExpiringCache->>EntryMap: evict after loadingTimeout
  EntryMap-->>WaitingFiber: complete with ExpiredError
  EntryMap-->>Loader: complete with ExpiredError
Loading

Suggested reviewers: edubrovski, mr-git

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the main cache rebuild and cancellation defect fixes described in the pull request.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch experimenting

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

@stasimus stasimus changed the title WIP: rebuild cache on MapRef, fix cancellation defects rebuild cache on MapRef, fix cancellation defects Aug 8, 2026

@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: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
scache/src/test/scala/com/evolution/scache/CacheSpec.scala (1)

1075-1090: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

The cancellation proper test can block on the loading Deferred.

fiber.cancel.start runs the cancellation concurrently. The following cache.get(0) is not synchronized against it. If get observes the entry still in Loading state, it awaits the entry's Deferred. That Deferred is completed by the cancellation cleanup with CancelledError, so the test recovers, but only after the cleanup runs.

If the cancellation cleanup has not yet marked the entry, get returns the loaded value instead of none, and result shouldEqual none fails.

Join the cancellation before asserting, or poll get until it returns none.

💚 Proposed fix
             fiber <- cache.getOrUpdateEnsure(0)(deferred.get)
-            fiber <- fiber.cancel.start
-            result <- cache.get(0)
-            _ <- IO { result shouldEqual none }
-            _ <- deferred.complete(0)
-            _ <- fiber.joinWithNever
+            cancelling <- fiber.cancel.start
+            _ <- cancelling.joinWithNever
+            result <- cache.get(0)
+            _ <- IO { result shouldEqual none }
+            _ <- deferred.complete(0)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scache/src/test/scala/com/evolution/scache/CacheSpec.scala` around lines 1075
- 1090, Update the `cancellation proper` test to synchronize cancellation before
asserting the cache state: join the cancellation fiber created by
`fiber.cancel.start` before calling `cache.get(0)` and checking `result
shouldEqual none`. Preserve the existing deferred completion and metrics
assertions.
🧹 Nitpick comments (4)
README.md (1)

148-157: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Use the shell fence language for the sbt commands.

The block contains shell commands, not Scala code. The comparison block at Line 204 already uses shell. Align the two blocks.

📝 Proposed fix
-```scala
+```shell
 // everything, around 10 minutes
 sbt "benchmark/Jmh/run"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@README.md` around lines 148 - 157, Change the fenced code language for the
sbt command block in the README from scala to shell, matching the existing shell
fence used by the comparison block while leaving the commands unchanged.
scache/src/main/scala/com/evolution/scache/ExpiringCache.scala (2)

442-447: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

foldMapPar delegates to cache.foldMap.

The method name promises parallel folding, but the body calls cache.foldMap. The underlying LoadingCache.foldMapPar does run in parallel. This predates the change, and only the signature line changed here.

Change the delegation to cache.foldMapPar if the parallel behavior is intended.

♻️ Proposed fix
       def foldMapPar[A: CommutativeMonoid](f: (K, Either[F[V], V]) => F[A]): F[A] = {
-        cache.foldMap {
+        cache.foldMapPar {
           case (k, Right(v)) => f(k, v.value.asRight)
           case (k, Left(v)) => f(k, v.map { _.value }.asLeft)
         }
       }

Note: this needs Parallel[F] on apply, which currently only requires MonadThrow: Clock.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scache/src/main/scala/com/evolution/scache/ExpiringCache.scala` around lines
442 - 447, Update ExpiringCache.foldMapPar to delegate to cache.foldMapPar so it
preserves the promised parallel behavior, and add the required Parallel[F]
constraint to the surrounding apply or relevant construction context alongside
the existing MonadThrow and Clock requirements.

41-51: 🚀 Performance & Scalability | 🔵 Trivial

Consider the scan cost when loadingTimeout is much shorter than the expiration.

expireInterval is bounded by loadingTimeoutMs / 2. One run walks every entry of the cache. A short loadingTimeout next to a large cache therefore schedules a full scan very often, for example every 50 ms for a 100 ms timeout.

The comment describes the trade-off, but nothing bounds the resulting work. Consider documenting the cost in Config.loadingTimeout, or tracking loading entries separately so that the loading sweep does not need a full traversal.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scache/src/main/scala/com/evolution/scache/ExpiringCache.scala` around lines
41 - 51, Address the full-cache scan cost in the expireInterval logic around
Config.loadingTimeout: avoid scheduling whole-cache traversals solely from a
short loadingTimeout, preferably by tracking loading entries separately so
loading cleanup does not require scanning every cache entry; otherwise document
the scan-cost trade-off in Config.loadingTimeout and preserve expiration cleanup
behavior.
scache/src/main/scala/com/evolution/scache/Cache.scala (1)

511-511: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Update the context-bound documentation for the new Async requirement.

The signature now requires Async[F]. The scaladoc above still explains the bounds in terms of Sync and Concurrent, and the expiring scaladoc still names Temporal. Readers of the API docs will see the old requirements.

Add a line describing why Async is needed, matching the README migration note.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scache/src/main/scala/com/evolution/scache/Cache.scala` at line 511, Update
the Scaladoc for loading to document the Async[F] context bound and its purpose,
replacing the outdated Sync/Concurrent explanation and matching the README
migration wording. Also update the expiring Scaladoc to reflect its current
Async requirement instead of naming Temporal.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@benchmark/src/main/scala/com/evolution/scache/bench/CacheBenchmark.scala`:
- Around line 207-213: Add method-level `@OperationsPerInvocation`(320000) to
removeAndPutRandomKeys, overriding the class-level operation count, and
regenerate the recorded benchmark results.

In `@scache/src/main/scala/com/evolution/scache/ExpiringCache.scala`:
- Around line 104-129: Update evictLoading so the loading deferred is completed
with ExpiredError before the entry is marked Removed and unlinked, preventing a
concurrent load from publishing a value after eviction. Preserve the existing
deferred-identity check and no-op behavior when the entry is no longer the
matching load.

In `@scache/src/main/scala/com/evolution/scache/LoadingCache.scala`:
- Around line 676-701: Update the LoadingCache put path at
LoadingCache.scala:676-701 and the corresponding modify path at
LoadingCache.scala:825-850 so deferred publication and the EntryState transition
use distinct, balanced ownership. Retain or transfer ownership when complete
publishes entry, release the producer’s ownership exactly once when set/setRef
fails after publication, preserve the waiter-owned reference, and release the
unpublished entry when complete fails before retrying or exiting.

In `@scache/src/test/scala/com/evolution/scache/CacheDefectsSpec.scala`:
- Around line 211-240: Increase the loadingTimeout in the “a new load generation
does not inherit the previous generation's stuck-timer” test and adjust the
second sleep in the result block to remain well below the new timeout,
preserving the presence assertion while providing sufficient scheduler-timing
margin.

---

Outside diff comments:
In `@scache/src/test/scala/com/evolution/scache/CacheSpec.scala`:
- Around line 1075-1090: Update the `cancellation proper` test to synchronize
cancellation before asserting the cache state: join the cancellation fiber
created by `fiber.cancel.start` before calling `cache.get(0)` and checking
`result shouldEqual none`. Preserve the existing deferred completion and metrics
assertions.

---

Nitpick comments:
In `@README.md`:
- Around line 148-157: Change the fenced code language for the sbt command block
in the README from scala to shell, matching the existing shell fence used by the
comparison block while leaving the commands unchanged.

In `@scache/src/main/scala/com/evolution/scache/Cache.scala`:
- Line 511: Update the Scaladoc for loading to document the Async[F] context
bound and its purpose, replacing the outdated Sync/Concurrent explanation and
matching the README migration wording. Also update the expiring Scaladoc to
reflect its current Async requirement instead of naming Temporal.

In `@scache/src/main/scala/com/evolution/scache/ExpiringCache.scala`:
- Around line 442-447: Update ExpiringCache.foldMapPar to delegate to
cache.foldMapPar so it preserves the promised parallel behavior, and add the
required Parallel[F] constraint to the surrounding apply or relevant
construction context alongside the existing MonadThrow and Clock requirements.
- Around line 41-51: Address the full-cache scan cost in the expireInterval
logic around Config.loadingTimeout: avoid scheduling whole-cache traversals
solely from a short loadingTimeout, preferably by tracking loading entries
separately so loading cleanup does not require scanning every cache entry;
otherwise document the scan-cost trade-off in Config.loadingTimeout and preserve
expiration cleanup behavior.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: c0b2a606-d294-4b09-acd4-cc3e8476ca8b

📥 Commits

Reviewing files that changed from the base of the PR and between 7c9fa9f and d70469f.

📒 Files selected for processing (15)
  • README.md
  • benchmark/results/mapref.json
  • benchmark/results/master-7c9fa9f.json
  • benchmark/src/main/scala/com/evolution/scache/bench/CacheBenchmark.scala
  • build.sbt
  • project/plugins.sbt
  • scache/src/main/scala/com/evolution/scache/Cache.scala
  • scache/src/main/scala/com/evolution/scache/CancelledError.scala
  • scache/src/main/scala/com/evolution/scache/ExpiredError.scala
  • scache/src/main/scala/com/evolution/scache/ExpiringCache.scala
  • scache/src/main/scala/com/evolution/scache/LoadingCache.scala
  • scache/src/main/scala/com/evolution/scache/SerialMap.scala
  • scache/src/test/scala/com/evolution/scache/CacheDefectsSpec.scala
  • scache/src/test/scala/com/evolution/scache/CacheSpec.scala
  • scache/src/test/scala/com/evolution/scache/SerialMapSpec.scala

Comment on lines +207 to +213
@Benchmark
def removeAndPutRandomKeys(state: PopulatedCacheState): Unit = {
parRun { (fiber, i) =>
val k = key(fiber, i)
state.cache.remove(k).flatten *> state.cache.put(k, i).flatten.void
}.unsafeRunSync()
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

For JMH 1.37, does a method-level @OperationsPerInvocation override a class-level annotation for one benchmark method?

💡 Result:

Yes, in JMH 1.37 (and generally across all versions), a method-level @OperationsPerInvocation annotation overrides any @OperationsPerInvocation annotation defined at the class level [1]. JMH annotation processing follows a hierarchy where the most specific annotation—the one closest to the benchmark method—takes precedence [2]. Specifically: 1. Class-level annotations set a default configuration for all @Benchmark methods within that class [1]. 2. Method-level annotations are treated as more specific and will override the class-level settings for that specific benchmark [1]. This behavior is consistent with how JMH handles other metadata annotations, where the "closest" annotation in the hierarchy wins [2]. When you define @OperationsPerInvocation on a specific @Benchmark method, JMH uses that value to calculate the individual operation cost for that method, ignoring any value provided at the class level for that same method [1].

Citations:


🏁 Script executed:

#!/bin/bash
set -eu
file="benchmark/src/main/scala/com/evolution/scache/bench/CacheBenchmark.scala"
printf '%s\n' '--- annotation and benchmark definitions ---'
rg -n -C 5 '`@OperationsPerInvocation`|removeAndPutRandomKeys|Fibers|OpsPerFiber|parRun' "$file"
printf '%s\n' '--- imports ---'
sed -n '1,80p' "$file"
printf '%s\n' '--- relevant body ---'
sed -n '190,220p' "$file"

Repository: evolution-gaming/scache

Length of output: 9430


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- JMH version and benchmark result artifacts ---'
rg -n -C 3 'jmh|Jmh|OperationsPerInvocation|removeAndPutRandomKeys' \
  build.sbt project benchmark README.md .github 2>/dev/null || true
printf '%s\n' '--- matching tracked files ---'
git ls-files | rg -i 'benchmark|jmh|result|csv|json|txt' | head -200
printf '%s\n' '--- annotation usage ---'
rg -n -C 2 '`@OperationsPerInvocation`' .

Repository: evolution-gaming/scache

Length of output: 43424


🌐 Web query:

JMH 1.37 OperationsPerInvocation annotation method level class level precedence official source BenchmarkGenerator

💡 Result:

JMH’s @OperationsPerInvocation has this level precedence: 1) Method-level @OperationsPerInvocation on a specific @Benchmark method overrides any class-level @OperationsPerInvocation. 2) If a benchmark method has no method-level @OperationsPerInvocation, the enclosing class’ @OperationsPerInvocation (if present) is used. 3) If neither is present, the default is 1 operation per single @Benchmark invocation. This is how JMH’s BenchmarkGenerator wires the chosen operations-per-invocation value into generated benchmark entries (it uses the resolved operationsPerInvocation from the method group during generation). [1] Operational definition: “value” = number of operations per single Benchmark call; default 1. [2][3] Notes: - @OperationsPerInvocation can be put at Benchmark method level or at the enclosing class level to affect one method or all methods in the class. [4] Sources: [2][4][3][1]

Citations:


🏁 Script executed:

#!/bin/bash
set -eu
python3 - <<'PY'
from pathlib import Path
import re

p = Path("benchmark/src/main/scala/com/evolution/scache/bench/CacheBenchmark.scala")
s = p.read_text()
fibers = int(re.search(r"final val Fibers = (\d+)", s).group(1))
ops_per_fiber = int(re.search(r"final val OpsPerFiber = (\d+)", s).group(1))
method = re.search(
    r"def removeAndPutRandomKeys\b.*?(?=\n  `@Benchmark`|\Z)", s, re.S
).group(0)
remove_calls = len(re.findall(r"\.cache\.remove\s*\(", method))
put_calls = len(re.findall(r"\.cache\.put\s*\(", method))
print({
    "Fibers": fibers,
    "OpsPerFiber": ops_per_fiber,
    "invocations_per_benchmark_call": fibers * ops_per_fiber,
    "remove_calls_per_invocation": fibers * ops_per_fiber * remove_calls,
    "put_calls_per_invocation": fibers * ops_per_fiber * put_calls,
    "cache_calls_per_invocation": fibers * ops_per_fiber * (remove_calls + put_calls),
})
PY

Repository: evolution-gaming/scache

Length of output: 352


Correct the operation count for removeAndPutRandomKeys.

This benchmark performs 160,000 remove calls and 160,000 put calls per invocation. Add method-level @OperationsPerInvocation(320000) to override the class-level value, then regenerate the recorded results.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@benchmark/src/main/scala/com/evolution/scache/bench/CacheBenchmark.scala`
around lines 207 - 213, Add method-level `@OperationsPerInvocation`(320000) to
removeAndPutRandomKeys, overriding the class-level operation count, and
regenerate the recorded benchmark results.

Comment on lines +104 to +129
def evictLoading(
key: K,
entryRef: LoadingCache.EntryRef[F, TimestampedValue],
deferred: LoadingDeferred,
): F[Unit] = {
entryRef
.modify {
case state: EntryState.Loading[F, TimestampedValue] if state.deferred == deferred =>
(EntryState.Removed, true)
case state =>
(state, false)
}
.flatMap {
case true =>
entryMap
.ref(key)
.update {
case Some(`entryRef`) => none
case other => other
}
.productR { deferred.complete(ExpiredError.asLeft).void }
case false =>
().pure[F]
}
.uncancelable
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Trace what a load does when it observes EntryState.Removed after eviction.
set -euo pipefail

fd -t f 'LoadingCache.scala' --exec rg -n -C 10 'tryPutNewValue|EntryState.Removed' {}
fd -t f 'ExpiringCacheSpec.scala' --exec rg -n -C 6 'ExpiredError|loadingTimeout' {}

Repository: evolution-gaming/scache

Length of output: 14714


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- candidate files ---'
fd -t f '(LoadingCache|ExpiringCache|README|Spec)' .

echo '--- LoadingCache load and remove paths ---'
file=$(fd -t f 'LoadingCache.scala' | head -n 1)
sed -n '280,480p' "$file"
sed -n '640,715p' "$file"
sed -n '930,985p' "$file"

echo '--- ExpiringCache and documentation ---'
expiring=$(fd -t f 'ExpiringCache.scala' | head -n 1)
sed -n '45,145p' "$expiring"
rg -n -C 4 'detached|ExpiredError|expire|loading' README.md "$expiring" $(fd -t f 'ExpiringCacheSpec.scala' || true) || true

echo '--- tests covering removal during loading ---'
fd -t f -e scala . | xargs rg -n -C 5 'remove.*load|load.*remove|ExpiredError|loadingTimeout|detached' || true

Repository: evolution-gaming/scache

Length of output: 50381


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- public remove contract ---'
rg -n -C 8 'def remove|Removes loading|load.*outlive|puts the key back' \
  scache/src/main/scala/com/evolution/scache/LoadingCache.scala \
  scache/src/main/scala/com/evolution/scache/Cache.scala \
  README.md

echo '--- timeout-related tests ---'
rg -n -C 12 'loadingTimeout|ExpiredError|remove.*loading|loading.*remove|detached|outlive' \
  scache/src/test/scala/com/evolution/scache/ExpiringCacheSpec.scala \
  scache/src/test/scala/com/evolution/scache/CacheSpec.scala \
  scache/src/test/scala/com/evolution/scache/CacheDefectsSpec.scala

echo '--- concise source assertions ---'
python3 - <<'PY'
from pathlib import Path

loading = Path('scache/src/main/scala/com/evolution/scache/LoadingCache.scala').read_text()
expiring = Path('scache/src/main/scala/com/evolution/scache/ExpiringCache.scala').read_text()
readme = Path('README.md').read_text()

checks = {
    'load retries insertion after Removed': 'case (EntryState.Removed, _) =>' in loading and 'case EntryState.Removed =>\\n                                          tryPutNewValue' in loading,
    'remove documents reinsertion': 'stores its value\\n       * under it' in loading,
    'ExpiringCache documents detached load': 'only detached from the cache' in expiring,
    'README documents detached load': 'only detached from the cache' in readme,
    'eviction completes ExpiredError': 'deferred.complete(ExpiredError.asLeft).void' in expiring,
}
for name, result in checks.items():
    print(f'{name}: {result}')
PY

echo '--- minimal transition model from the documented branches ---'
python3 - <<'PY'
class EntryRef:
    def __init__(self, state):
        self.state = state

def load_after_eviction(entry_ref, map_ref, key, value):
    if entry_ref.state == 'Removed':
        new_entry_ref = EntryRef(('Value', value))
        if map_ref.get(key) is None:
            map_ref[key] = new_entry_ref
            return 'inserted'
        return 'existing-entry-wins'
    return 'different-state'

entry_ref = EntryRef('Removed')
entry_map = {}
print(load_after_eviction(entry_ref, entry_map, 'k', 'v'))
print(entry_map['k'].state)
PY

Repository: evolution-gaming/scache

Length of output: 22832


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- complete load race and publication branch ---'
sed -n '320,485p' scache/src/main/scala/com/evolution/scache/LoadingCache.scala

echo '--- eviction test through post-eviction completion ---'
sed -n '181,210p' scache/src/test/scala/com/evolution/scache/CacheDefectsSpec.scala

echo '--- dependency and race API references ---'
rg -n -C 3 'cats.effect|race1|racePair' build.sbt project scache/build.sbt scache/src/main scache/src/test 2>/dev/null || true

echo '--- deterministic interleaving model ---'
python3 - <<'PY'
# Model only the state transitions visible in the source:
# eviction marks Removed, unlinks the key, and completes the Deferred;
# load either completes the Deferred with its value first or observes ExpiredError first.
def outcome(load_wins):
    entry_map = {'k': 'loading'}
    state = 'Loading'
    deferred = None

    state = 'Removed'
    entry_map.pop('k', None)

    if load_wins:
        deferred = 'value'
        if state == 'Removed' and 'k' not in entry_map:
            entry_map['k'] = 'value'
    else:
        deferred = 'ExpiredError'
        # race1 returns the Deferred result and does not enter tryPutNewValue

    return deferred, entry_map.get('k')

for winner in (True, False):
    print(f'{"load" if winner else "eviction"} wins Deferred: {outcome(winner)}')
PY

Repository: evolution-gaming/scache

Length of output: 37286


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- race1 cancellation and result precedence ---'
sed -n '1258,1315p' scache/src/main/scala/com/evolution/scache/LoadingCache.scala

echo '--- exact eviction ordering ---'
sed -n '99,129p' scache/src/main/scala/com/evolution/scache/ExpiringCache.scala

echo '--- exact post-eviction publication comments ---'
sed -n '40,66p' scache/src/main/scala/com/evolution/scache/LoadingCache.scala
sed -n '533,540p' scache/src/main/scala/com/evolution/scache/ExpiringCache.scala

Repository: evolution-gaming/scache

Length of output: 5659


Prevent an evicted load from winning the publication race

evictLoading marks the entry Removed and unlinks the key before completing the deferred with ExpiredError. The load can complete the deferred with its value in this gap, observe Removed, run tryPutNewValue, and repopulate the key. Complete the deferred before publishing Removed, or add a tombstone that prevents publication after eviction.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scache/src/main/scala/com/evolution/scache/ExpiringCache.scala` around lines
104 - 129, Update evictLoading so the loading deferred is completed with
ExpiredError before the entry is marked Removed and unlinked, preventing a
concurrent load from publishing a value after eviction. Preserve the existing
deferred-identity check and no-op behavior when the entry is no longer the
matching load.

Comment on lines +676 to +701
case (state: EntryState.Loading[F, V], set) =>
state
.deferred
.complete(entry.asRight)
.flatMap {
// We successfully completed the deferred, now trying to set the value.
case true =>
set(EntryState.Value(entry)).flatMap {
// We successfully replaced the entry with our value, so we are done.
case true =>
set(EntryState.Value(entry)).flatMap {
// We successfully replaced the entry with our value, so we are done.
case true =>
none[V]
.pure[F]
.asRight[Int]
.pure[F]
// Another fiber placed their new value before us
// so we just release our value and exit.
case false =>
entry
.release
.traverse { _.start } // Start releasing and forget
.as {
none[V]
.pure[F]
.asRight[Int]
}
}
// Someone just completed the deferred we saw
none[V]
.pure[F]
.asRight[Unit]
.pure[F]

// Another fiber placed their new value before us
// so we just release our value and exit.
case false =>
entry
.release
.traverse { _.start } // Start releasing and forget
.as {
none[V]
.pure[F]
.asRight[Int]
}
releaseAndExit
}

// The key was just removed from the map, so just release the value and exit.
case (EntryState.Removed, _) =>
entry
.release
.traverse { _.start } // Start releasing and forget
.as {
none[V]
.pure[F]
.asRight[Int]
}
}
.uncancelable
// Someone just completed the deferred we saw
// so we just release our value and exit.
case false =>
releaseAndExit
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Inspect every site that completes a Loading deferred and then commits the entry state.
set -euo pipefail

fd -t f 'LoadingCache.scala' --exec rg -n -C 15 '\.complete\(entry\.asRight\)' {}

Repository: evolution-gaming/scache

Length of output: 5206


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- LoadingCache.scala structure ---'
ast-grep outline scache/src/main/scala/com/evolution/scache/LoadingCache.scala

printf '%s\n' '--- put implementation around both completion sites ---'
sed -n '300,390p' scache/src/main/scala/com/evolution/scache/LoadingCache.scala
sed -n '620,715p' scache/src/main/scala/com/evolution/scache/LoadingCache.scala

printf '%s\n' '--- modify implementation around the completion site ---'
sed -n '770,870p' scache/src/main/scala/com/evolution/scache/LoadingCache.scala

printf '%s\n' '--- EntryState, entry construction, and release helpers ---'
rg -n -C 8 'sealed|EntryState|def entryOf|releaseAndExit|setRef|set\(' scache/src/main/scala/com/evolution/scache/LoadingCache.scala

Repository: evolution-gaming/scache

Length of output: 43114


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- Relevant source ranges ---'
sed -n '300,390p' scache/src/main/scala/com/evolution/scache/LoadingCache.scala
sed -n '620,715p' scache/src/main/scala/com/evolution/scache/LoadingCache.scala
sed -n '770,870p' scache/src/main/scala/com/evolution/scache/LoadingCache.scala

printf '%s\n' '--- Definitions and all related call sites ---'
rg -n -C 10 'EntryState|entryOf|releaseAndExit|setRef|complete\(entry\.asRight\)' scache/src/main/scala/com/evolution/scache/LoadingCache.scala

Repository: evolution-gaming/scache

Length of output: 46332


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
p = Path("scache/src/main/scala/com/evolution/scache/LoadingCache.scala")
lines = p.read_text().splitlines()
for lo, hi in [(250, 390), (560, 715), (770, 870)]:
    print(f"--- {p}:{lo}-{hi} ---")
    for n in range(lo, min(hi, len(lines)) + 1):
        print(f"{n:4}: {lines[n-1]}")
PY

printf '%s\n' '--- EntryState and release definitions in the repository ---'
rg -n -C 12 'sealed (trait|abstract class) EntryState|object EntryState|case class (Loading|Value|Removed)|def release1|def releaseAndReturnValue|def entryOf' scache/src/main/scala scache/src/test

Repository: evolution-gaming/scache

Length of output: 34522


🌐 Web query:

evolution-gaming/scache LoadingCache.scala EntryState deferred complete entry.asRight

💡 Result:

The code fragment entry.deferred.complete(value.asRight) in evolution-gaming/scache is part of the library's internal mechanism for managing asynchronous value loading [1][2]. In the context of the scache library—a Scala library built on top of cats-effect for concurrent caching—the LoadingCache component handles cache misses by triggering an asynchronous load operation [3]. To prevent redundant concurrent loads for the same key, the library uses a coordination pattern involving a Deferred (from cats-effect) [1]. 1. Deferred State: When a value is requested and not yet present, the cache creates an Entry.Loading state containing a Deferred [1]. This Deferred acts as a synchronization point for any subsequent requests for the same key. 2. Completing the Deferred: When the actual loading process succeeds, the result (a loaded entry) is wrapped as a Right (via.asRight) and used to complete the Deferred [1][4]. This unblocks any concurrent requests waiting on the same key, allowing them to receive the loaded value. 3. Handling Errors: Conversely, if the loading process fails, the error is typically lifted into the Left side of an Either (via.asLeft) and used to complete the Deferred [1], signaling to waiting callers that the load operation failed. This pattern ensures that only one load operation executes for a given key, while others efficiently await the single result [3]. The.asRight syntax is standard cats/cats-effect usage to create an Either type, specifically signaling the success case of the asynchronous operation.

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import urllib.request
url = "https://raw.githubusercontent.com/evolution-gaming/scache/master/scache/src/main/scala/com/evolution/scache/LoadingCache.scala"
try:
    data = urllib.request.urlopen(url, timeout=10).read().decode()
except Exception as e:
    print(type(e).__name__, e)
else:
    lines = data.splitlines()
    for lo, hi in [(300, 390), (620, 715), (770, 870)]:
        print(f"--- remote source {lo}-{hi} ---")
        for n in range(lo, min(hi, len(lines)) + 1):
            print(f"{n:4}: {lines[n-1]}")
PY

Repository: evolution-gaming/scache

Length of output: 305


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from dataclasses import dataclass

`@dataclass`
class Entry:
    released: bool = False

class Model:
    def __init__(self):
        self.state = "Loading"
        self.deferred = None
        self.waiter_entry = None

    def complete(self, entry):
        if self.deferred is not None:
            return False
        self.deferred = entry
        self.waiter_entry = entry
        return True

    def remove(self):
        self.state = "Removed"

    def set_value(self, entry):
        if self.state != "Loading":
            return False
        self.state = "Value"
        return True

    def release(self, entry):
        entry.released = True

# put: publication succeeds, removal wins before the state commit.
m = Model()
put_entry = Entry()
assert m.complete(put_entry) is True
m.remove()
assert m.set_value(put_entry) is False
m.release(put_entry)
assert m.waiter_entry is put_entry and m.waiter_entry.released
print("put: waiter receives the published entry after its release starts")

# modify: publication succeeds, removal wins before the state commit,
# and the retry path does not release the published entry.
m = Model()
modify_entry = Entry()
assert m.complete(modify_entry) is True
m.remove()
assert m.set_value(modify_entry) is False
assert m.waiter_entry is modify_entry and not m.waiter_entry.released
print("modify: retry leaves the published entry unreleased")
PY

Repository: evolution-gaming/scache

Length of output: 279


Make deferred publication and EntryState transition share entry ownership. At LoadingCache.scala:676-701 and 825-850, a competing Removed transition can make set/setRef fail after complete publishes entry. put then releases an entry that waiters still hold, while modify retries without releasing it. Ensure every published entry has exactly one owner on both commit-success and commit-failure paths.

📍 Affects 1 file
  • scache/src/main/scala/com/evolution/scache/LoadingCache.scala#L676-L701 (this comment)
  • scache/src/main/scala/com/evolution/scache/LoadingCache.scala#L825-L850
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scache/src/main/scala/com/evolution/scache/LoadingCache.scala` around lines
676 - 701, Update the LoadingCache put path at LoadingCache.scala:676-701 and
the corresponding modify path at LoadingCache.scala:825-850 so deferred
publication and the EntryState transition use distinct, balanced ownership.
Retain or transfer ownership when complete publishes entry, release the
producer’s ownership exactly once when set/setRef fails after publication,
preserve the waiter-owned reference, and release the unpublished entry when
complete fails before retrying or exiting.

Comment on lines +211 to +240
test("a new load generation does not inherit the previous generation's stuck-timer") {
val config = ExpiringCache.Config[IO, Int, Int](
expireAfterRead = 1.minute,
loadingTimeout = 200.millis.some,
)
val io = ExpiringCache.of[IO, Int, Int](config).use { cache =>
for {
started1 <- Deferred[IO, Unit]
gate1 <- Deferred[IO, Unit]
loader1 <- cache.getOrUpdate(0) { started1.complete(()) *> gate1.get.as(1) }.start
_ <- started1.get
_ <- IO.sleep(150.millis)
_ <- gate1.complete(())
_ <- loader1.join
_ <- cache.remove(0).flatten
started2 <- Deferred[IO, Unit]
gate2 <- Deferred[IO, Unit]
loader2 <- cache.getOrUpdate(0) { started2.complete(()) *> gate2.get.as(2) }.start
_ <- started2.get
result <- {
for {
_ <- IO.sleep(150.millis)
present <- cache.contains(0)
_ = present shouldEqual true
} yield ()
}.guarantee { gate2.complete(()) *> loader2.join.void }
} yield result
}
io.run()
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Increase the timing margin of the generation test.

The test sets loadingTimeout = 200.millis, sleeps 150 ms after the second load starts, and then asserts that key 0 is still present. The cleanup interval for this config is max(min(60000/10, 200/2), 10) = 100 ms, so two cleanup runs can occur inside the 150 ms window. On a loaded CI machine the second load can be evicted and the assertion fails.

Raise loadingTimeout relative to the sleep, for example a 2 s timeout with a 200 ms sleep, so that the margin does not depend on scheduler jitter.

💚 Proposed fix
     val config = ExpiringCache.Config[IO, Int, Int](
       expireAfterRead = 1.minute,
-      loadingTimeout = 200.millis.some,
+      loadingTimeout = 2.seconds.some,
     )

and

-        _ <- IO.sleep(150.millis)
+        _ <- IO.sleep(300.millis)
         _ <- gate1.complete(())

with the second sleep inside the result block adjusted to stay well below the new timeout.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
test("a new load generation does not inherit the previous generation's stuck-timer") {
val config = ExpiringCache.Config[IO, Int, Int](
expireAfterRead = 1.minute,
loadingTimeout = 200.millis.some,
)
val io = ExpiringCache.of[IO, Int, Int](config).use { cache =>
for {
started1 <- Deferred[IO, Unit]
gate1 <- Deferred[IO, Unit]
loader1 <- cache.getOrUpdate(0) { started1.complete(()) *> gate1.get.as(1) }.start
_ <- started1.get
_ <- IO.sleep(150.millis)
_ <- gate1.complete(())
_ <- loader1.join
_ <- cache.remove(0).flatten
started2 <- Deferred[IO, Unit]
gate2 <- Deferred[IO, Unit]
loader2 <- cache.getOrUpdate(0) { started2.complete(()) *> gate2.get.as(2) }.start
_ <- started2.get
result <- {
for {
_ <- IO.sleep(150.millis)
present <- cache.contains(0)
_ = present shouldEqual true
} yield ()
}.guarantee { gate2.complete(()) *> loader2.join.void }
} yield result
}
io.run()
}
test("a new load generation does not inherit the previous generation's stuck-timer") {
val config = ExpiringCache.Config[IO, Int, Int](
expireAfterRead = 1.minute,
loadingTimeout = 2.seconds.some,
)
val io = ExpiringCache.of[IO, Int, Int](config).use { cache =>
for {
started1 <- Deferred[IO, Unit]
gate1 <- Deferred[IO, Unit]
loader1 <- cache.getOrUpdate(0) { started1.complete(()) *> gate1.get.as(1) }.start
_ <- started1.get
_ <- IO.sleep(300.millis)
_ <- gate1.complete(())
_ <- loader1.join
_ <- cache.remove(0).flatten
started2 <- Deferred[IO, Unit]
gate2 <- Deferred[IO, Unit]
loader2 <- cache.getOrUpdate(0) { started2.complete(()) *> gate2.get.as(2) }.start
_ <- started2.get
result <- {
for {
_ <- IO.sleep(150.millis)
present <- cache.contains(0)
_ = present shouldEqual true
} yield ()
}.guarantee { gate2.complete(()) *> loader2.join.void }
} yield result
}
io.run()
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scache/src/test/scala/com/evolution/scache/CacheDefectsSpec.scala` around
lines 211 - 240, Increase the loadingTimeout in the “a new load generation does
not inherit the previous generation's stuck-timer” test and adjust the second
sleep in the result block to remain well below the new timeout, preserving the
presence assertion while providing sufficient scheduler-timing margin.

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.

2 participants