Skip to content

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 into
masterfrom
s122-music-scan-skip-unchanged-bounded-concurrency
Jul 27, 2026
Merged

S122 — music rescan: skip unchanged files (mtime/size), bound read concurrency at 4, fix the dead getID3 tag path#569
detain merged 8 commits into
masterfrom
s122-music-scan-skip-unchanged-bounded-concurrency

Conversation

@detain

@detain detain commented Jul 27, 2026

Copy link
Copy Markdown
Owner

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 the
work was for nothing.

(a) mtime/size skip — the biggest lever

New MusicScanSkipIndex loads the library's (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
(a padding tag editor plus touch -r, cp --preserve=timestamps) is missed until
something 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:

UPDATE media_items
   SET metadata_json = JSON_REMOVE(metadata_json, '$.file_mtime', '$.file_size')
 WHERE library_id = '' AND type = 'track';

No migration. The identity goes in media_items.metadata_json as
file_mtime/file_size — the convention AudioScanner:218-219 and
MusicLibraryManager:334-335 already use for exactly this datum — so media_items is not
ALTERed and the 13-member type ENUM is not touched. Measured 90.9 bytes/entry, 5.30 MB
for 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 $mayAdopt is
re-read per file so a caught 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 — 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 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:

threads ms/file files/s speedup
1 117.0 8.5 1.00x
4 67.7 14.8 1.73x
8 197.8 5.1 0.59x — worse than serial
16 115.9 8.6 1.01x

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 rather than editing an integer. There is
no in-process alternative on this runtime: SWOOLE_HOOK_FILE is excluded from
SwooleRuntime::SAFE_HOOK_NAMES (it crashed workers), there is no ext-parallel, and
config/process.php count must stay 1 for LibraryScanWorker'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 = 1 disables 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 only
by getid3_lib::CopyTagsToComments(), which no getID3 module and no part of analyze()
ever calls. So probeViaGetId3() returned NULL for every file ever scanned and
100 % of the library fell through to ffprobe — after paying getID3's full read first.

probe ms/file read syscalls
ffprobe (what actually ran) 114.9 232 (strace -c)
tuned getID3 (what was meant to run) 2.2

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 = false does
NOT reduce bytes read. module.tag.id3v2.php:139 reads the whole ID3v2 frame region
contiguously 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 the
byte. It buys memory, not I/O. Removing that read needs a vendor/ patch, which is
forbidden; 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 only
MusicLibraryScanner.php + config/scanner.php reverted. The other 33 specify NEW
classes 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:

mutation verdict
drop JOIN music_tracks from load() RED
compare only mtime in isUnchanged() RED
drop min() from clampReaders() RED
hasUnhealedMusicMediaItem()false RED
canSkip() ignores $mayAdopt RED
load() also loads for a NULL library id RED
skip moved AFTER probeMetadata() RED
no stamp on the skipped branch RED
CopyTagsToComments() removed RED
skip does not increment scanned RED
drop JOIN (lost-file retry) RED

Two tests initially proved nothing and were fixed, not explained away (both fixture
defects, both found by mutation):

  • the heal test nulled music_artists.media_item_id but LEFT the media_items row, making
    it an orphan — already caught by the other gate, so the heal gate was never under
    test;
  • 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 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.

A third defect was in the double: keying its branch on JOIN music_tracks meant deleting
that 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 getID3
over a committed MP3 fixture against real MySQL 8.0, with 3 more mutations RED:

  1. JSON_SET(COALESCE(metadata_json, JSON_OBJECT()), …)JSON_SET(NULL, …) returns
    NULL, so without the COALESCE the stamp UPDATE erases the document (sub_type
    and name with it). Mutation R1 RED.
  2. metadata_json->>'$.file_mtime' against a real JSON column + the JOIN. 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. Reformatting it so the
    keyword is missed (the workerman/mysql landmine) is R3 RED.

Verification

  • PHPUnit Tests: 7596 → 7652 (+56), all pass, run against real MySQL 8.0 with all 95
    migrations applied. Only Tests: is quoted — Assertions:/Skipped: drift.
  • PHPStan L9 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.
  • Coverage: MusicScanSkipIndex 69/69 = 100 %, MusicScanPrefetcher 84/91 =
    92.3 %
    , MusicLibraryScanner 636/676 = 94.1 %.
  • No scan was started anywhere — every behaviour is proved by fixtures and tests.
  • Infra levers (mount options, sysctls, phlix-vault-tune.service) untouched: out of scope
    and already applied on prod.

🤖 Generated with Claude Code

claude added 5 commits July 26, 2026 20:44
…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>
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@codacy-production

codacy-production Bot commented Jul 27, 2026

Copy link
Copy Markdown

Not up to standards ⛔

🔴 Issues 46 medium · 54 minor

Alerts:
⚠ 100 issues (≤ 0 issues of at least minor severity)

Results:
100 new issues

Category Results
UnusedCode 2 medium
BestPractice 21 medium
Documentation 7 minor
ErrorProne 5 medium
CodeStyle 27 minor
Complexity 18 medium
Comprehensibility 20 minor

View in Codacy

🟢 Metrics 506 complexity · 0 duplication

Metric Results
Complexity 506
Duplication 0

View in Codacy

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

codecov Bot commented Jul 27, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.85965% with 21 lines in your changes missing coverage. Please review.
✅ Project coverage is 65.48%. Comparing base (762c2c9) to head (039bfd1).

Files with missing lines Patch % Lines
src/Media/Music/MusicLibraryScanner.php 91.25% 14 Missing ⚠️
src/Media/Music/MusicScanPrefetcher.php 93.20% 7 Missing ⚠️
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.
📢 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.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

detain and others added 3 commits July 26, 2026 22:12
…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>
@detain
detain merged commit 5c0fdea into master Jul 27, 2026
16 of 17 checks passed
@detain
detain deleted the s122-music-scan-skip-unchanged-bounded-concurrency branch July 27, 2026 03:03
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