Repository navigation
Conversation
|
Pushed a follow-up commit. The batch lookup in the first commit does not cover the whole fan-out, and I would rather say that here than have it reviewed as though it did.
On a torrent client they usually do. My own test missed it because the items it builds carry no source files. The new one builds ten items with three files each. It reports 10 concurrent lookups without the follow-up commit and 1 with it, and I ran it with the production line reverted to confirm it fails rather than assuming it would. One test change came with it. Having missed one inside a method I had already read, I swept the rest rather than leave it at that. #870 says I had not checked the other gateway methods, and I can close that now. Three places resolve mappings, and all three are right: Outside the gateway there are only two callers of either method, both in Full suite: 3,031 passed, 0 failed, 125 skipped. The line in the description saying a thirty-item queue goes from up to sixty queries to one held only for items with no source files. I have corrected it to say the lookups per batch, which is the claim the tests actually support. |
…client's path mappings once per batch, not per item" This reverts commit f3ca823.
c00057e to
fb4ac27
Compare
…lient cache contract Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ady invalidates Listenarrs#535 removed the read side of the per-client mapping cache and left the three Remove("rpm_client_{id}") calls in create, update and delete, so the key is never populated and every translated path reads the mapping table. A two-item queue poll (3 and 5 source files) costs 12 reads, repeated on every poll interval. Populate the key with a short lifetime and hand each caller its own list. A keyed lock lets the concurrent translations of one poll share a single cold read. Reads still go through the repository, which opens a context per operation since Listenarrs#964, so this does not share a DbContext across the fan-out. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ng moved between clients Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…lock and clear both owners on a move A write could be lost to a cold read already in flight: the read fetched the old rows, the write saved and removed the key, then the read stored the old rows and hid the write for the rest of the lifetime. Create, update and delete now remove the key under the same keyed lock the fill holds, so a fill that started before the write always stores before the write clears it. UpdateAsync also clears the previous owner's key, because the endpoint accepts a changed client id and the old client would otherwise keep translating with the moved mapping. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
1e5d611 to
d29f381
Compare
|
Checked against current canary (e1a1d12). Merge: Full unfiltered suite on the head as published (d29f381, not merged): 3741 passed, 0 failed, 150 skipped, as the PR says. Canary alone: 3880 / 0 / 152. Not comparable: this branch sits on an older canary. Fail-without: with only RemotePathMappingService.cs reverted, 4 of the 7 new tests fail (a two-item poll queries 12 times and concurrent cold reads 8, instead of once each, plus two more). The other 3 cannot fail on the old code: there is no cache to go stale. I broke the fix two ways instead: invalidating outside the lock fails the in-flight create test, and not clearing the old owner fails the move test. Disclosure: drafted with Claude Code at my direction; I ran the cited checks at the stated commits and reviewed this before posting. |
therobbiedavis
left a comment
There was a problem hiding this comment.
@m4bard Found one race with overlapping mapping moves. Both updates can read the same old client, so the second update misses clearing the intermediate client’s cache. That client can keep translating paths through a mapping it no longer owns until the cache expires.
Reproduced this in a Linux container with SQLite. The regression test fails on this head and passes with the uncached service. A local fix passed the focused tests (53 passed, 2 skipped) and 100 overlapping moves without reproducing it.
The normal mapping API paths passed. I didn’t run real downloader imports, the full suite, or CodeQL.
| try | ||
| { | ||
| // A mapping can move to another client, so the old owner's entry is stale too. | ||
| await InvalidateClientCacheAsync(existing.DownloadClientId); |
There was a problem hiding this comment.
Both updates can read A as the old owner. The first moves the mapping to B and clears A/B. If a read fills B’s cache before the second update moves it to C, this only clears A/C and leaves B stale. I reproduced B still translating through that mapping after both requests returned. Please serialize the old-owner lookup, save, and invalidation across writers; locking the cache fill and removal separately doesn’t cover this.
… client Two updates that both read A as the old owner, or a delete that reads A while an update moves the mapping to B, leave B serving a mapping it no longer owns once a read has cached B between the two saves. Both tests fail on the current service. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ears across writers UpdateAsync read the old owner before taking any lock, so two overlapping moves of one mapping (A to B, A to C) could both see A. If a read cached B between the two saves, the second update cleared only A and C and B kept translating through a mapping it no longer owned until the entry expired. A delete racing a move had the same gap. Update and delete now run their lookup, write and both clears under one process-wide writer lock. The lock is taken before any per-client cache key and a fill never takes it, so the two locks cannot wait on each other. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A writer parked on a lookup gate holds the static writer lock, so a failure between the two releases left every later mapping write in the test run waiting forever. The helper now releases both gates in a finally. The writer lock's comment also says why it is static. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks, you are right. UpdateAsync read the old owner before taking any lock, so two overlapping moves could both see A, and the second one cleared only A and C. DeleteAsync had the same gap when it raced a move: it read A, the move cleared A and B, a read cached B, and the delete then cleared only A. Your note was about updates; I included delete because it has the same read-then-clear shape. Fixed in 15856cf. Update and delete now hold one process-wide writer lock (a static SemaphoreSlim, because the service is scoped) across GetByIdAsync, SaveAsync or DeleteAsync, and the cache clears after it. On lock order: a writer takes the writer lock first, then one ClientCacheLocks key at a time, never two keys at once. A cache fill takes only its own key and never the writer lock, so the two can't deadlock. Create has no previous owner to read, so it is left outside the lock. The tests came first, in 086b8ff, on top of d29f381. UpdateAsync_OverlappingMovesFromOneClient_ClearTheIntermediateClient holds the first update (A to B) right after its lookup and starts a second update (A to C). Once the first update has finished, the test reads B so B is cached, then lets the second update go. At the end B has to be empty. DeleteAsync_OverlappingAMoveFromOneClient_ClearsTheClientItMovedTo runs the same sequence with a delete as the second writer. With the service as it is at d29f381, both tests fail on the B assertion, 5 runs out of 5. With the fix both pass, 5 runs out of 5. If I take the lock off delete again, only the delete test fails. The test helper releases both of its gates in a finally (e15762e), so a failed step cannot leave a writer holding the lock and hang the rest of the run. Full backend suite on Linux, with nothing filtered out: 3743 passed, 0 failed, 150 skipped on e15762e. On d29f381 it was 3741 passed, 0 failed, 150 skipped. The two extra passes are the new tests, and both runs skipped the same tests. I have not run real downloader imports or CodeQL. Disclosure: drafted with Claude Code at my direction; I read the cited code at the stated commit and reviewed this before posting. |
Summary
I replaced this branch with a smaller change, because upstream moved underneath the old one. The old version resolved a client's mappings once per batch in
DownloadClientGateway. #964 (c07a9a2, #964) fixed the DbContext crash. What I think #964 left is the read volume: a two-item queue poll costs 12 reads of the mapping table (2 plus source files per item), repeated every poll interval. With this change it is 1.The service still removes
rpm_client_{id}on create, update and delete, but nothing populates that key, so every translated path reads the table. I think the #576 restructure (#535) dropped the cache by accident (inferred:git log -S rpm_client_shows 1d6a8e9, from #576, deleting the read and keeping the threeRemovecalls, and the commit message does not mention caching).Changes
Fixed
GetPathMappingByClientAsyncpopulates the existing key for 10 s and returns a copy to each caller.Only
RemotePathMappingServicechanges. #964's per-operation contexts and the gateway are untouched.Trade-offs
A mapping edited directly in the database (not through the service) is stale for up to 10 s; the three service methods are the only writers I found. A write on a client now waits for that client's in-flight fill, bounded by one read. The 10 s lifetime and the lock are judgement calls and easy to change.
Testing
Does that match what you would expect, and is 10 s a sensible lifetime?
Disclosure: drafted with Claude Code at my direction; I read the cited code at the stated commit and reviewed this before posting.