Skip to content

fix(flow-producer): surface aborted transactions instead of silent fail - #4111

Open
mohanrajvenkatesan23-04 wants to merge 2 commits into
taskforcesh:masterfrom
mohanrajvenkatesan23-04:fix/issue-3851-flowproducer-silent-fail
Open

fix(flow-producer): surface aborted transactions instead of silent fail#4111
mohanrajvenkatesan23-04 wants to merge 2 commits into
taskforcesh:masterfrom
mohanrajvenkatesan23-04:fix/issue-3851-flowproducer-silent-fail

Conversation

@mohanrajvenkatesan23-04

Copy link
Copy Markdown
Contributor

Port Impact Checklist

  • Python – does this change need to be ported or documented in the Python library?
  • Elixir – does this change need to be ported or documented in the Elixir library?
  • PHP – does this change need to be ported or documented in the PHP library?

Why

Fixes #3851.

FlowProducer.add() and FlowProducer.addBulk() swallowed multi.exec() returning null. ioredis returns null from Pipeline.exec() whenever the transaction was aborted — for example issued against a READONLY replica, a WATCH conflict, or a pipeline-level error — and the producer would happily resolve with a half-formed JobNode whose job.id was the locally-generated UUID, pointing at nothing in Redis.

The reporter saw exactly that shape under an Upstash failover window where Redis is briefly read-only:

Adding jobs with FlowProducer...
Job added with FlowProducer unexpectedly: 6d82aace-3808-4750-8040-f9feb5149023
.add() did not throw but flow does not exist: undefined

Queue.add() correctly throws in the same scenario; the bug was in flow-producer.ts:225 and the parallel block in addBulk at line 302.

How

Two paths needed slightly different treatment because the API contract differs.

  • add() (single flow — atomic by design): throw when results === null, and throw the first per-command error if any. Without this, a transaction abort or a per-command failure leaves the user with a JobNode whose UUID id is unrecoverable.
  • addBulk() (partial-success semantics): an existing regression test (should not corrupt id mapping for successful jobs when some addBulk commands fail) explicitly relies on addBulk letting some root commands fail while others succeed. So we throw only on results === null (the entire transaction aborted, every flow is dead). Per-command errors continue to leave that root's job.id unassigned, and callers detect partial failure via await queue.getJob(tree.job.id) === undefined — the same contract that test asserts.

The error message names the cause:

Flow could not be added: Redis transaction was aborted

Additional Notes (Optional)

  • Two new regression tests in tests/flow.test.ts use a vitest spy on the next client.multi() call to swap in a multi whose .exec() returns null. Both add() and addBulk() are asserted to throw /transaction was aborted/. The original Pipeline is otherwise pristine — no impact on neighbouring tests.
  • The existing partial-success test (should not corrupt id mapping...) is preserved unchanged and still passes against the new logic.
  • TypeScript / Node-only. No port work required.

FlowProducer.add() and addBulk() ignored a `null` return from
`multi.exec()`. ioredis returns null when the multi was aborted
(e.g. issued against a READONLY replica, a WATCH violation, or a
pipeline error), and the producer would happily resolve with a
half-formed JobNode whose `job.id` was just the locally-generated
UUID — pointing at nothing in Redis. The reporter saw exactly that
shape under an Upstash failover window where Redis briefly went
read-only.

This commit:

- For `add()` (single flow, atomic by design): throws on a null
  result and on the first per-command error.
- For `addBulk()` (partial-success semantics, codified by an
  existing regression test): throws only on a null result.
  Per-command errors continue to leave that root's `job.id`
  unassigned so callers can detect partial failures by checking
  `await queue.getJob(tree.job.id)` — the same contract the existing
  "should not corrupt id mapping" test exercises.

Adds two vitest spies on `client.multi` that override `exec()` to
return null and asserts both code paths throw with a clear message.

Fixes taskforcesh#3851
@manast
manast requested a review from Copilot July 15, 2026 18:08
@manast

manast commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

@copilot resolve the merge conflicts in this pull request

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes a FlowProducer edge case where FlowProducer.add() / addBulk() could silently succeed when multi.exec() returns null (aborted transaction), returning a JobNode whose ID doesn’t correspond to anything in Redis.

Changes:

  • FlowProducer.add(): throws when multi.exec() returns null and now surfaces per-command Errors from exec() results.
  • FlowProducer.addBulk(): throws when multi.exec() returns null while preserving existing partial-success semantics for per-command failures.
  • Adds regression tests that mock multi.exec() to return null and assert both APIs throw.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 4 comments.

File Description
src/classes/flow-producer.ts Adds explicit handling for aborted transactions (exec() === null) and improves error surfacing behavior.
tests/flow.test.ts Adds regression coverage for aborted transactions via a mocked multi.exec() returning null.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/classes/flow-producer.ts Outdated
Comment thread src/classes/flow-producer.ts Outdated
Comment thread tests/flow.test.ts Outdated
Comment thread tests/flow.test.ts Outdated
@manast

manast commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

@copilot resolve the merge conflicts in this pull request

- Use explicit results === null check in add and addBulk so an empty
  exec() result is not misclassified as an aborted transaction
- Use the public FlowProducer client getter in tests instead of
  reaching into (flow as any).connection.client
@mohanrajvenkatesan23-04

Copy link
Copy Markdown
Contributor Author

Thanks for the review! Pushed a follow-up commit addressing all four comments:

  • flow-producer.ts (add and addBulk): switched the aborted-transaction guard to an explicit results === null check so an empty exec() result is no longer misclassified as an abort.
  • flow.test.ts (both spots): now use the public flow.client getter instead of (flow as any).connection.client, dropping the any cast and the coupling to internals.

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.

[Bug]: FlowProducer.add() can fail silently

3 participants