fix(files): physical-generation snapshots reject database-loaded observation timestamps - #902
Conversation
…s normalize to UTC kind (PR Listenarrs#902) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rvation timestamps ApplyPhysicalObjectIdentity requires DateTimeKind.Utc, but SQLite materializes the stored PhysicalIdentityObservedAtUtc as Unspecified. Two paths re-apply that database-loaded value verbatim and throw 'Physical identity observation time must be UTC', failing the scan or generation-refresh that triggered them: ClonePhysicalGeneration (hit live on a rescan of a book with an existing physical identity) and EfAudiobookFileRepository.ApplyPhysicalGeneration. Normalize with DateTime.SpecifyKind, mirroring the treatment already in RootFolderRelocationService.MetadataPlanning. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
0c31a42 to
c13420d
Compare
| DateTime.SpecifyKind( | ||
| source.PhysicalIdentityObservedAtUtc.Value, | ||
| DateTimeKind.Utc)); |
There was a problem hiding this comment.
I think we're repairing this one layer too late. PhysicalIdentityObservedAtUtc is UTC-by-contract — AudiobookFile.ApplyPhysicalObjectIdentity rejects anything else — but SQLite materialization currently allows an AudiobookFile to leave persistence with this property set to DateTimeKind.Unspecified.
Rather than requiring each consumer to know that persisted AudiobookFile timestamps need repairing, can we normalize this when EF materializes PhysicalIdentityObservedAtUtc, ideally in AudiobookFileConfiguration with a value conversion?
That would restore the entity invariant at the persistence boundary and keep ClonePhysicalGeneration persistence-agnostic. Otherwise the next consumer that reuses this property can reproduce the same failure.
| DateTime.SpecifyKind( | ||
| source.PhysicalIdentityObservedAtUtc.Value, | ||
| DateTimeKind.Utc)); |
There was a problem hiding this comment.
Can you clarify the production path this is intended to fix?
ApplyPhysicalGeneration appears to be used only by the non-relational fallback. SQLite takes the IsRelational() / ExecuteUpdateAsync path, and the source passed here is the caller-provided replacement rather than the database-loaded existing row.
So I don't think this method represents the SQLite failure described in the PR body. If we normalize PhysicalIdentityObservedAtUtc at EF materialization instead, I think this workaround becomes unnecessary as well.
If there is a path where a database-materialized source reaches this method, could we add a regression test demonstrating it?
therobbiedavis
left a comment
There was a problem hiding this comment.
The underlying bug looks valid, but I'd like regression coverage before we merge this.
Our normal BaseTests configuration uses EF InMemory, which preserves the DateTime.Kind and therefore cannot reproduce the SQLite behavior that caused this failure. The regression test needs to cross an actual SQLite persistence boundary:
Create an AudiobookFile with a UTC physical-identity observation timestamp.
Save it through SQLite.
Dispose that context.
Load the row through a fresh context.
Verify the loaded entity still satisfies the UTC contract.
I'd also like a test that drives the reported failure through RefreshPhysicalGenerationAsync using a persisted/reloaded file, since that proves the original ClonePhysicalGeneration exception cannot recur.
The fresh context is important here: an existing SQLite repository test saves and reloads through the same context, so EF can return the already-tracked UTC object instead of materializing the value back from SQLite. That's why the existing test suite doesn't catch this.
…edAtUtc at EF materialization Review rework: instead of repairing DateTimeKind in each consumer, add a materialization value conversion in AudiobookFileConfiguration so a loaded AudiobookFile always satisfies its own UTC contract, and revert both consumer-side SpecifyKind patches (the EfAudiobookFileRepository one also turned out to be the non-relational fallback with a caller-provided source, not the SQLite failure path). Regression coverage crosses a real SQLite persistence boundary: - fresh-context round-trip asserting the loaded timestamp keeps Utc kind and re-applies through the guarded domain method - RefreshPhysicalGenerationAsync driven against a SQLite-backed provider with registration and refresh in separate resolutions, reproducing the original ClonePhysicalGeneration exception when the conversion is absent (verified red without it) ServiceCollectionBuilder gains WithSqliteDatabase for tests that depend on real SQLite materialization semantics, which EF InMemory cannot reproduce. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Thanks for the review — all three points addressed in the follow-up commit:
Test infra note: |
… EF materialization (PR Listenarrs#902) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…verter-only shape Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The bug
AudiobookFile.ApplyPhysicalObjectIdentityguards that itsobservedAtUtcargument hasDateTimeKind.Utc— but SQLite/EF materializes the storedPhysicalIdentityObservedAtUtccolumn asUnspecified. Two paths re-apply that database-loaded value verbatim and throw:AudiobookFileService.ClonePhysicalGeneration— hit live on my instance: any rescan of a book that has an existing physical identity fails its scan job withSystem.ArgumentException: Physical identity observation time must be UTC.(stack:RefreshPhysicalGenerationCoreAsync→ClonePhysicalGeneration). This bites more often after upgrading across the v1.3.0→v1.3.1 identity-scheme change, since stale identities now commonly trigger generation refreshes.EfAudiobookFileRepository.ApplyPhysicalGeneration— same exposure when itssourcerow came from the database.The fix
Normalize with
DateTime.SpecifyKind(value, DateTimeKind.Utc)at both re-apply sites — the same treatmentRootFolderRelocationService.MetadataPlanningalready applies to this exact column, so this just closes the two spots that were missed.🤖 Generated with Claude Code