He-Pin opened a new issue, #3696:
URL: https://github.com/apache/pekko/issues/3696
## 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`:
```scala
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-23` defines
`timing`, `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:41` describes the
`failed` label 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`:
```scala
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:
```scala
* 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`:
```scala
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:
```scala
override def expectMessage[T <: M](max: FiniteDuration, obj: T): T =
expectMessage_internal(max.dilated, obj)
```
while `:210-211` does not:
```scala
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`:
```scala
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`:
```scala
/**
* 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:
1. **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 `failed` label would keep it
honest, and it gives the tag-exclusion mechanism something finer-grained than a
whole category to point at.
2. **A gate on newly-introduced non-deterministic patterns.** A CI check
that fails when a PR *adds* `Thread.sleep`, `pendingUntilFixed`, or a
`GHExcludeTest` tag 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 over `rg` output before it
becomes an sbt task or a scalafix rule.
3. **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-`.dilated` duration 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.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]