fix(events): fail closed when the enqueue fails - #6319
Conversation
|
Automated pre-review (advisory, not a required check) — verdict: HOLD · CI green HOLD — The enqueue-failure cleanup hard-deletes |
8de00e4 to
5a6964c
Compare
|
Automated pre-review (advisory, not a required check) — verdict: HOLD · CI green HOLD — the enqueue-failure cleanup hard-deletes Add direct job coverage proving that a queued |
## Context `Events::CreateService` commits the event row and only then enqueues `Events::PostProcessJob`. The two are not transactional and the rescue clauses cover only ActiveRecord errors, so when Redis is unavailable the row is committed and the job never runs. No pay-in-advance fee is ever created, and the caller cannot repair it: reposting the same transaction_id hits `index_unique_transaction_id` and answers `value_already_exist` forever. `Events::CreateBatchService` has the same shape with a wider blast radius, since `perform_all_later` is a single bulk push: one failure strands the whole batch. ## Description Removes the event when its post-processing cannot be enqueued, so the error the caller receives is honest and their retry succeeds instead of being refused as a duplicate. Nothing is silently accepted and then dropped. The event is hard-deleted rather than discarded, a deliberate exception to the usual rule for soft-deletable models: `index_unique_transaction_id` carries no `deleted_at` predicate, so a discarded event would keep answering the caller's retry with `value_already_exist` and leave them exactly as stuck. The enqueue also moves ahead of the Kafka produce, so a failed enqueue leaves nothing behind downstream either. `ActiveJob::SerializationError` is an `ArgumentError`, so an enqueue failure fell into the rescue written for the timestamp parse and was reported to the caller as an invalid timestamp. That rescue is now scoped to the parse itself. Note the trade-off: aggregation reads the `events` table directly, so a stranded event still billed correctly for pay-in-arrears charges. Rejecting it drops that usage unless the caller retries. This also narrows rather than closes the window, since a process killed between the commit and the cleanup still strands the event. Organizations on the ClickHouse events store are unaffected: they persist no row, so a failure there already left nothing behind. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
5a6964c to
fce3219
Compare
|
Automated pre-review (advisory, not a required check) — verdict: HOLD · CI green HOLD — both enqueue-failure handlers hard-delete
|
fce3219 to
01166eb
Compare
|
Automated pre-review (advisory, not a required check) — verdict: PASS · CI green PASS — The transaction/enqueue ordering is consistent across both PostgreSQL ingestion paths while preserving ClickHouse behavior. The focused specs cover rollback and retryability on enqueue failure, Kafka ordering, serialization errors, and the pre-commit deserialization race. |
01166eb to
fce3219
Compare
Part of ING-606
Context
Events::CreateServicecommits the event row and only then enqueuesEvents::PostProcessJob. The two are not transactional, so when Redis is down the row is committed and the job never runs: no pay-in-advance fee is created, and reposting the sametransaction_idanswersvalue_already_existforever.Events::CreateBatchServicehas the same shape, and one bulk push failure strands the whole batch.Description
ArgumentErrorrescue to the timestamp parse:ActiveJob::SerializationErroris anArgumentError, so an enqueue failure was reported as an invalid timestamp.Hard-deleted rather than discarded:
index_unique_transaction_idhas nodeleted_atpredicate, so a discarded event keeps refusing the retry. Enqueuing inside the transaction is worse: the job is pushed before the commit, so every event would race its own commit instead of only the rare accepted-then-failed enqueue.Behavior change to be aware of
eventsdirectly, so a stranded event still billed pay-in-arrears charges correctly. Rejecting it drops that usage unless the caller retries.