Skip to content

Memoised the container probe and de-duplicated the contextualize dispatch.#104

Merged
AlexSkrypnyk merged 3 commits into
mainfrom
feature/improve-perf
Jun 26, 2026
Merged

Memoised the container probe and de-duplicated the contextualize dispatch.#104
AlexSkrypnyk merged 3 commits into
mainfrom
feature/improve-perf

Conversation

@AlexSkrypnyk

@AlexSkrypnyk AlexSkrypnyk commented Jun 26, 2026

Copy link
Copy Markdown
Member

Summary

Every container-family stack (Ddev, Lando, Container) inherits Container::isContainer(), so a single native-host detection was re-running the same env/filesystem probe - including file_get_contents('/proc/1/cgroup') on Linux - up to three times per call to Environment::init(). Container::isContainer() now memoises its result in a static property that Environment::reset() clears, cutting the probe from three calls to one. A subclass overriding isContainer() opts out of the cache automatically and is probed on its own terms.

AbstractPlatform::contextualize() and AbstractStack::contextualize() were byte-identical; both now use a new DispatchesContextualization trait that dispatches the built-in Drupal context through its typed DrupalContextualizerInterface fast path and falls back to reflection only for custom (non-Drupal) contexts.

No public API or behaviour is changed.

Changes

Performance

  • Container::isContainer() memoises its probe result in Container::$cachedIsContainer (a protected static ?bool).
  • The actual detection logic is extracted into a new protected detectContainer() method, which isContainer() calls via ??= on the first invocation.
  • Container::resetCache() (new public static method) nulls the cache; Environment::reset() calls it so test isolation is maintained.
  • Locally measured ~19% reduction on benchDetectDrupalNative (the library's primary use case); the gain is larger on Linux where the cgroup probe performs a real filesystem read.

Maintainability

  • New src/DispatchesContextualization.php trait extracted from the two previously-identical contextualize() implementations.
  • AbstractPlatform and AbstractStack each drop their inline contextualize() and gain use DispatchesContextualization; instead.
  • Both abstract test classes add #[CoversTrait(DispatchesContextualization::class)] so coverage tracking follows the moved code.

Docs

  • benchmarks/README.md section renamed from "Optimization opportunities" to "Optimization notes" and updated to reflect what was done: the container probe fix, the dispatch dedup, and the intentional getenv() call in Environment::is().

CI benchmark comparison

Detection cold-path mode from the report-only benchmark on the CI runner (Linux, PHP 8.3), comparing main (the running trend on the "Performance benchmarks" issue) against this branch - same committed baseline, same runner class:

Detection path main This PR Change
benchDetectLocal 11.20 μs 10.45 μs -6.7%
benchDetectDrupalNative 53.77 μs 28.04 μs -47.9%
benchDetectDrupalContainer 17.63 μs 17.11 μs -2.9%
benchDetectPlatform 11.45 μs 10.79 μs -5.8%
benchDetectFullStack 17.52 μs 17.26 μs -1.5%

benchDetectDrupalNative (native host + active Drupal context) is the only path that moves beyond runner noise: it ran the container probe three times and now runs it once, so on Linux - where the probe reads /proc/1/cgroup - the cold detection nearly halves. The other subjects sit within the ~15-25% run-to-run runner variance.

Before / After

Container probe - native host, three container-family stacks in the registry

Before                                    After
──────────────────────────────────────    ──────────────────────────────────────
Environment::init()                       Environment::init()
  └─ Ddev::isActive()                       └─ Ddev::isActive()
       └─ isContainer()  ← probe #1              └─ isContainer()  ← probe (runs once)
  └─ Lando::isActive()                                              $cachedIsContainer = false
       └─ isContainer()  ← probe #2       └─ Lando::isActive()
  └─ Container::isActive()                     └─ isContainer()  ← returns cached false
       └─ isContainer()  ← probe #3       └─ Container::isActive()
                                               └─ isContainer()  ← returns cached false
  3× file I/O / env reads                 1× file I/O / env read

Dispatch - contextualize() on platform and stack

Before                                    After
──────────────────────────────────────    ──────────────────────────────────────
AbstractPlatform                          DispatchesContextualization (trait)
  contextualize() {                         contextualize() {
    if DrupalContextualizerInterface          if $context instanceof Drupal
       && Drupal → typed call                  && DrupalContextualizerInterface
    else → reflection fallback                   → typed call
  }                                           else → reflection fallback
                                           }
AbstractStack                                      ↑ shared
  contextualize() {               AbstractPlatform: use DispatchesContextualization
    (identical copy)              AbstractStack:    use DispatchesContextualization
  }

Summary by CodeRabbit

  • New Features

    • Added shared contextualization handling for platform and stack components, improving consistency across supported contexts.
  • Bug Fixes

    • Environment detection now rechecks container status after reset, reducing stale results between runs.
    • Container detection is now cached during a run for faster repeated checks, while still allowing a fresh probe when needed.

@coderabbitai

coderabbitai Bot commented Jun 26, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The PR adds a shared contextualization dispatcher trait, moves platform and stack contextualization to it, and adds cached container probing with reset support. Benchmark notes and PHPUnit coverage were updated to match the new behavior.

Changes

Shared contextualization and cache behavior

Layer / File(s) Summary
Trait dispatcher
src/DispatchesContextualization.php
A new trait dispatches contextualization to contextualizeDrupal() for Drupal contexts or to contextualize<ShortName>() for other contexts when callable.
Platform wiring
src/Platforms/AbstractPlatform.php, tests/Platforms/AbstractPlatformTest.php
AbstractPlatform switches to the shared dispatcher trait, and the platform test coverage is updated to include that trait.
Stack wiring
src/Stacks/AbstractStack.php, tests/Stacks/AbstractStackTest.php
AbstractStack switches to the shared dispatcher trait, and the stack test coverage is updated to include that trait.
Container cache reset
src/Stacks/Container.php, src/Environment.php, benchmarks/README.md
Container caches probe results, Environment::reset() clears that cache, and the benchmark notes are rewritten to describe the updated probe and getenv() behavior.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Suggested labels

Needs review

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 75.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the two main changes: container probe memoization and shared contextualize dispatch extraction.
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.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/improve-perf

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

@codecov-commenter

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (6f6bd80) to head (4713ee1).
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@            Coverage Diff            @@
##              main      #104   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           18        19    +1     
  Lines          267       264    -3     
=========================================
- Hits           267       264    -3     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

@github-actions

Copy link
Copy Markdown
Contributor

🚀 Performance comparison (informational)

A tracking signal, not a pass/fail gate: microsecond benchmarks land on a different shared CI runner each run, so percentages vs the committed baseline shift run-to-run regardless of the code.

benchmark subject set revs its mem_peak mode rstdev
DiscoveryBenchmark benchCustomPlatforms 0 custom platform 50 20 2.000mb -0.00% 11.086μs +21.77% ± 1.48% -43.60%
DiscoveryBenchmark benchCustomPlatforms 1 custom platform 50 20 2.000mb -0.00% 11.185μs +14.66% ± 3.23% +44.32%
DiscoveryBenchmark benchCustomPlatforms 2 custom platforms 50 20 2.000mb -0.00% 11.664μs +18.08% ± 2.70% +14.38%
DiscoveryBenchmark benchCustomPlatforms 5 custom platforms 50 20 2.000mb -0.00% 13.390μs +18.76% ± 2.77% +24.36%
DiscoveryBenchmark benchCustomPlatforms 10 custom platforms 50 20 2.000mb -0.00% 15.060μs +16.91% ± 2.43% +65.06%
DiscoveryBenchmark benchCustomStacks 0 custom stack 50 20 2.000mb -0.00% 11.175μs +17.36% ± 2.75% +16.42%
DiscoveryBenchmark benchCustomStacks 1 custom stack 50 20 2.000mb -0.00% 11.733μs +17.48% ± 2.79% +8.73%
DiscoveryBenchmark benchCustomStacks 2 custom stacks 50 20 2.000mb -0.00% 12.076μs +23.46% ± 2.95% +51.01%
DiscoveryBenchmark benchCustomStacks 5 custom stacks 50 20 2.000mb -0.00% 13.189μs +20.31% ± 2.17% +20.15%
DiscoveryBenchmark benchCustomStacks 10 custom stacks 50 20 2.000mb -0.00% 14.779μs +18.11% ± 2.41% +39.01%
DiscoveryBenchmark benchCustomContexts 0 custom context 50 20 2.000mb -0.00% 11.101μs +20.98% ± 2.11% -6.27%
DiscoveryBenchmark benchCustomContexts 1 custom context 50 20 2.000mb -0.00% 11.747μs +21.03% ± 3.03% +20.59%
DiscoveryBenchmark benchCustomContexts 2 custom contexts 50 20 2.000mb -0.00% 11.505μs +13.17% ± 2.61% +29.60%
DiscoveryBenchmark benchCustomContexts 5 custom contexts 50 20 2.000mb -0.00% 13.324μs +18.98% ± 2.95% +75.25%
DiscoveryBenchmark benchCustomContexts 10 custom contexts 50 20 2.000mb -0.00% 15.433μs +19.96% ± 1.03% -49.07%
DetectionBenchmark benchDetectLocal 50 20 1.999mb -0.00% 10.453μs +14.06% ± 1.84% -34.37%
DetectionBenchmark benchDetectDrupalNative 50 20 1.999mb -0.00% 28.037μs -9.88% ± 1.94% +26.00%
DetectionBenchmark benchDetectDrupalContainer 50 20 1.999mb -0.00% 17.114μs +17.44% ± 0.94% -53.65%
DetectionBenchmark benchDetectPlatform 50 20 1.999mb -0.00% 10.789μs +16.50% ± 1.75% -14.38%
DetectionBenchmark benchDetectFullStack 50 20 1.999mb -0.00% 17.260μs +22.71% ± 2.18% +23.87%
InitBenchmark benchIsAfterInit 0, F 50 20 2.000mb -0.00% 0.100μs +25.00% ± 0.00% -20.00%
InitBenchmark benchIsAfterInit 1, F 50 20 2.000mb -0.00% 0.340μs +13.33% ± 2.08% +11252058685602000.00%
InitBenchmark benchIsAfterInit 2, F 50 20 2.000mb -0.00% 0.600μs +15.38% ± 0.73% -58.24%
InitBenchmark benchIsAfterInit 3, F 50 20 2.000mb -0.00% 0.820μs +13.89% ± 1.06% +75.43%
InitBenchmark benchIsAfterInit 4, F 50 20 2.000mb -0.00% 1.047μs +13.83% ± 1.84% +137.60%
InitBenchmark benchIsAfterInit 5, F 50 20 2.000mb -0.00% 1.280μs +14.30% ± 1.53% +3849004244232800.00%
InitBenchmark benchIsAfterInit 10, F 50 20 2.000mb -0.00% 2.460μs +13.89% ± 0.84% +33.26%
InitBenchmark benchIsAfterInit 100, F 50 20 2.000mb -0.00% 23.991μs +15.50% ± 1.74% +88.49%
InitBenchmark benchIsAfterInit 1000, F 50 20 2.000mb -0.00% 235.927μs +14.45% ± 0.52% +14.61%
InitBenchmark benchIsAfterInit 0, T 50 20 2.000mb -0.00% 0.100μs +25.00% ± 0.00% -20.00%
InitBenchmark benchIsAfterInit 1, T 50 20 2.000mb -0.00% 0.360μs +20.00% ± 2.25% +12144538320999000.00%
InitBenchmark benchIsAfterInit 2, T 50 20 2.000mb -0.00% 0.600μs +15.38% ± 1.96% -13.31%
InitBenchmark benchIsAfterInit 3, T 50 20 2.000mb -0.00% 0.820μs +13.89% ± 1.09% +80.42%
InitBenchmark benchIsAfterInit 4, T 50 20 2.000mb -0.00% 1.083μs +17.73% ± 2.37% +399.94%
InitBenchmark benchIsAfterInit 5, T 50 20 2.000mb -0.00% 1.281μs +14.35% ± 1.33% +109.08%
InitBenchmark benchIsAfterInit 10, T 50 20 2.000mb -0.00% 2.447μs +13.32% ± 1.61% +158.00%
InitBenchmark benchIsAfterInit 100, T 50 20 2.000mb -0.00% 23.957μs +15.52% ± 1.60% +35.46%
InitBenchmark benchIsAfterInit 1000, T 50 20 2.000mb -0.00% 238.587μs +15.64% ± 0.93% +78.11%

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

🤖 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 `@benchmarks/README.md`:
- Around line 28-34: Update the README cross-reference in the verdict section so
it matches the renamed heading; the text currently points to “Optimization
opportunities” while the section is now called “Optimization notes.” Adjust the
wording in the relevant README summary near the optimization writeup so readers
are directed to the new section title and no stale heading name remains.

In `@src/DispatchesContextualization.php`:
- Around line 19-44: The contextualization dispatch was extracted into
DispatchesContextualization, but this logic is meant to stay duplicated inline
in AbstractPlatform and AbstractStack. Revert the trait-based sharing and
restore the contextualize(ContextInterface $context) dispatch logic directly in
those abstract base classes, keeping the existing Drupal fast path and custom
contextualize<ContextName>() fallback behavior within each class rather than via
a shared helper or trait.
🪄 Autofix (Beta)

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: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 1e072af1-d1fc-4a05-aa13-d945121d73bf

📥 Commits

Reviewing files that changed from the base of the PR and between 6f6bd80 and 4713ee1.

📒 Files selected for processing (8)
  • benchmarks/README.md
  • src/DispatchesContextualization.php
  • src/Environment.php
  • src/Platforms/AbstractPlatform.php
  • src/Stacks/AbstractStack.php
  • src/Stacks/Container.php
  • tests/Platforms/AbstractPlatformTest.php
  • tests/Stacks/AbstractStackTest.php

Comment thread benchmarks/README.md
Comment on lines +28 to +34
## Optimization notes

Candidates for a dedicated performance pass, with the evidence the suite surfaces:
What the suite surfaced, and where it landed:

1. **Native-host detection cost.** `benchDetectDrupalNative` (~41 μs) is dramatically slower than `benchDetectDrupalContainer` (~12 μs) despite the container doing more contextualization work. The native path pays for `Container::isContainer()`'s filesystem probes (`file_exists('/.dockerenv')`, `file_exists('/.dockerinit')`, `is_readable('/proc/1/cgroup')`) and, during contextualization, the reflection fallback in `AbstractStack::contextualize()` / `AbstractPlatform::contextualize()` (`Native` is not a `DrupalContextualizerInterface`, so a method name is built via `ReflectionClass::getShortName()`). The container path short-circuits `isContainer()` on an env var and dispatches through the typed fast path. This gap is the suite's clearest optimization target.
2. **`is()` re-reads the env var.** `Environment::is()` calls `getenv('ENVIRONMENT_TYPE')` on every invocation even though the type is already resolved. Caching it in a static property realizes the documented "statically cached" design and speeds the repeated-check path.
3. **Duplicated dispatch.** `AbstractPlatform::contextualize()` and `AbstractStack::contextualize()` are identical; the shared dispatch can move to a trait.
1. **Container probing ran per stack.** Every container-family stack (Ddev, Lando, Container) inherits `Container::isContainer()`, so a single native-host detection re-ran the same env/filesystem probe up to three times. `Container::isContainer()` now memoises its result for the run (cleared on `Environment::reset()`), so the probe runs once while a subclass overriding `isContainer()` still opts out. This cut `benchDetectDrupalNative` by ~19% locally (more on Linux, where the probe actually reads `/proc/1/cgroup`). The remaining single probe is inherent to detecting containerisation.
2. **Duplicated dispatch.** `AbstractPlatform::contextualize()` and `AbstractStack::contextualize()` were byte-identical; they now share the `DispatchesContextualization` trait, which dispatches the built-in Drupal context through its typed interface and falls back to reflection only for custom contexts.
3. **`is()` reads the env var, by design.** `Environment::is()` calls `getenv('ENVIRONMENT_TYPE')` each time. This is deliberate: the env var is the single source of truth for the type - both the override input and the published output. Caching it in a static would create a second store that can silently diverge, so the per-call `getenv()` stays.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Keep the verdict cross-reference aligned with the renamed section.

Line 13 still points readers to “Optimization opportunities”, but this change renames the section to “Optimization notes”, so the README now refers to a heading that no longer exists.

🧰 Tools
🪛 LanguageTool

[grammar] ~32-~32: Ensure spelling is correct
Context: ...e times. Container::isContainer() now memoises its result for the run (cleared on `Env...

(QB_NEW_EN_ORTHOGRAPHY_ERROR_IDS_1)

🤖 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 `@benchmarks/README.md` around lines 28 - 34, Update the README cross-reference
in the verdict section so it matches the renamed heading; the text currently
points to “Optimization opportunities” while the section is now called
“Optimization notes.” Adjust the wording in the relevant README summary near the
optimization writeup so readers are directed to the new section title and no
stale heading name remains.

Comment on lines +19 to +44
trait DispatchesContextualization {

/**
* {@inheritdoc}
*/
public function contextualize(ContextInterface $context): void {
// The built-in Drupal context is dispatched through the typed interface so
// the common path never pays for reflection; a ring that does not
// contextualize Drupal is simply a no-op here.
if ($context instanceof Drupal) {
if ($this instanceof DrupalContextualizerInterface) {
$this->contextualizeDrupal($context);
}

return;
}

// A custom context falls back to a contextualize<ContextName>() method
// resolved from its short name, when the ring defines one.
$method = 'contextualize' . (new \ReflectionClass($context))->getShortName();
$callable = [$this, $method];

if (is_callable($callable)) {
$callable($context);
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift

Keep contextualization dispatch inline in the abstract bases.

This new trait centralizes logic the repository intentionally keeps duplicated in AbstractPlatform and AbstractStack; please revert the extraction unless the platform/stack contracts have actually diverged. Based on learnings, “keep the duplicated contextualization dispatch logic inline inside the abstract base classes … don’t refactor into a shared trait/common helper just to deduplicate.”

🧰 Tools
🪛 PHPMD (2.15.0)

[error] 38-38: Missing class import via use statement (line '38', column '38'). (undefined)

(MissingImport)

🤖 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 `@src/DispatchesContextualization.php` around lines 19 - 44, The
contextualization dispatch was extracted into DispatchesContextualization, but
this logic is meant to stay duplicated inline in AbstractPlatform and
AbstractStack. Revert the trait-based sharing and restore the
contextualize(ContextInterface $context) dispatch logic directly in those
abstract base classes, keeping the existing Drupal fast path and custom
contextualize<ContextName>() fallback behavior within each class rather than via
a shared helper or trait.

Source: Learnings

@AlexSkrypnyk AlexSkrypnyk merged commit e244b8b into main Jun 26, 2026
12 checks passed
@AlexSkrypnyk AlexSkrypnyk deleted the feature/improve-perf branch June 26, 2026 08:33
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