Test determinism: AGENTS.md rules have no enforcement, no in-repo known-flaky list, and the time-dilation contract is inconsistent across three APIs
Maintainers usually reply within 1 day
Nobody has claimed this yet.
Assessment
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Newbie friendliness
- 45/100
- Issue type
- Feature
- Clarity
- Clearly specified
- Activity status
- Active
- Tech stack
- github-actions, scala
- Domain
- ci-cd, documentation, testing
Research direction
Read AGENTS.md and CONTRIBUTING.md for current test rules. For part 3, inspect testkit/src/main/scala/org/apache/pekko/testkit/TestKit.scala:456,722 and actor-testkit-typed/.../TestProbeImpl.scala:161,210 to document dilation. Done: each affected method's Scaladoc states if supplied duration is dilated, cross-linked, with optional lint for double-dilation.
Written by the indexing model from the issue text.
Description
Summary
AGENTS.md states clear test-determinism rules, but nothing in the build enforces any of them, and known-flaky tests are tracked only as GitHub issues. The result is measurable: 341 Thread.sleep calls in Scala sources, against a rule that says "Avoid Thread.sleep".
A second, related problem is that the testkit's time-dilation contract is inconsistent across three APIs and is documented only in Scaladoc prose, which makes dilation mistakes an easy and recurring source of flakes.
The enforcement gap
AGENTS.md, "Test Rules":
- Prefer deterministic tests over larger timeouts.
- Avoid `Thread.sleep`.
- Prefer probes, latches, stepped components, `within`, `remaining`, and `remainingOrDefault`.
- Keep `expectNoMessage` short and intentional.
- Do not weaken assertions to hide production behavior.
- Do not fix flakes only by increasing timeouts.
The only style gate in CI is build.sbt:44:
addCommandAlias("checkCodeStyle", "scalafmtCheckAll; scalafmtSbtCheck; javafmtCheckAll; +headerCheckAll")
Formatting and license headers — nothing inspects test sources for non-deterministic patterns. Meanwhile:
rg -c 'Thread\.sleep' --type scala | awk -F: '{s+=$2} END {print s}'
341
Concentration (occurrences per file):
stream-tests/.../scaladsl/HubSpec.scala 18
docs/src/test/scala/docs/stream/cookbook/RecipeAdhocSource.scala 13
cluster/.../sbr/SplitBrainResolverSpec.scala 11
actor-tests/.../util/BoundedBlockingQueueSpec.scala 11
actor-tests/.../pattern/CircuitBreakerSpec.scala 11
actor-tests/.../actor/SchedulerSpec.scala 11
stream-tests/.../scaladsl/RestartSpec.scala 9
stream-tests/.../impl/TimeoutsSpec.scala 8
pendingUntilFixed appears 3 times.
How flakiness is handled today
- Retry, but still fail —
project/PekkoDevelocityPlugin.scala:65-66:.withMaxRetries(1)and.withFlakyTestPolicy(FlakyTestPolicy.Fail), commented "preserve the original build outcome in case of flaky tests". Honest, but it does not isolate anything. - Exclusion by category, not by test —
testkit/src/test/scala/org/apache/pekko/testkit/TestTags.scala:18-23definestiming,long-running,performance,gh-exclude,gh-exclude-aeron, and CI passes-Dpekko.test.tags.exclude=gh-exclude,timing. A single flaky test in a category cannot be excluded without excluding the whole category. - Issues, closed by elapsed time —
CONTRIBUTING.md:41describes thefailedlabel and states: "When the same problem isn't seen again over a period of 6 months we assume it to be a rare flaky test or a problem that might have since been fixed, so we close the issue until it pops up again." There is no in-repo list, so nothing connects a known-flaky test to the code that is allowed to exclude or retry it.
The dilation contract is inconsistent
Three public testkit APIs treat a caller-supplied duration differently, and the difference is only discoverable by reading Scaladoc or source.
TestKit.expectMsg(max, obj) dilates — testkit/src/main/scala/org/apache/pekko/testkit/TestKit.scala:456:
def expectMsg[T](max: FiniteDuration, obj: T): T = expectMsg_internal(max.dilated, obj)
TestKit.expectNoMessage(max) does not — :722-724, where the Scaladoc carries the whole contract:
* Supplied value is not dilated.
...
def expectNoMessage(max: FiniteDuration) = {
stream-testkit expectNext(d) does not — stream-testkit/src/main/scala/org/apache/pekko/stream/testkit/StreamTestKit.scala:478-479:
def expectNext(d: FiniteDuration): I = {
val t = probe.remainingOr(d)
The typed testkit splits the same way — actor-testkit-typed/src/main/scala/org/apache/pekko/actor/testkit/typed/internal/TestProbeImpl.scala:161 dilates:
override def expectMessage[T <: M](max: FiniteDuration, obj: T): T = expectMessage_internal(max.dilated, obj)
while :210-211 does not:
override def expectNoMessage(max: FiniteDuration): Unit =
expectNoMessage_internal(max)
Two concrete failure modes follow. A test that passes an already-dilated duration to a dilating method gets dilation applied twice — with the CI setting -Dpekko.test.timefactor=2 (.github/workflows/build-test-prValidation.yml:93-94) a 3s budget silently becomes 12s. A test that passes a raw duration to a non-dilating method gets no relief on a slow runner and flakes only in CI. Neither is visible at the call site.
A related surprise in within
TestKit.scala:432-434 skips the upper-bound assertion when the block's last assertion was expectNoMessage:
if (!lastWasNoMsg) {
assert(diff <= max_diff, s"block took ${format(_max.unit, diff)}, exceeding ${format(_max.unit, max_diff)}")
}
The typed testkit has the same mechanism, and documents the intent — TestProbeImpl.scala:91-95:
/**
* if last assertion was expectNoMessage, disable timing failure upon within()
* block end.
*/
private var lastWasNoMessage = false
This is deliberate (expectNoMessage itself consumes the budget, so the assertion would always trip), but it means within gives no upper-bound guarantee for such a block, and that is not discoverable from within's own Scaladoc.
Proposal
Three parts, independently useful and separately mergeable:
-
An in-repo known-flaky list. One row per test: fully-qualified name, issue link, date first seen, symptom class (timing / ordering / teardown / port / resource), and owner. Deriving it from the
failedlabel would keep it honest, and it gives the tag-exclusion mechanism something finer-grained than a whole category to point at. -
A gate on newly-introduced non-deterministic patterns. A CI check that fails when a PR adds
Thread.sleep,pendingUntilFixed, or aGHExcludeTesttag to test sources, backed by a committed baseline file that exempts the existing 341 occurrences. The baseline makes the existing debt visible and bounded without requiring a big-bang cleanup, and it stops the count from growing. This can start as a shell script overrgoutput before it becomes an sbt task or a scalafix rule. -
Make the dilation contract explicit and checkable. At minimum, state on each affected method whether the supplied duration is dilated, in a consistent wording, and cross-link them. Better: a lint that flags a caller passing an already-
.dilatedduration into a dilating method, which is the double-dilation case and is mechanically detectable.
Part 3 is documentation plus an optional lint and carries no binary-compatibility risk. Parts 1 and 2 are additive CI artifacts. None of the three changes production behaviour.
Note on scope
Changing the dilation behaviour of any of these methods — for example making expectNoMessage dilate for consistency — is deliberately not proposed here. That would be a public API behaviour change affecting many existing tests and would need its own discussion.
- Dominant language
- Scala
- Stars
- 1.7k
- Forks
- 212
- Avg merge
- 20h 44m
- Merged PRs (30d)
- 104
Getting set up
- No Dockerfile or Docker Compose file
- No pull request template
- Read the contributing guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from apache/pekko
-
Difficulty 2/5 1-3 hours Newbie friendliness 85/100
Maintainers usually reply within 1 day
-
documentation
Difficulty 2/5 1-3 hours Newbie friendliness 68/100
apache/pekko#1840 · 8 comments ·
Maintainers usually reply within 1 day
-
Add wildcard bounds to Java DSL parameters that are needlessly invariantPossibly taken A pull request linked to this issue is open or already merged. Open
Difficulty 4/5 3-5 days Newbie friendliness 52/100
apache/pekko#3712 · 5 comments ·
Maintainers usually reply within 1 day
-
Coordinator singleton lease loss permanently stops the local ShardRegion while the node is still UpPossibly taken @He-Pin claimed this 4 days ago. Openbug cluster
Difficulty 4/5 3-5 days Newbie friendliness 25/100
Maintainers usually reply within 1 day
-
build enhancement github_actions
Difficulty 3/5 1-2 days Newbie friendliness 70/100
Maintainers usually reply within 1 day
Similar issues
-
Difficulty 1/5 Under an hour Newbie friendliness 73/100
lichess-org/lila#22298 ·
Maintainers usually reply within 1 day
-
Task 🔧
Difficulty 1/5 Under an hour Newbie friendliness 88/100
valkey-io/valkey-glide#7455 ·
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 72/100
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 82/100
Maintainers usually reply within 1 day
-
master is red: two chapter-title overrides orphaned by the plan update, and both titles are untranslated in compendium-enPossibly taken A pull request linked to this issue is open or already merged. Open
Difficulty 1/5 Under an hour Newbie friendliness 85/100
lunduniversity/introprog#1005 · 1 comment ·
Maintainers usually reply within 7 days