Skip to content

Deflake pkg/trace/writer by isolating tests from each other - #55614

Draft
ajgajg1134 wants to merge 1 commit into
mainfrom
andrew.glaude/moreflakesmorefurious
Draft

Deflake pkg/trace/writer by isolating tests from each other#55614
ajgajg1134 wants to merge 1 commit into
mainfrom
andrew.glaude/moreflakesmorefurious

Conversation

@ajgajg1134

Copy link
Copy Markdown
Contributor

What does this PR do?

Isolates the pkg/trace/writer tests from each other, and adds the diagnostics needed to identify the culprit if the flake recurs. Also fixes two sender shutdown bugs found while reading that path.

Test isolation

  • cleanupWriter gives every stats test ownership of its writer and its test server, stopping senders before closing the server. Both sender.Stop and testServer.Close are now idempotent so a test may still tear down explicitly.
  • Stops the five trace writers that were never stopped (TestTraceWriterFlushSync/ok, TestResetBuffer, TestTraceWriterSyncNoop/ok, TestTraceWriterV1FlushSync/ok, TestTraceWriterV1ResetBuffer) and closes the test server in TestTraceWriterMultipleEndpointsConcurrent, the only listener in trace_test.go left bound.
  • TestMain now fails an otherwise-green run that leaks sender goroutines. This is what found the five leaks above.

Diagnostics

  • The test server records each accepted request with its remote address, listener URL, headers and body prefix, and flags a body carrying the expectResponses marker — the one non-gzip body the package produces.
  • assertPayload, payloadsContain and payloadsContainV1 assert how many requests arrived before decoding any, and print that provenance on failure. Two call sites whose counts are genuinely nondeterministic opt out via a documented -1 sentinel.
  • The server no longer panics when a request body read fails; it records the error and aborts the connection with http.ErrAbortHandler.

Sender fixes (the only non-test changes)

  • Push counted a payload as inflight after queuing it, so FlushSync and Stop could observe zero while a payload was already queued and return before it was sent.
  • Stop is idempotent and now waits for its worker goroutines, bounded by the same five second budget the inflight wait uses so a worker asleep in backoff cannot stall agent shutdown.

Motivation

TestStatsSyncWriter/ok failed on main (job 1995053897) with gzip: invalid header, alongside seven use of closed network connection panics from the test server. The same signature hit TestTraceWriterMultipleEndpointsConcurrent earlier (job 1977041561); #55406 only removed a follow-on nil deref, so the cause was never found. Earlier attempts (#44601, #42036) did not touch it either.

gzip: invalid header means the first bytes were not 1f 8b, so the recorded body was never a writer payload at all. The package's tests were not isolated: newSender starts ConnectionLimit worker goroutines immediately, so a forgotten writer left workers, an HTTP client and a bound listener alive for every later test to interact with — and a closed listener's ephemeral port can be handed to a later test's server, at which point one test's requests land on another test's server and get recorded as its payloads. The only non-gzip bodies in the package are the plain-text ones expectResponses builds.

Describe how you validated your changes

  • dda inv test --targets=./pkg/trace/writer and --race — 84 tests pass, no leaked goroutines
  • dda inv test --targets=./pkg/trace/... and --race — 1721 tests pass
  • 20 consecutive full-package runs and 10 with --race — all green
  • dda inv linter.go --targets=./pkg/trace/writer — 0 issues; gofmt clean; gazelle produced no BUILD changes
  • The new ErrAbortHandler behaviour is covered by TestTestServer/truncated-body, verified to fail if that change is reverted (it reads back HTTP/1.1 400 Bad Request). An earlier version of that test passed against both behaviours and was rewritten.

Additional Notes

Two points worth a reviewer's judgement rather than treating as settled:

  1. The ReadErrors() tripwire changes a failure mode. A straggler request that previously passed vacuously — assertPayload never checked the payload count — now fails the test. That is intentional, but on a loaded runner it could make TestStatsSyncWriter fail more often, just with a message naming the cause instead of gzip: invalid header.
  2. The macOS skip on TestStatsSyncWriter is left in place. Removing it would be the real test of this fix; I did not want to gamble on macOS CI in the same PR.

