Skip to content

fix(files): isolate event handlers, harden storage moves, validate S3 prefix - #1455

Merged
iammukeshm merged 2 commits into
mainfrom
fix/storage-relocation-followups
Nov 3, 2026
Merged

iammukeshm merged 2 commits into
mainfrom
fix/storage-relocation-followups

Conversation

@iammukeshm

Copy link
Copy Markdown
Member

Follow-ups from the review of #1422. Four items, each with its own test.

1. One failing handler blocked the others for the same event

Problem: InMemoryEventBus ran an event's handlers in order and stopped at the first exception. For FileStorageKeyChangedIntegrationEvent, if the store rejected Files' DeleteSupersededObjectHandler delete, Catalog's product-image URL rewrite was skipped until the outbox retry, and never ran at all if the delete kept failing until the row dead-lettered.

Fix: the bus now runs every handler, collects failures, and rethrows afterwards (the original exception when one fails, an AggregateException when several do), so the outbox still retries. The inbox already dedups by (EventId, HandlerName) and only marks a handler after it succeeds, so a retry re-runs just the failed handlers.

Why this instead of a separate Files-only event: the problem isn't specific to this event. Every event that has more than one handler had it, including the Webhooks fan-out handler that is registered for every event. A Files-only event would have fixed this one case, added a new event type (which the fan-out would also send to tenant webhooks), and still left Identity's avatar handler able to block Catalog. The bus change is about 30 lines in one BuildingBlocks file and keeps the outbox's retry and dead-letter behaviour as it was.

Test: InMemoryEventBusHandlerIsolationTests (3 tests). A failing handler registered first doesn't stop a later one and the error still surfaces; a retry re-runs only the failed handler; two failures come back as an AggregateException.

2. Finalize could return 500 for a file it had already committed

Problem: FinalizeUploadCommandHandler moves an in-flight legacy upload after the finalize commit. If the copy failed, the client got a 500 for a file that was already Available, and a retried finalize returned 409.

Fix: the move is now best-effort. On failure it logs a structured warning and returns the committed file. A public file is moved by MigrateLegacyPublicFileKeysJob on its next run (every API start). A private file stays at its legacy key, because the job only handles public files. That is safe: no bucket policy grants anonymous read outside public/*, so it stays presigned-only.

Test: Finalize_Should_Succeed_And_LeaveTheFileAtItsLegacyKey_When_TheMoveAfterCommitFails (integration, RustFS). The handler runs with a store whose copy throws, returns Available, and leaves the file at its legacy key. The migration job then moves it under public/.

3. A copy whose commit failed was left orphaned and charged to quota

Problem: in FileStorageRelocator, if the copy succeeded and the transaction then failed, the new object stayed in the bucket and was charged to StorageBytes, and nothing ever pointed at it.

Fix: if the move fails after the copy, the relocator removes the copy through DeleteIfUnreferencedAsync. That runs under the file's row lock, so it won't touch a copy a concurrent move has since committed. The quota decorator refunds the bytes once the object is gone. The cleanup is best-effort and logged, and the original exception is still rethrown.

Test: ChangeVisibility_Should_RemoveTheCopy_And_RefundItsBytes_When_TheCommitFailsAfterTheCopy (integration). An outbox write that throws aborts the transaction after the copy. The row and the private object are unchanged, the public copy is gone, and the in-memory quota counter is back to 0.

4. Storage:S3:Prefix is validated at startup

Problem: with Prefix = public, private keys were stored as public/private/... and fell under the public/* anonymous-read policy.

Fix: AddHeroStorage adds a Validate(...) next to the existing PresignServiceUrl rule, under ValidateOnStart. It fails when the prefix's first segment is public or private (case-insensitive; a leading / and surrounding whitespace are ignored). The rule is only registered when Storage:Provider is s3, so the DbMigrator and local-storage hosts are unaffected.

Test: S3PrefixValidationTests (14 cases). Valid prefixes such as apps/public and publicity pass; public, PRIVATE/, public/apps, /private/apps and similar fail; a local-provider host with a bad prefix does not fail.

Verification

  • dotnet build src/FSH.Starter.slnx: 0 warnings, 0 errors.
  • Unit: Framework 363, Files 52, Catalog 65, Architecture 55, Webhooks 69, Notifications 13, all passing.
  • Integration (full suite, Testcontainers): 792 passed, 0 failed.
  • Before the fixes, the new tests for items 1, 2 and 3 fail.

Docs: fullstackhero/docs#275 (changelog entry, plus updates to the Storage, Files and Eventing pages). .agents/rules/eventing.md and storage.md are updated in this PR.

Closes #1423

… prefix

- InMemoryEventBus runs every handler for an event and rethrows afterwards,
  so one failing handler (Files' superseded-object delete) no longer skips
  the others (Catalog's URL rewrite); the inbox re-runs only failed ones.
- FinalizeUpload treats the post-commit legacy-key move as best-effort and
  returns the committed file instead of a 500.
- FileStorageRelocator removes (and refunds) the copy when the commit fails.
- AddHeroStorage fails startup when Storage:S3:Prefix starts with public or
  private.

Closes #1423
A failed handler could leave tracked but unsaved changes on a scoped
DbContext, and the next handler's SaveChanges in the shared scope would
persist them now that dispatch continues past a failure. Also invoke
handlers with DoNotWrapExceptions so a synchronous throw surfaces as
itself instead of a TargetInvocationException.
@iammukeshm
iammukeshm merged commit f97efcf into main Nov 3, 2026
17 checks passed
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.

Storage relocation follow-ups: handler isolation, finalize after move, orphaned copy on failed commit

1 participant