Repository navigation
fix(files): isolate event handlers, harden storage moves, validate S3 prefix - #1455
Merged
Merged
Conversation
… 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
InMemoryEventBusran an event's handlers in order and stopped at the first exception. ForFileStorageKeyChangedIntegrationEvent, if the store rejected Files'DeleteSupersededObjectHandlerdelete, 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
AggregateExceptionwhen 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 anAggregateException.2. Finalize could return 500 for a file it had already committed
Problem:
FinalizeUploadCommandHandlermoves an in-flight legacy upload after the finalize commit. If the copy failed, the client got a 500 for a file that was alreadyAvailable, 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
MigrateLegacyPublicFileKeysJobon 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 outsidepublic/*, so it stays presigned-only.Test:
Finalize_Should_Succeed_And_LeaveTheFileAtItsLegacyKey_When_TheMoveAfterCommitFails(integration, RustFS). The handler runs with a store whose copy throws, returnsAvailable, and leaves the file at its legacy key. The migration job then moves it underpublic/.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 toStorageBytes, 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:Prefixis validated at startupProblem: with
Prefix = public, private keys were stored aspublic/private/...and fell under thepublic/*anonymous-read policy.Fix:
AddHeroStorageadds aValidate(...)next to the existingPresignServiceUrlrule, underValidateOnStart. It fails when the prefix's first segment ispublicorprivate(case-insensitive; a leading/and surrounding whitespace are ignored). The rule is only registered whenStorage:Provideriss3, so the DbMigrator and local-storage hosts are unaffected.Test:
S3PrefixValidationTests(14 cases). Valid prefixes such asapps/publicandpublicitypass;public,PRIVATE/,public/apps,/private/appsand similar fail; a local-provider host with a bad prefix does not fail.Verification
dotnet build src/FSH.Starter.slnx: 0 warnings, 0 errors.Docs: fullstackhero/docs#275 (changelog entry, plus updates to the Storage, Files and Eventing pages).
.agents/rules/eventing.mdandstorage.mdare updated in this PR.Closes #1423