S122 — music rescan: skip unchanged files (mtime/size), bound read concurrency at 4, fix the dead getID3 tag path - #569
Merged
detain merged 8 commits intoJul 27, 2026
Conversation
…4, fix the dead getID3 path
Stops re-reading ~54,585 unchanged files over sshfs on every music rescan.
Measured baseline: 2.78 files/s = 360 ms/file = 6.1 h for 61,135 files, with
items_added=0 / items_updated=6,550 — i.e. 89% of the work was for nothing.
(a) mtime/size skip. New MusicScanSkipIndex loads the library's already-indexed
(path -> mtime,size) map in ONE query and the walk skips a matching file
BEFORE probeMetadata(). Predicate: skip iff mtime AND size both match what
was recorded the last time the tags were actually read. Stated failure mode:
an in-place edit that preserves both is missed; the escape hatch is clearing
the two metadata_json keys. Identity is stored in media_items.metadata_json
(file_mtime/file_size) — the convention AudioScanner:218 and
MusicLibraryManager:334 already use, so no migration and no media_items
ALTER. Measured 90.9 bytes/entry, 5.30 MB for the real 61,135-file library.
Two one-query-per-scan gates keep it from regressing S96(e)/orphan adoption:
the fast path is only available when nothing is unhealed and no orphan is
adoptable, and $mayAdopt is re-read PER FILE so a mid-walk failure disables
it for the remainder. The identity map JOINs music_tracks, so a media_items
row with no track row is never skippable — otherwise a lost file would be
permanently lost instead of retried.
(b) Read concurrency 1 -> <=4. New MusicScanPrefetcher runs up to 3 reader
processes LOOKAHEAD files ahead of the walk, warming the page cache for
files it is about to probe. Hard-capped at 4: measured 1.73x at 4 threads
and 0.59x at 8 (worse than serial) on a single latency-bound spindle. The
figures are cited in the class, in config/scanner.php, and asserted by a
test, so raising the cap means deleting a falsifiable claim. The pool is a
cache warmer only — it cannot influence what is indexed — and submit()
drops work rather than ever blocking the scan. config/scanner.php
music_read_concurrency = 1 disables it. process.php count stays 1.
(c) Tag reads. THE BIG ONE, and it is a bug: getID3::analyze() never populates
$info['comments'] (the merged view is built only by
getid3_lib::CopyTagsToComments(), which no getID3 module calls), so
probeViaGetId3() returned NULL for every file ever scanned and 100% of the
library fell through to ffprobe — measured 114.9 ms and 232 read syscalls
per file, against 2.2 ms for getID3, on a warm LOCAL 4.17 MB MP3. Fixed by
calling CopyTagsToComments(). Plus measured option tuning: -69% bytes,
-66% reads, -68% seeks on an encoder-written fixture, and 1,891,240 ->
33,256 bytes retained per analyse.
CORRECTION to the vault diagnostic, measured: option_save_attachments=false
does NOT reduce bytes read — module.tag.id3v2.php:139 reads the whole ID3v2
frame region contiguously before any frame is inspected. It only stops the
cover art being retained. Removing that read needs a vendor patch, which is
forbidden; the bytes lever for it is (a).
Tests: 7596 -> 7640 (+44). PHPStan L9 clean, phpcs -n src/ clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
M4 (hasUnhealedMusicMediaItem -> return false) and M5 (canSkip -> return true) both left the suite GREEN. Root causes, both fixture defects: - the heal test nulled music_artists.media_item_id but LEFT the media_items row, which makes it an ORPHAN — already caught by hasAdoptableMusicMediaItem(), so the heal gate was never the thing under test. The row is now removed too, reproducing the real S96(e) shape (the mint failed outright). - the mid-walk-flip test used a small fixture, and a SKIPPED file never opens an album, so the 32-album window could not overflow, no album was flushed during the walk, and $mayAdopt could not flip at all. Now 40 albums with the first 33 touched: 40 probes with the per-file read, 33 without it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Keying the double's branch on 'JOIN music_tracks' meant that DELETING the join from production SQL made the statement fall through and return NOTHING, where a real MySQL returns MORE rows. Measured: testAFileTheScanLostIsNotStampedAndIsRetried passed against that mutation. The branch is now recognised by the columns it asks for and the join filter is applied only when the statement contains it — the same rule the sibling double applies to the album adoption's parent_id scoping. Both affected tests are now RED against the mutation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…press New MusicScanSkipUnchangedIntegrationTest (5 tests, 58 assertions), driving the REAL getID3 over the committed MP3 fixture against real MySQL 8.0: 1. JSON_SET(COALESCE(metadata_json, JSON_OBJECT()), ...) — JSON_SET(NULL, ...) returns NULL in MySQL, so without the COALESCE the stamp UPDATE ERASES the document (sub_type/name with it) instead of adding two keys. Mutation R1 (drop the COALESCE) is RED. 2. metadata_json->>'$.file_mtime' extraction against a real JSON column, and the JOIN music_tracks that keeps a track-less row unskippable. Mutation R2 RED. 3. The heal gate's SELECT ... UNION ALL ... LIMIT 1 must parse AND be classified as a 'select' by Connection::query()'s leading-keyword dispatch. Mutation R3 (reformat it so the keyword is missed and the driver returns null) is RED. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… on the pool Six added tests, each aimed at a branch the happy path never reaches: - a query() returning null (the driver's answer for an unrecognised keyword) and a non-array row are ignored rather than crashing the walk; - MAX_ENTRIES really truncates, really warns, and really bounds remember() — driven with MAX_ENTRIES + 1 rows, not asserted; - remember() ignores a file it cannot stat; - a busy reader is SKIPPED in favour of a free one (a pool, not a queue); - a reader that exits is retired from the pool instead of written to forever; - the heal gate FAILS SAFE — an unanswerable gate disables the fast path, the opposite direction from the adoption gate beside it, because a skip we cannot justify is a change we may silently miss. MusicScanSkipIndex 69/69 statements, MusicScanPrefetcher 84/91, scanner 630/676. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
Not up to standards ⛔🔴 Issues
|
| Category | Results |
|---|---|
| UnusedCode | 2 medium |
| BestPractice | 21 medium |
| Documentation | 7 minor |
| ErrorProne | 5 medium |
| CodeStyle | 27 minor |
| Complexity | 18 medium |
| Comprehensibility | 20 minor |
🟢 Metrics 506 complexity · 0 duplication
Metric Results Complexity 506 Duplication 0
NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #569 +/- ##
============================================
+ Coverage 65.34% 65.48% +0.13%
- Complexity 20957 21061 +104
============================================
Files 656 658 +2
Lines 66023 66311 +288
============================================
+ Hits 43142 43421 +279
- Misses 22881 22890 +9 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
…three false docblocks B1 (data loss). The (mtime, size) stamp was stat'ed inside flushAlbum() -> upsertTrack(), long after probeMetadata() had buffered the tags. SplFileInfo does not memoise its stat, so that was a genuinely later observation of the file: an ordinary tag write landing in the probe->flush window (>= 32 albums, unbounded for a tag-identity-spread album) was stamped POST-edit against PRE-edit tags and every later scan skipped it forever. The walk now captures stampValues() one statement above probeMetadata() and carries it — as two flat ints, measured at zero added buffer cost — through flushAlbum() into upsertTrack()/createMediaItem()/remember(). Stamping the pre-read identity errs towards a redundant read, never a missed change. Two regression tests reproduce the window where it lives (inside the probe), one on the 'skipped' branch and one on 'added'. B2. stampFileIdentity() claimed the stamp UPDATE is suppressed on the fast-path-off scans, "keeping the exceptional, slow scan from also issuing 61,135 pointless UPDATEs". It cannot: the index is only loaded when the fast path is available, so isStampCurrent() is false for every file on exactly those scans. Corrected to the measured behaviour (5 unchanged files, fast path off -> 5 probes, 5 JSON_SET UPDATEs) plus the one path where it does fire (40 probes, 32 UPDATEs on the mid-walk-flip fixture). Both numbers now pinned by tests. B3. The memory figure was ~2x understated and its test could not measure it. Real retention is 186.9 B/entry = 10.90 MiB for 61,135 entries, reproducible to the byte by two independent methods; 90.9 B/entry only reproduces when the path strings are pre-allocated and refcount-shared, which is what the test did. Corrected: equal to (not half) the ~11.1 MB S95 ceiling, 7.8x (not 16x) cheaper per entry, MAX_ENTRIES ~44.3 MiB (not ~21.7 MB). The test now generates its rows inside the connection double at the real library size, with a floor assertion so the flaw cannot return. Also documented that MAX_ENTRIES bounds retention only — the load's transient peak is 36.74 MiB at 61,135 rows and 153.54 MiB at the cap. Non-blocking, all eight: 1. music_read_concurrency = 1 no longer performs the second directory walk at all; config/scanner.php's "byte-for-byte the pre-S122 scanner" is now true and scoped. 2. MusicScanPrefetcher::$dropped is emitted as prefetch_dropped in the summary. 3. music_tracks.media_item_id NOT NULL UNIQUE cited to 065_music_library.sql:74. 4. createMediaItem()'s dead `+` union removed. 5. proc_close()'s unbounded wait on a stalled FUSE mount stated, with why polling proc_get_status() would trade it for a zombie leak. 6. The upsertTrack() album_id/artist_id note made accurate: unchanged in kind by S122. 7. The getID3 revival changes the metadata SOURCE for 100% of the library — documented, including that the first rescan reports ~every file updated. Measured the accuracy question: getID3 playtime_seconds is byte-identical at 10/20/50/200 frames on 11 fixtures, equals ffprobe to one MPEG frame (0.0261 s) on Xing MP3s, and BEATS it outright on headerless VBR (ffprobe 627.467 s vs a true 300.000 s). 8. The three phpcs one-class-per-file warnings cleared by giving each double its own file, matching the ~20 existing standalone doubles in tests/. PHPUnit 7658 (branch baseline 7652), PHPStan L9 src/ clean, phpcs PSR12 src/ clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…he claim
A targeted mutation sweep over the six carried-stamp sites left four RED (the two DB
stamp sites and their two regression tests) and TWO GREEN:
`remember($file, $values)` and `isStampCurrent($file, $values)` can both be mutated
back to a fresh stat with the whole suite still passing.
Rather than invent a contrived test, the claims are corrected to what is
demonstrable:
* the DB stamp sites ARE correctness (M1/M2 red, each on its own regression test);
* the in-memory `remember()`/`isStampCurrent()` values are INVARIANT HYGIENE. The
map is keyed by verbatim path and the scan's walk never yields the same path
twice — measured: `RecursiveDirectoryIterator` (SKIP_DOTS + LEAVES_ONLY) does not
descend `top/linked -> ../real` and a hard link is its own distinct path — so no
probe is saved and the only observable difference is one redundant UPDATE.
That also removes a pre-existing wrong rationale from `remember()`'s docblock and
from its test ("so a path the walk reaches twice is not probed twice").
Also measured while checking the B3 test has teeth: re-introducing the pre-shared
path strings makes the new floor assertion fail at exactly 90.88 B/entry, i.e. it
reproduces the understated figure from the exact flaw it now guards against.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…f self::fail(), narrow the "to the byte" memory claim
NB1: MusicLibraryScanner.php:2577 was 123 chars (phpcs runs with -n, so a
warning, not a gate failure). Split after `{@see` — the same wrap this repo
already uses in StatsCollector.php:669 and ItemRepository.php:3030.
NB2: ProbeCountingScanner extends MusicLibraryScanner, not TestCase, so
`self::fail()` was undefined there: reaching that branch would have raised
`Error: Call to undefined method` instead of the message it meant to print.
Now throws \LogicException with the identical message.
NB3: MusicScanSkipIndex's class docblock claimed BOTH memory figures were
"reproducible to the byte". True of the RETAINED figure (a memory_get_usage()
delta over a map this process solely owns — and the only half the test
asserts), NOT of the TRANSIENT peak: memory_get_peak_usage() is process-wide
and monotonic, so review r2 re-measured the same load at 38,609,696 B
peak-vs-peak and 40,303,064 B peak-vs-usage against the documented
38,527,368 B. The exactness claim now covers the retained figures only; both
peaks (36.74 MiB and 153.54 MiB) are marked approximate/baseline-dependent.
No number that was correct changed, and no test assertion was touched.
Verified: phpunit tests/Unit/Media/Music/ OK (186 tests, 910 assertions);
phpcs PSR12 src/ 0 errors, 2 warnings (both pre-existing in
LibraryMetadataMatcher.php); phpstan src/Media/Music --level=9 [OK].
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
S122 — music rescan: 6.1 h → minutes
Stops re-reading ~54,585 unchanged files over sshfs on every music rescan. Baseline, from
the vault diagnostic and the first-ever completed scan: 2.78 files/s = 360 ms/file =
6.1 h for 61,135 files, with
items_added = 0/items_updated = 6,550— 89 % of thework was for nothing.
(a) mtime/size skip — the biggest lever
New
MusicScanSkipIndexloads the library's(path → mtime, size)map in ONE queryand the walk skips a matching file BEFORE
probeMetadata().Predicate: skip iff
mtimeANDsizeboth match what was recorded the last timethe tags were actually read. Stated failure mode: an in-place edit that preserves BOTH
(a padding tag editor plus
touch -r,cp --preserve=timestamps) is missed untilsomething touches the file. That is asserted as a known trade-off, not left implied, and
the escape hatch is operator-reachable without a code change:
No migration. The identity goes in
media_items.metadata_jsonasfile_mtime/file_size— the conventionAudioScanner:218-219andMusicLibraryManager:334-335already use for exactly this datum — somedia_itemsis notALTERed and the 13-member
typeENUM is not touched. Measured 90.9 bytes/entry, 5.30 MBfor the real 61,135-file library (16x cheaper per entry than the whole-tree map S95
removed).
Two one-query-per-scan gates keep it from regressing S96(e): the fast path is only
available when nothing is unhealed and no orphan is adoptable, and
$mayAdoptisre-read per file so a caught mid-walk failure disables it for the remainder. The
identity map JOINs
music_tracks, so amedia_itemsrow with no track row is neverskippable — without that, a lost file would be permanently lost instead of retried.
⚠ The first rescan after this ships is still slow. Every pre-S122 row carries no
stamp, so the first pass reads everything (as today) and stamps as it goes; the second is
the fast one. There is deliberately no filesystem backfill migration — recording
today's mtime for a row whose indexed tags might predate a change would manufacture
exactly the silent miss the predicate is designed to avoid.
(b) Read concurrency 1 → ≤ 4
New
MusicScanPrefetcherruns up to 3 reader processesLOOKAHEADfiles ahead of thewalk, warming the page cache for files it is about to probe. Hard-capped at 4:
The figures are cited in the class, in
config/scanner.php, and asserted by a test, soraising the cap means deleting a falsifiable claim rather than editing an integer. There is
no in-process alternative on this runtime:
SWOOLE_HOOK_FILEis excluded fromSwooleRuntime::SAFE_HOOK_NAMES(it crashed workers), there is noext-parallel, andconfig/process.phpcountmust stay 1 forLibraryScanWorker's reaper invariant.The pool is a page-cache warmer only — it cannot influence what is indexed — and
submit()drops work rather than ever blocking (pinned with a FIFO-wedged reader).scanner.music_read_concurrency = 1disables it entirely.(c) Tag reads — this one is a BUG, not a tuning result
getID3::analyze()never populates$info['comments']. That merged view is built onlyby
getid3_lib::CopyTagsToComments(), which no getID3 module and no part ofanalyze()ever calls. So
probeViaGetId3()returnedNULLfor every file ever scanned and100 % of the library fell through to ffprobe — after paying getID3's full read first.
ffprobe(what actually ran)strace -c)52x, ≈1.95 h of pure ffprobe over the production library on warm local disk, before any
sshfs latency. Fixed by calling
CopyTagsToComments(). Plus measured option tuning:−69 % bytes, −66 % reads, −68 % seeks on an encoder-written fixture, and
1,891,240 → 33,256 bytes retained per analyse.
⚠ CORRECTION to the vault diagnostic, measured:
option_save_attachments = falsedoesNOT reduce bytes read.
module.tag.id3v2.php:139reads the whole ID3v2 frame regioncontiguously before any frame is inspected; the option is consulted afterwards, at
:1448, only to decide whether to KEEP the picture — 598,585 bytes either way, to thebyte. It buys memory, not I/O. Removing that read needs a
vendor/patch, which isforbidden; the bytes lever for it is (a): an unchanged file is not opened at all.
Anti-tautology: measured, not claimed
12 of 50 new unit tests fail against
master(762c2c99) with onlyMusicLibraryScanner.php+config/scanner.phpreverted. The other 33 specify NEWclasses for which there is no prior, so each load-bearing claim is pinned by mutation
instead — 11 of 11 mutations RED, 11 of 11
RESTORED_OK:JOIN music_tracksfromload()isUnchanged()min()fromclampReaders()hasUnhealedMusicMediaItem()→falsecanSkip()ignores$mayAdoptload()also loads for a NULL library idprobeMetadata()skippedbranchCopyTagsToComments()removedscannedJOIN(lost-file retry)Two tests initially proved nothing and were fixed, not explained away (both fixture
defects, both found by mutation):
music_artists.media_item_idbut LEFT themedia_itemsrow, makingit an orphan — already caught by the other gate, so the heal gate was never under
test;
so the 32-album window could not overflow and
$mayAdoptcould not flip at all. Now 40albums with the first 33 touched: 40 probes with the per-file read, 33 without it.
A third defect was in the double: keying its branch on
JOIN music_tracksmeant deletingthat join from production SQL made the statement fall through and return nothing, where
real MySQL returns more rows. The branch is now recognised by the columns it asks for
and the join filter applied only when the statement contains it.
Real-DB tests for what a double cannot express
MusicScanSkipUnchangedIntegrationTest(5 tests, 58 assertions) drives the real getID3over a committed MP3 fixture against real MySQL 8.0, with 3 more mutations RED:
JSON_SET(COALESCE(metadata_json, JSON_OBJECT()), …)—JSON_SET(NULL, …)returnsNULL, so without the COALESCE the stamp UPDATE erases the document (
sub_typeand
namewith it). Mutation R1 RED.metadata_json->>'$.file_mtime'against a real JSON column + the JOIN. R2 RED.SELECT … UNION ALL … LIMIT 1must parse and be classified as aselectbyConnection::query()'s leading-keyword dispatch. Reformatting it so thekeyword is missed (the
workerman/mysqllandmine) is R3 RED.Verification
Tests: 7596 → 7652(+56), all pass, run against real MySQL 8.0 with all 95migrations applied. Only
Tests:is quoted —Assertions:/Skipped:drift.src/:[OK] No errors(exit 0).phpcs --standard=PSR12 -n src/: exit 0, zero output.phpcs --standard=PSR12 src/Server/: exit 0.composer validate: valid.MusicScanSkipIndex69/69 = 100 %,MusicScanPrefetcher84/91 =92.3 %,
MusicLibraryScanner636/676 = 94.1 %.phlix-vault-tune.service) untouched: out of scopeand already applied on prod.
🤖 Generated with Claude Code