TestStatsSyncWriter/stop previously asserted nothing: in sync mode Write only buffers and Stop does not flush, so zero payloads arrive, and the old assertPayload passed vacuously. It now asserts that behaviour explicitly. Whether Stop should flush pending sync-mode payloads is a separate question I have not answered here.

Deliberately out of scope:

  • Stop can still close(s.queue) under a producer blocked on a full queue once the five second wait expires (sender.go). Pre-existing; this PR makes it strictly less likely by counting inflight before the blocking send, but does not remove it. Wants its own fix.
  • The leak guard watches sender goroutines, not unclosed httptest listeners — the listener is the actual port-reuse hazard. Fixed the one instance by hand; nothing prevents the next.
  • waitTimeout leaks its helper goroutine if the wait group never completes.

🤖 Generated with Claude Code

TestStatsSyncWriter/ok failed on main with `gzip: invalid header`, alongside
seven `use of closed network connection` panics from the test server. The same
signature hit TestTraceWriterMultipleEndpointsConcurrent earlier; the fix then
(#55406) only removed a follow-on nil deref, so the cause was never found.

A body that is not valid gzip means the first bytes were not 1f 8b, so it was
never a writer payload at all. The package's tests were not isolated: several
created writers whose senders were never stopped, and test servers that were
never closed. newSender starts ConnectionLimit worker goroutines immediately,
so a forgotten writer left workers, an HTTP client and a bound listener alive
for every later test to interact with, and a closed listener's ephemeral port
can be handed to a later test's server.

Test isolation:
- cleanupWriter gives every stats test ownership of its writer and server,
  stopping senders before closing the server.
- Stop the five trace writers that were never stopped, and close the test
  server in TestTraceWriterMultipleEndpointsConcurrent, which was the only
  listener in trace_test.go left bound.
- TestMain fails an otherwise-green run that leaks sender goroutines. This is
  what found the five leaks above.

Diagnostics, so the next occurrence names its own cause:
- The test server records each accepted request with its remote address,
  listener, headers and body prefix, and flags a body carrying the
  expectResponses marker, which is the one non-gzip body in the package.
- assertPayload, payloadsContain and payloadsContainV1 assert how many
  requests arrived before decoding any, and print that provenance on failure.
  Two call sites whose counts are genuinely nondeterministic opt out with a
  documented sentinel.
- The server no longer panics when a request body read fails. That is ordinary
  when a server closes with a request in flight, and a suspected panic makes
  the CI harness skip the rerun pass. It now records the error and aborts the
  connection with http.ErrAbortHandler, preserving the old transport-error
  semantics for the sender without the stack trace.

Sender fixes found while reading the shutdown path:
- Push counted a payload as inflight after queuing it, so FlushSync and Stop
  could observe zero while a payload was already queued and return before it
  was sent.
- Stop is idempotent and now waits for its worker goroutines, bounded by the
  same five second budget the inflight wait uses so a worker asleep in backoff
  cannot stall agent shutdown.

Validation: pkg/trace/writer and pkg/trace/... pass with and without -race;
20 consecutive full-package runs and 10 with -race are green; linter.go
reports no issues. The new ErrAbortHandler behaviour is covered by
TestTestServer/truncated-body, verified to fail if that change is reverted.

Not addressed here: Stop can still close the queue under a producer blocked on
a full queue once the five second wait expires, which predates this change and
wants its own fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@dd-octo-sts dd-octo-sts Bot added internal Identify a non-fork PR team/agent-apm trace-agent labels Aug 28, 2026
@github-actions github-actions Bot added the medium review PR review might take time label Aug 28, 2026

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

AI review by Codex (OpenAI) - workflow run

Patch is incorrect: shutdown can now block for roughly 15 seconds despite documenting a five-second budget, and the release note claims behavior the code does not implement.

Comment on lines 293 to +306
func (s *sender) Stop() {
s.WaitForInflight()
s.mu.Lock()
s.closed = true
s.mu.Unlock()
close(s.queue)
s.stopOnce.Do(func() {
// Wait once before closing, so that payloads already inflight keep their
// full retry budget.
s.WaitForInflight()
s.mu.Lock()
s.closed = true
s.mu.Unlock()
// Push increments inflight while holding a read lock, so any Push that
// observed !closed has already been counted by the time the write lock
// above is acquired. Waiting again here therefore cannot miss a payload
// that raced with the close, and guarantees the channel is only closed
// once no producer can still be sending on it.
s.WaitForInflight()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Each of the two WaitForInflight calls gets its own five-second timer, and waitTimeout below gets a third. If a request remains stuck, Stop can therefore take about 15 seconds, regressing the previous five-second shutdown bound. Use one shared deadline/budget across all three waits.

Comment on lines +11 to +13
closed, and is safe to call more than once. Payloads handed to a sender
that is already stopped are returned to the payload pool instead of being
dropped without being recycled.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This recycling claim is not implemented: Push still returns immediately when s.closed is true without returning p to its pool. Either implement the stated behavior or remove it from the release note.

Comment on lines +163 to +164
conn, err := net.Dial("tcp", strings.TrimPrefix(ts.URL, "http://"))
assert.NoError(err)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

If net.Dial fails, assert.NoError continues and the following conn.Close()/type assertion dereferences a nil connection, hiding the actual failure. Use require.NoError before accessing conn.

@datadog-official

datadog-official Bot commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

🎯 Code Coverage (details)
Patch Coverage: 91.67%
Overall Coverage: 52.74% (+0.02%)

This comment will be updated automatically if new data arrives.
🔗 Commit SHA: 2fc896c | Docs | View more details | Give us feedback!

@dd-octo-sts

dd-octo-sts Bot commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Files inventory check summary

File checks results against ancestor ac3e2c30:

Results for datadog-agent_7.84.0~devel.git.582.2fc896c.pipeline.134034001-1_amd64.deb:

No change detected

Results for datadog-iot-agent_7.84.0~devel.git.582.2fc896c.pipeline.134034001-1_amd64.deb:

No change detected

@dd-octo-sts

dd-octo-sts Bot commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Static quality checks

✅ Please find below the results from static quality gates
Comparison made with ancestor ac3e2c3
📊 Static Quality Gates Dashboard
🔗 SQG Job

Successful checks

Info

Quality gate Change Size (prev → curr → max)
agent_rpm_arm64 +4.0 KiB (0.00% increase, -0.54% of buffer) 737.583 → 737.587 → 738.310
agent_suse_arm64 +4.0 KiB (0.00% increase, -0.54% of buffer) 737.583 → 737.587 → 738.310
docker_agent_arm64 +4.0 KiB (0.00% increase, -1.19% of buffer) 821.652 → 821.655 → 821.980
docker_agent_jmx_arm64 +4.0 KiB (0.00% increase, -1.16% of buffer) 1001.344 → 1001.348 → 1001.680
28 successful checks with minimal change (< 2 KiB)
Quality gate Current Size
agent_deb_amd64 761.710 MiB
agent_deb_amd64_fips 713.971 MiB
agent_heroku_amd64 312.986 MiB
agent_rpm_amd64 761.694 MiB
agent_rpm_amd64_fips 713.955 MiB
agent_rpm_arm64_fips 693.182 MiB
agent_suse_amd64 761.694 MiB
agent_suse_amd64_fips 713.955 MiB
agent_suse_arm64_fips 693.182 MiB
docker_agent_amd64 820.726 MiB
docker_agent_jmx_amd64 1011.638 MiB
docker_cluster_agent_amd64 211.261 MiB
docker_cluster_agent_arm64 224.398 MiB
docker_cws_instrumentation_amd64 7.439 MiB
docker_cws_instrumentation_arm64 6.877 MiB
docker_dogstatsd_amd64 39.553 MiB
docker_dogstatsd_arm64 37.623 MiB
docker_host_profiler_amd64 306.873 MiB
docker_host_profiler_arm64 318.169 MiB
dogstatsd_deb_amd64 30.290 MiB
dogstatsd_deb_arm64 28.302 MiB
dogstatsd_rpm_amd64 30.290 MiB
dogstatsd_suse_amd64 30.290 MiB
iot_agent_deb_amd64 46.568 MiB
iot_agent_deb_arm64 43.208 MiB
iot_agent_deb_armhf 44.015 MiB
iot_agent_rpm_amd64 46.568 MiB
iot_agent_suse_amd64 46.567 MiB

@cit-pr-commenter-54b7da

Copy link
Copy Markdown

Regression Detector

Regression Detector Results

Metrics dashboard
Target profiles
Job ID: 6ae55244-0a4a-446c-9321-0f2584e01d53

Baseline: ac3e2c3
Comparison: 2fc896c
Diff

Optimization Goals: ✅ No significant changes detected

Fine details of change detection per experiment

perf experiment goal Δ mean % Δ mean % CI trials links
quality_gate_security_idle memory utilization +0.50 [+0.45, +0.55] 1 Logs bounds checks dashboard
quality_gate_idle memory utilization +0.08 [+0.03, +0.12] 1 Logs bounds checks dashboard
dsd_uds_10mb_3k_timestamped_contexts_memory memory utilization +0.03 [-0.19, +0.24] 1 Logs
quality_gate_idle_all_features memory utilization +0.00 [-0.04, +0.04] 1 Logs bounds checks dashboard
quality_gate_private_action_runner memory utilization -0.04 [-0.16, +0.09] 1 Logs bounds checks dashboard
quality_gate_security_no_fs_load memory utilization -0.05 [-0.13, +0.04] 1 Logs bounds checks dashboard
quality_gate_security_mean_fs_load memory utilization -0.16 [-0.20, -0.13] 1 Logs bounds checks dashboard
quality_gate_logs % cpu utilization -0.17 [-1.03, +0.69] 1 Logs bounds checks dashboard
quality_gate_metrics_logs memory utilization -0.85 [-1.08, -0.62] 1 Logs bounds checks dashboard
dsd_uds_10mb_3k_timestamped_contexts_cpu % cpu utilization -1.72 [-1.97, -1.46] 1 Logs

Bounds Checks: ✅ Passed

perf experiment bounds_check_name replicates_passed observed_value links
quality_gate_idle intake_connections 10/10 4 = 4 bounds checks dashboard
quality_gate_idle memory_usage 10/10 176.15MiB ≤ 179MiB bounds checks dashboard
quality_gate_idle total_bytes_received 10/10 748.78KiB ≤ 819.20KiB bounds checks dashboard
quality_gate_idle_all_features intake_connections 10/10 4 = 4 bounds checks dashboard
quality_gate_idle_all_features memory_usage 10/10 519.87MiB ≤ 537MiB bounds checks dashboard
quality_gate_idle_all_features total_bytes_received 10/10 1.14MiB ≤ 1.25MiB bounds checks dashboard
quality_gate_logs intake_connections 10/10 20 ≤ 40 bounds checks dashboard
quality_gate_logs memory_usage 10/10 214.24MiB ≤ 220MiB bounds checks dashboard
quality_gate_logs missed_bytes 10/10 0B = 0B bounds checks dashboard
quality_gate_logs total_bytes_received 10/10 263.10MiB ≤ 292MiB bounds checks dashboard
quality_gate_metrics_logs cpu_usage 10/10 373.67 ≤ 2000 bounds checks dashboard
quality_gate_metrics_logs intake_connections 10/10 17 ≤ 40 bounds checks dashboard
quality_gate_metrics_logs memory_usage 10/10 419.03MiB ≤ 455MiB bounds checks dashboard
quality_gate_metrics_logs missed_bytes 10/10 0B = 0B bounds checks dashboard
quality_gate_metrics_logs total_bytes_received 10/10 0.94GiB ≤ 1.04GiB bounds checks dashboard
quality_gate_private_action_runner memory_usage 10/10 72.95MiB ≤ 75MiB bounds checks dashboard
quality_gate_security_idle cpu_usage 10/10 29.42 ≤ 100 bounds checks dashboard
quality_gate_security_idle memory_usage 10/10 326.63MiB ≤ 355MiB bounds checks dashboard
quality_gate_security_mean_fs_load cpu_usage 10/10 73.66 ≤ 200 bounds checks dashboard
quality_gate_security_mean_fs_load memory_usage 10/10 305.22MiB ≤ 335MiB bounds checks dashboard
quality_gate_security_no_fs_load cpu_usage 10/10 22.78 ≤ 100 bounds checks dashboard
quality_gate_security_no_fs_load memory_usage 10/10 311.90MiB ≤ 345MiB bounds checks dashboard

Explanation

Confidence level: 90.00%
Effect size tolerance: |Δ mean %| ≥ 5.00%

Performance changes are noted in the perf column of each table:

  • ✅ = significantly better comparison variant performance
  • ❌ = significantly worse comparison variant performance
  • ➖ = no significant change in performance

A regression test is an A/B test of target performance in a repeatable rig, where "performance" is measured as "comparison variant minus baseline variant" for an optimization goal (e.g., ingress throughput). Due to intrinsic variability in measuring that goal, we can only estimate its mean value for each experiment; we report uncertainty in that value as a 90.00% confidence interval denoted "Δ mean % CI".

For each experiment, we decide whether a change in performance is a "regression" -- a change worth investigating further -- if all of the following criteria are true:

  1. Its estimated |Δ mean %| ≥ 5.00%, indicating the change is big enough to merit a closer look.

  2. Its 90.00% confidence interval "Δ mean % CI" does not contain zero, indicating that if our statistical model is accurate, there is at least a 90.00% chance there is a difference in performance between baseline and comparison variants.

  3. Its configuration does not mark it "erratic".

CI Pass/Fail Decision

Passed. All Quality Gates passed.

  • quality_gate_idle_all_features, bounds check total_bytes_received: 10/10 replicas passed. Gate passed.
  • quality_gate_idle_all_features, bounds check intake_connections: 10/10 replicas passed. Gate passed.
  • quality_gate_idle_all_features, bounds check memory_usage: 10/10 replicas passed. Gate passed.
  • quality_gate_security_idle, bounds check memory_usage: 10/10 replicas passed. Gate passed.
  • quality_gate_security_idle, bounds check cpu_usage: 10/10 replicas passed. Gate passed.
  • quality_gate_security_no_fs_load, bounds check memory_usage: 10/10 replicas passed. Gate passed.
  • quality_gate_security_no_fs_load, bounds check cpu_usage: 10/10 replicas passed. Gate passed.
  • quality_gate_metrics_logs, bounds check memory_usage: 10/10 replicas passed. Gate passed.
  • quality_gate_metrics_logs, bounds check intake_connections: 10/10 replicas passed. Gate passed.
  • quality_gate_metrics_logs, bounds check cpu_usage: 10/10 replicas passed. Gate passed.
  • quality_gate_metrics_logs, bounds check missed_bytes: 10/10 replicas passed. Gate passed.
  • quality_gate_metrics_logs, bounds check total_bytes_received: 10/10 replicas passed. Gate passed.
  • quality_gate_idle, bounds check total_bytes_received: 10/10 replicas passed. Gate passed.
  • quality_gate_idle, bounds check memory_usage: 10/10 replicas passed. Gate passed.
  • quality_gate_idle, bounds check intake_connections: 10/10 replicas passed. Gate passed.
  • quality_gate_logs, bounds check intake_connections: 10/10 replicas passed. Gate passed.
  • quality_gate_logs, bounds check missed_bytes: 10/10 replicas passed. Gate passed.
  • quality_gate_logs, bounds check memory_usage: 10/10 replicas passed. Gate passed.
  • quality_gate_logs, bounds check total_bytes_received: 10/10 replicas passed. Gate passed.
  • quality_gate_private_action_runner, bounds check memory_usage: 10/10 replicas passed. Gate passed.
  • quality_gate_security_mean_fs_load, bounds check memory_usage: 10/10 replicas passed. Gate passed.
  • quality_gate_security_mean_fs_load, bounds check cpu_usage: 10/10 replicas passed. Gate passed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

internal Identify a non-fork PR medium review PR review might take time team/agent-apm trace-agent

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant