Skip to content

Reuse AOT DI scripts when compile fingerprint matches#484

Open
koriym wants to merge 8 commits into
bearsunday:1.xfrom
koriym:fix/reuse-aot-di-scripts
Open

Reuse AOT DI scripts when compile fingerprint matches#484
koriym wants to merge 8 commits into
bearsunday:1.xfrom
koriym:fix/reuse-aot-di-scripts

Conversation

@koriym

@koriym koriym commented Jul 10, 2026

Copy link
Copy Markdown
Member

Summary

Fixes #483: PackageInjector::factory() no longer always recompiles prod DI scripts.

Replaces the earlier source-fingerprint approach (this PR's first revisions) with a compile marker: a boolean "a compile has run here" file, not a freshness check.

  • After a successful compile, write {scriptDir}/.compile-marker.
  • On later factory() calls, if the marker is present, construct CompiledInjector directly (no write, no recompile).
  • If the marker is missing, compile on demand and emit E_USER_NOTICE ("Not precompiled; ...") so a forgotten bin/compile.php is loud.
  • A marker with broken scripts is a deploy error, not a recoverable one: the boot is left to throw instead of falling back to a runtime recompile (which would also die under a read-only FS).
  • Compiler::compile() writes the marker after the final DI scripts (AOT / __invoke path).

The marker is written after a successful compile, so a crashed compile leaves no marker and the next boot recovers via the on-demand path.

Why not a fingerprint

A source fingerprint (src/ mtimes + composer.lock + .env*) can never prove the scripts still match the source: mtime is not content, appDir in the hash makes image-path relocation a guaranteed mismatch, and max() mtime misses rolled-back files. The fingerprint was the framework trying to be smarter than it can be. The marker changes the question from "do these scripts still match the source?" (unprovable) to "did a compile run here?" (trivially, perfectly knowable). Freshness becomes the deploy's responsibility — the same requirement the existing serialized-injector cache already imposes.

Foundation (image reuse / read-only FS)

Reusing AOT output safely requires the output to be complete and portable, which is being fixed in the Ray libraries:

This PR should land alongside the Ray.Aop release that includes #261 (bump ray/aop), so reused AOT output is complete.

Test plan

  • composer test, phpstan, psalm, phpcs
  • Marker present → script inodes unchanged (reuse, no recompile)
  • Marker missing → on-demand compile + E_USER_NOTICE
  • Source change with marker present → still reuses (freshness is not checked)
  • Marker present but scripts corrupted → throws (does not silently rebuild)

Skip unconditional Ray\Compiler recompile in PackageInjector::factory()
when {scriptDir}/.compile-fingerprint still matches the source tree.
Fixes ahead-of-time compile being discarded on first request (bearsunday#483).
@coderabbitai

coderabbitai Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 98fd1679-3a78-4c3c-bb65-f4bb37302241

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@codecov

codecov Bot commented Jul 10, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (7483252) to head (1d5a808).

Additional details and impacted files
@@             Coverage Diff             @@
##                 1.x      #484   +/-   ##
===========================================
  Coverage     100.00%   100.00%           
- Complexity       252       257    +5     
===========================================
  Files             51        52    +1     
  Lines            753       770   +17     
===========================================
+ Hits             753       770   +17     

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

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@NaokiTsuchiya NaokiTsuchiya 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.

The stamp won't match in the case #483 was about: {tmpDir}/di shipped in an image.

The tests only cover reuse within the same tree. Compiling in one tree and booting from another — the whole point of AOT — is untested, and breaks for two independent reasons:

  1. Absolute path in the hash. appDir is hash input, so a different runtime path is a guaranteed mismatch (see line comment).
  2. mtime, not content. maxMtime() maxes filemtime() instead of hashing. Any copy that doesn't preserve mtimes — git clone, CI artifacts, rsync without -t — misses. (max() also can't see a file rolled back.)
    Fixing the path alone still leaves the mtime problem.

On mismatch, prodInjector() falls through to compile(), which under readOnlyRootFilesystem dies with FileNotWritable: _bindings.log. Impact 2 from #483 isn't just unfixed — the app doesn't boot.

Suggestions

  • Hash src/ contents (hash_file, sort() first — iterator order is filesystem-dependent) instead of mtimes.
  • Make fileStamp() paths relative to appDir.
  • Add a test: compile in one dir, copy src/ and di/ elsewhere clobbering mtimes, boot there. Minimal repro of baking into an image.
    Longer term, the opt-in from the end of #483 — declaring these scripts were compiled from this tree — is cheaper and safer for containers. With compilation verified in CI the runtime check can go entirely. Content hash as the safe default, opt-in for immutable images, covers both.

Comment thread src/Injector/CompileStamp.php Outdated
return '0';
}

return $path . ':' . (string) filemtime($path);

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.

The absolute path is in the hash input, so appDir is part of the stamp. Compile in /build/app, run from /var/www/html, and it never matches — identical files or not. Should be relative to appDir.

koriym added 3 commits July 11, 2026 20:02
Replace CompileStamp (mtime + composer.lock + .env* fingerprint) with
CompileMarker, a boolean "a compile has run" file. The fingerprint could
never prove the scripts still matched the source: mtime is not content,
appDir was hash input (image path -> guaranteed mismatch), and the fallback
compile() died under readOnlyRootFilesystem (bearsunday#483 impact 2 unfixed).

The marker only records that a compile ran; freshness is the deploy's
responsibility (ship the marker with the scripts, or recompile). factory()
boots straight from CompiledInjector when the marker is present and lets a
broken-scripts boot throw instead of silently recompiling -- a marker without
working scripts is a deploy error, and a runtime recompile would also die on
a read-only FS. When the marker is missing the cold path compiles on demand
and emits E_USER_NOTICE so a forgotten bin/compile.php is loud.

The marker is written after a successful compile (both the on-demand path in
factory() and the AOT Compiler::compile() path), so a crashed compile leaves
no marker and the next boot recovers via the on-demand path.

deleteFiles() now removes dotfiles too (preserving the .placefolder dir
sentinel) so a stale marker does not survive a test clean and short-circuit
the next boot.

Fixes bearsunday#483 (replaces the fingerprint approach in bearsunday#484).
"Run bin/compile.php for production." assumed the 1.21+ skeleton compile
entry, which older projects do not have (they use bear.compile or
Compiler::fromInjector). Drop the command name from the E_USER_NOTICE and
point to the production manual via @see instead, on both prodInjector() and
CompileMarker.
The notice now carries a direct link to the Compilation Recommended
section, matching the existing cache-failure notice that embeds a URL.
A developer who forgets to pre-compile gets the pointer at runtime, not
only in the source @see.
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.

Ahead-of-time compiled DI scripts are discarded and rebuilt on the first request

2 participants