-
Notifications
You must be signed in to change notification settings - Fork 3
fix: keep a moved legacy item's row pointing at the object that exists #47
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
c6856b3
9e6f612
90efd2e
c15bea9
fc2e185
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,70 @@ | ||
| import { describe, it, expect, beforeEach, jest } from '@jest/globals'; | ||
| import { S3Service } from '../../services/S3Service'; | ||
| import { mockLoggerService } from '../setup'; | ||
|
|
||
| /** | ||
| * fileExists is tri-state, and LibraryService.processMovedFiles depends on the | ||
| * distinction: only a definitive `false` from BOTH the source and target probe | ||
| * lets it conclude a moved item's bytes are nowhere and leave source_path null. | ||
| * A probe that merely could not determine the answer must come back as null, or | ||
| * that path records "nothing exists" on an object it was simply unable to read. | ||
| */ | ||
| describe('S3Service.fileExists — tri-state', () => { | ||
| let service: S3Service; | ||
| let headObjectMock: jest.Mock; | ||
|
|
||
| beforeEach(() => { | ||
| service = new S3Service(); | ||
| headObjectMock = jest.fn(); | ||
| (service as any).client = { headObject: headObjectMock }; | ||
| (service as any)._logger = mockLoggerService; | ||
| mockLoggerService.log.mockClear(); | ||
| }); | ||
|
|
||
| it('returns true when the object is there', async () => { | ||
| headObjectMock.mockImplementation(async () => ({ | ||
| $metadata: { httpStatusCode: 200 }, | ||
| })); | ||
| await expect(service.fileExists('prefix/root/a.m4b')).resolves.toBe(true); | ||
| }); | ||
|
|
||
| it('returns false for a 404 — definitively absent', async () => { | ||
| headObjectMock.mockImplementation(async () => { | ||
| throw Object.assign(new Error('Not Found'), { | ||
| $metadata: { httpStatusCode: 404 }, | ||
| }); | ||
| }); | ||
| await expect(service.fileExists('prefix/root/a.m4b')).resolves.toBe(false); | ||
| }); | ||
|
|
||
| it('returns null for a 403 — denied is not the same as absent', async () => { | ||
| headObjectMock.mockImplementation(async () => { | ||
| throw Object.assign(new Error('Forbidden'), { | ||
| $metadata: { httpStatusCode: 403 }, | ||
| }); | ||
| }); | ||
| await expect(service.fileExists('prefix/root/a.m4b')).resolves.toBeNull(); | ||
| }); | ||
|
|
||
| it('returns null for any other failure', async () => { | ||
| headObjectMock.mockImplementation(async () => { | ||
| throw Object.assign(new Error('boom'), { | ||
| $metadata: { httpStatusCode: 500 }, | ||
| }); | ||
| }); | ||
| await expect(service.fileExists('prefix/root/a.m4b')).resolves.toBeNull(); | ||
| }); | ||
|
|
||
| it('does not log the storage prefix, which can be the account email', async () => { | ||
| headObjectMock.mockImplementation(async () => { | ||
| throw Object.assign(new Error('Forbidden'), { | ||
| $metadata: { httpStatusCode: 403 }, | ||
| }); | ||
| }); | ||
| await service.fileExists('someone@example.com/root/a.m4b'); | ||
|
|
||
| const logged = JSON.stringify(mockLoggerService.log.mock.calls); | ||
| expect(logged).not.toContain('someone@example.com'); | ||
| expect(logged).toContain('root/a.m4b'); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,37 @@ | ||
| import { describe, it, expect } from '@jest/globals'; | ||
| import { stripStoragePrefix } from '../../utils'; | ||
|
|
||
| /** | ||
| * stripStoragePrefix is what keeps a legacy account's email address out of | ||
| * CloudWatch: the per-user storage prefix is `users.external_id`, or that | ||
| * address for accounts predating it (see StoragePrefixService). These cases | ||
| * pin the edges so a later simplification cannot quietly reintroduce the leak. | ||
| */ | ||
| describe('stripStoragePrefix', () => { | ||
| it('drops the prefix segment and keeps the rest of the key', () => { | ||
| expect(stripStoragePrefix('someone@example.com/root/1_a.mp3')).toBe( | ||
| 'root/1_a.mp3', | ||
| ); | ||
| }); | ||
|
|
||
| it('keeps every segment after the first', () => { | ||
| expect( | ||
| stripStoragePrefix('someone@example.com/TV/Bluey/S01/[E01] Bike.mp3'), | ||
| ).toBe('TV/Bluey/S01/[E01] Bike.mp3'); | ||
| }); | ||
|
|
||
| it('returns empty for a bare prefix, rather than echoing the address', () => { | ||
| expect(stripStoragePrefix('someone@example.com')).toBe(''); | ||
| }); | ||
|
|
||
| it('returns empty for missing or empty input', () => { | ||
| expect(stripStoragePrefix(undefined)).toBe(''); | ||
| expect(stripStoragePrefix('')).toBe(''); | ||
| }); | ||
|
|
||
| it('handles an external_id prefix the same way', () => { | ||
| expect( | ||
| stripStoragePrefix('001172.22b2d822a90b45bf8c4d250c3dda4d6a.1714/root/x.m4b'), | ||
| ).toBe('root/x.m4b'); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1253,11 +1253,7 @@ export class LibraryService { | |
| !fileMoved.source_path && | ||
| parseInt(fileMoved.type) === parseInt(LibraryItemType.BOOK) | ||
| ) { | ||
| const suffix = | ||
| parseInt(fileMoved.type) === parseInt(LibraryItemType.BOOK) | ||
| ? '' | ||
| : '/'; | ||
| const sourceKey = `${storagePrefix}/${fileMoved.old_key}${suffix}`; | ||
| const sourceKey = `${storagePrefix}/${fileMoved.old_key}`; | ||
| const original_filename = `${ | ||
| process.env.ROOT_FOLDER | ||
| }/${moment().format('YYYYMMDDHHmmss')}_${ | ||
|
|
@@ -1268,16 +1264,83 @@ export class LibraryService { | |
| sourceKey, | ||
| targetKey, | ||
| }); | ||
| if (isMoved) { | ||
| await this._libraryDB.updateBySourcePath( | ||
| // Either way the row must end up naming the object that actually | ||
| // exists. A legacy item (source_path IS NULL) is read back at | ||
| // `${prefix}/${key}`, so once the key rewrite commits, an object | ||
| // still sitting at old_key is unreachable — the row would point at | ||
| // nothing while the bytes are orphaned under the pre-move path. | ||
| // | ||
| // Throwing to roll the rewrite back is not an option: the items | ||
| // relocated earlier in this batch are already at their new keys, | ||
| // and the rollback would strip the source_path that names them, | ||
| // orphaning those instead. So on failure we record where the file | ||
| // really is. The move itself still succeeds — it is a display-path | ||
| // change — and the item stays playable from its legacy key. | ||
| // Where the object is, as best we can establish it. | ||
| let pinnedSourcePath = original_filename; | ||
|
|
||
| if (!isMoved) { | ||
| // A failed move does not locate the bytes on its own. moveFile | ||
| // is copy-then-delete, so the failure may be a copy that never | ||
| // landed (bytes at old_key) or a delete that landed on S3 but | ||
| // lost its response (bytes at the target, source already gone). | ||
| // fileExists also reports 403 as false, so a false is "could not | ||
| // find it", not "it is not there". Probe before concluding. | ||
| const sourceStillThere = await this._storage.fileExists({ | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 INFO — These probes run inside the caller's transaction ( The S3 client has no explicit timeout configured here, so consider a short
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Flagging this one for a maintainer rather than picking an option. Worth noting the framing: S3 calls already ran inside this transaction before the PR — moveFile is a copy plus a delete per item, called from the same place with the same trx. The probes add one or two HEADs on the failure path only, so this is roughly a 1.5-2x increase in round trips on an already-pathological path, not a new class of exposure. That is a reason to weigh the options rather than to rush one in. The suggestions differ materially:
Different trade-offs on different axes, so leaving it for the maintainers to choose as a follow-up. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Reported again on the newest commit, worded differently — the current wording is: Still open from the previous push: these probes run inside the caller's transaction ( Concrete options: configure a short |
||
| key: sourceKey, | ||
| }); | ||
| const targetLanded = | ||
| sourceStillThere === false | ||
| ? await this._storage.fileExists({ key: targetKey }) | ||
| : null; | ||
|
|
||
| // Anything short of a definitive "source is gone" means the | ||
| // object is, or is presumed, still at the old key — the common | ||
| // case, and the safe default when the probe itself failed | ||
| // (fileExists returns null then). | ||
| if (sourceStillThere !== false) { | ||
| pinnedSourcePath = fileMoved.old_key; | ||
| } | ||
|
|
||
| // Nothing found at either key. Pinning would record a path that | ||
| // holds nothing and freeze the row: the guard above skips items | ||
| // that already have a source_path, so no later move would retry | ||
| // the relocation. Leave it null instead — still broken, but | ||
| // still detectable and still retryable. | ||
| const foundNothing = | ||
|
GianniCarlo marked this conversation as resolved.
|
||
| sourceStillThere === false && targetLanded !== true; | ||
|
|
||
| this._logger.log( | ||
| { | ||
| user_id: user.id_user, | ||
| key: fileMoved.key, | ||
| source_path: original_filename, | ||
| origin: 'LibraryService.processMovedFiles', | ||
| message: 'Storage relocation failed', | ||
| data: { | ||
| id_user: user.id_user, | ||
| oldKey: fileMoved.old_key, | ||
| newKey: fileMoved.key, | ||
| sourceStillThere, | ||
| targetLanded, | ||
| // The decision itself, not just its inputs. A remediation | ||
| // sweep can then separate a confirmed phantom (both | ||
| // probes 404) from one where the target probe only came | ||
| // back indeterminate. | ||
| foundNothing, | ||
| targetIndeterminate: targetLanded === null, | ||
| pinnedSourcePath: foundNothing ? null : pinnedSourcePath, | ||
| }, | ||
| }, | ||
| trx, | ||
| 'error', | ||
| ); | ||
| if (foundNothing) continue; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 INFO — The give-up branch leaves the row in the exact state the PR describes as invisible-by-default: Since the durable audit table already exists, consider making |
||
| } | ||
| await this._libraryDB.updateBySourcePath( | ||
| { | ||
| user_id: user.id_user, | ||
| key: fileMoved.key, | ||
| source_path: pinnedSourcePath, | ||
| }, | ||
| trx, | ||
| ); | ||
| } | ||
| } | ||
| }), | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.