Skip to content

docker fixtures: unique container names, ownership-scoped cleanup, unconditional removal - #7427

Draft
bm1549 wants to merge 1 commit into
mainfrom
brian.marks/parametric-unique-container-name
Draft

docker fixtures: unique container names, ownership-scoped cleanup, unconditional removal#7427
bm1549 wants to merge 1 commit into
mainfrom
brian.marks/parametric-unique-container-name

Conversation

@bm1549

@bm1549 bm1549 commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Motivation

Two parametric tests failed in a dd-trace-php run: one in setup with
409 Conflict. The container name "/php-test-client-<id>" is already in use, the other in
teardown on that same container. Neither is a sampling bug; any two tests in the scenario can
hit it. Three defects combine, and the third is what actually killed the container:

  1. test_id was str(uuid.uuid4())[0:6]. That 24-bit value is the only variable part of every
    container and network name built from it, and those names are shared by all xdist workers
    against one docker daemon. At ~800 container-creating tests per session that is a ~1.8%
    collision chance per session. Two node ids drew the same value 2.1s apart, on gw5 and gw3.

  2. tests/integration_frameworks/conftest.py carried a byte-identical copy of the same fixture,
    so the scenario had the same exposure.

  3. On a failed create, docker_run force-removed every container matching the name without
    checking that it had created them. When the name was already taken, that destroyed the owning
    worker's live container. The losing test's cleanup removed the winner's container while the
    winner was inside stop(timeout=5), so the winner's logs() then returned
    409 ... dead or marked for removal.

Separately, docker_run's teardown gated container.remove(force=True) on container.logs(),
so any log-capture error skipped removal and leaked the container. Its name stayed taken for the
rest of the session, and the log file needed to explain the death was discarded.

Changes

  • new_test_id() in utils/docker_fixtures/_core.py is now the single definition used by both
    the parametric and integration-frameworks fixtures, so the two cannot drift apart again. It
    yields 64 bits. 16 hex chars rather than the full 32 keeps the longest generated name
    ({library}-test-library-{framework}-{framework_version}-{test_id}) inside the 63-char DNS
    label limit.
  • Containers are created with a per-invocation label, and failed-create cleanup selects on that
    label instead of the name, so another worker's container is never removed.
  • container.remove(force=True) moved into its own finally. The log-capture error still
    propagates: swallowing it would turn an unexplained dead container into a green test, and
    logger is built use_stdout=False, so a warning would reach only the log artifact, not the
    terminal, junit, or the job log. NotFound is suppressed because removal is now reached in
    states where docker reports the container already gone, where failing would be spurious.
  • tests/test_the_test/test_docker_run_cleanup.py: 8 cases. 5 fail against the previous
    behaviour: both ownership cases, both leak paths, and the already-removed case.

Verified: ruff format --check, ruff check, mypy clean; full TEST_THE_TEST 372 passed,
1 xfailed.

Note for reviewers

This changes docker_run's create-failure and teardown-failure contracts, which every tracer's
parametric run depends on. A test that previously passed after a swallowed teardown error will now
fail loudly. That is the intent, but it may surface pre-existing flakiness on the first runs.

Workflow

  1. ⚠️ Create your PR as draft ⚠️
  2. Work on you PR until the CI passes
  3. Mark it as ready for review
    • Test logic is modified? -> Get a review from RFC owner.
    • Framework is modified, or non obvious usage of it -> get a review from R&P team

🚀 Once your PR is reviewed and the CI green, you can merge it!

🛟 #apm-shared-testing 🛟

Reviewer checklist

  • Anything but tests/ or manifests/ is modified ? I have the approval from R&P team
    • utils/docker_fixtures/_core.py is modified — R&P approval still needed, not yet obtained.
  • A docker base image is modified?
    • the relevant build-XXX-image label is present
    • No base image changes.
  • A scenario is added, removed or renamed?
    • Get a review from R&P team
    • No scenario changes.

…conditional removal

Three related defects let one parametric test destroy another's container and fail
it. Observed in a dd-trace-php parametric run, where
Test_Trace_Sampling_Tags_Feb2024_Revision::test_metric_existence failed in setup
with `409 Conflict. The container name "/php-test-client-<id>" is already in use`
while Test_Trace_Sampling_Tags::test_trace_dropped_by_trace_sampling_rule_tags
failed in teardown on that same container. Neither is a sampling bug; any two
tests sharing the scenario can hit this.

1. `test_id` was `str(uuid.uuid4())[0:6]`, a 24-bit space, and it is the only
   variable part of every container and network name derived from it. Those names
   are shared by all xdist workers against one docker daemon, so at ~800
   container-creating tests per session the birthday collision chance is ~1.8%
   per session. Two different node ids drew the same value 2.1s apart on gw5 and
   gw3. `new_test_id()` now yields 64 bits from one shared definition, so the
   parametric and integration-frameworks fixtures cannot drift apart again -- the
   latter carried a byte-identical copy of the defect. 16 hex chars rather than
   the full 32 keeps the longest generated name (integration-frameworks client,
   `{library}-test-library-{framework}-{framework_version}-{test_id}`) inside the
   63-char DNS label limit.

2. On a failed create, `docker_run` force-removed every container matching the
   name, with no check that it had created them. When the name was taken, that
   destroyed the owning worker's *live* container. This is what actually killed
   the container in the run above: the losing test's cleanup removed the winner's
   container while the winner was inside `stop(timeout=5)`, so the winner's
   `logs()` then returned `409 ... dead or marked for removal`. Containers now
   carry a per-invocation label and cleanup selects on that label, never on the
   name, so a foreign container is never touched.

3. `docker_run`'s teardown gated `container.remove(force=True)` on
   `container.logs()`. Any error capturing logs skipped removal, leaking the
   container and holding its name for the rest of the session. Removal now runs
   in its own `finally`. The capture error still propagates: swallowing it would
   turn an unexplained dead container into a green test, and `logger` is built
   `use_stdout=False`, so a warning would reach only the log artifact -- not the
   terminal, junit, or the job log. `NotFound` is suppressed because removal is
   now reached in states where docker reports the container already gone, and
   failing there would be spurious.

Adds TEST_THE_TEST coverage; 5 of the 8 cases fail against the previous
behaviour, including both ownership cases and both leak paths.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@bm1549 bm1549 added the ai-generated The pull request includes a significant amount of AI-generated code label Jul 31, 2026
@github-actions

Copy link
Copy Markdown
Contributor

CODEOWNERS have been resolved as:

tests/test_the_test/test_docker_run_cleanup.py                          @DataDog/system-tests-core
tests/integration_frameworks/conftest.py                                @DataDog/system-tests-core
tests/parametric/conftest.py                                            @DataDog/system-tests-core @DataDog/apm-sdk-capabilities
utils/docker_fixtures/_core.py                                          @DataDog/system-tests-core

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

Labels

ai-generated The pull request includes a significant amount of AI-generated code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant