Skip to content

fix(handler): run jobs without open transactions and survive lost connections - #9

Merged
Matys333 merged 5 commits into
devfrom
fix/job-handler-transactions
Oct 8, 2026
Merged

Matys333 merged 5 commits into
devfrom
fix/job-handler-transactions

Conversation

@sajfi

@sajfi sajfi commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

Problem

In production (depoto-server, Sentry TT-SERVER-CQ, 156 events) the messenger:consume job_message worker crashes with There is already an active transaction. It then picks up the same message again every hour (redeliver_timeout), so the same job is re-run in a poison message loop.

Cause in JobMessageHandler (v2.0.9 = v2.1.0):

  • while ($process->isRunning()) busy-loops with no sleep. On every iteration it calls $em->refresh($job) and flush(), which rewrites the whole, growing output inside an ORM transaction.
  • If the connection fails inside that transaction, DBAL resets its nesting level while PDO still believes a transaction is open.
  • The stock doctrine transport then sends the retry over the same default connection, fails in beginTransaction(), and the worker exits before it can reject the message.
  • A redelivered message re-runs a job that is already RUNNING.
  • output has no size cap, which most likely ends at max_allowed_packet.

Changes

  • JobOutputWriter: every write goes through DBAL in autocommit, never inside a transaction.
    • append (with a cap), readStatus, atomic markRunning claim.
    • finalize: a cancelled job stays cancelled.
    • cancel is a conditional UPDATE.
    • resetConnection() rolls back above the caller's nesting level and closes the connection when PDO is in a transaction DBAL doesn't know about.
  • JobMessageHandler rewrite:
    • A missing job throws UnrecoverableMessageHandlingException.
    • Loading and claiming the job are retried; if that keeps failing, the handler throws RecoverableMessageHandlingException (nothing has run yet).
    • A redelivered RUNNING job is marked FAILED and not re-run by default (rerun_on_redelivery).
    • The entity is detached after the claim.
    • The loop is throttled: 100 ms sleep, output writes and the cancel check at most once per poll_interval_ms.
    • A DB error inside the loop only counts towards db_failure_tolerance; output stays in memory and the command keeps running.
    • A failing command is recorded as a job status, not thrown as an exception.
    • Finalize is retried with a connection reset. Output the DB rejects is replaced with a note, so it can never block the job result.
    • Deleting the job while it runs stops the command.
  • Output handling:
    • Utf8Stream never splits a multi-byte character and replaces invalid bytes.
    • OutputLimiter caps the output (4 MB by default, 0 = unlimited).
    • OutputParamsParser accumulates params across chunks, including a line split between two chunks.
  • Config job_queue.processing: poll_interval_ms (1000), output_max_bytes (4194304), db_failure_tolerance (30), rerun_on_redelivery (false).
  • JobController::cancel uses a conditional UPDATE, so it cannot overwrite a finished result.
  • README / CHANGELOG (Unreleased 2.2.0):
    • recommend retry_strategy.max_retries: 0 and --keepalive,
    • don't use the doctrine_transaction middleware,
    • truncate the output of a stuck legacy job before upgrading.
  • composer / CI:
    • psr/log added to require, ext-pdo_sqlite to require-dev.
    • conflict: symfony/dependency-injection >=8: the bundle's XML service config can't be loaded on DI 8. This was already broken before this PR.
    • CI uses tools: flex, so SYMFONY_REQUIRE takes effect, and gains DBAL 3 / ORM 2 legs.

Behaviour changes (minor bump → 2.2.0)

  • Output is capped at 4 MB by default.
  • outputParams are written at finalize and accumulated.
  • A redelivered RUNNING job is not re-run.
  • The handler detaches the Job entity after the claim.

Tests

88 tests / 393 assertions:

  • unit tests;
  • SQLite integration tests with a real Process and a DBAL fault-injection middleware;
  • throttling (query count), no transaction during the loop;
  • connection loss and a stale PDO transaction (these fail when resetConnection() is a no-op);
  • finalize failure, redelivery, lost claim, cancel, job deleted mid-run;
  • output cap and UTF-8, output rejected by the DB (SQLite trigger), the caller's transaction.

They pass locally on DBAL 4.5/ORM 3.7, DBAL 3.10/ORM 3.7 and DBAL 3.10/ORM 2.20. The updated CI workflow runs for the first time on this PR.

Not covered: a MySQL 8 CI job (strict sql_mode, small max_allowed_packet, KILL CONNECTION). That is left as a follow-up.

Two independent reviews ran; their blocker/major findings are fixed in 46cbd52.

After merge (depoto-server)

  • Release v2.2.0.
  • composer update tomatom/jobqueuebundle.
  • max_retries: 0 for the job_message transport.
  • Before deploying, run the mitigation SQL for the stuck job (truncate its output and mark it failed).

🤖 Generated with Claude Code

sajfi and others added 5 commits October 8, 2026 15:04
OutputParamsParser accumulates OUTPUT PARAMS values across output chunks
and finds a line split between two chunks. OutputLimiter caps the stored
output and emits a truncation marker once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nections

The handler kept the job entity managed and flushed it from a busy loop,
rewriting the whole output on every iteration. A connection dropped inside
such a flush left pdo believing in a transaction DBAL had forgotten, and the
worker crashed in the retry send with "There is already an active
transaction"; the message was redelivered every redeliver_timeout.

- JobOutputWriter writes output, status and result through DBAL in
  autocommit and resets the connection after a lost connection or a stale
  native transaction
- the job is claimed atomically (planned -> running), redelivered messages
  of running/finished jobs do not run the command again
- the wait loop sleeps 100 ms and writes at most once per poll interval;
  database errors are tolerated up to db_failure_tolerance
- a failed command is a job status; only an unstorable result is thrown,
  as UnrecoverableMessageHandlingException
- a missing job is rejected without retry
- new config job_queue.processing: poll_interval_ms, output_max_bytes,
  db_failure_tolerance, rerun_on_redelivery

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Runs the real handler with real subprocesses against a temporary SQLite
database. A DBAL driver middleware injects lost connections (including the
stale pdo transaction of TT-SERVER-CQ) and the DBAL logging middleware counts
statements and transactions. Adds pdo_sqlite to the CI matrix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ock the result

- Utf8Stream holds back a character split between two pipe reads and scrubs
  bytes which are not UTF-8; OutputLimiter cuts at a character boundary. A
  strict MySQL utf8mb4 column rejected the broken character (1366) forever.
- Output a working database rejects (not a lost connection) is replaced by a
  note in the loop and in finalize, so it can neither kill a healthy command
  nor keep the result from being stored; a rejected result write is retried
  without output params and status message. Stored messages are bounded.
- The writer keeps the caller's transaction nesting level for every reset,
  not only in the handler's finally block.
- Finalize appends its status message only below the output cap.
- Loading and claiming the job are retried (a lost claim response is
  verified by status + started_at) and end in a recoverable exception; a
  missing job is looked up again before it is rejected.
- A rerun claim appends its note in the claim UPDATE, so MySQL always
  reports the changed row.
- A job deleted mid-run stops its command; Process output buffers are
  cleared after each read; stdout and stderr are parsed separately.
- The controller cancels by a conditional UPDATE, so it never overwrites a
  stored result.
- Tests: handler-level TT-SERVER-CQ guard with a non-ConnectionLost driver
  error plus a stale native transaction (loop and finalize), multibyte cap
  and split reads, rejected output, caller transaction, deleted job, lost
  claim race, pre-claim retries.
- CI: symfony/flex so SYMFONY_REQUIRE applies, DBAL 3 / ORM 2 legs;
  conflict with symfony/dependency-injection 8. Docs and changelog updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Matys333
Matys333 merged commit 81ec8de into dev Oct 8, 2026
22 checks passed
Matys333 added a commit that referenced this pull request Oct 8, 2026
…nections (#9) (#10)

* feat(output): add chunk-safe output params parser and output limiter

OutputParamsParser accumulates OUTPUT PARAMS values across output chunks
and finds a line split between two chunks. OutputLimiter caps the stored
output and emits a truncation marker once.



* fix(handler): run jobs without open transactions and survive lost connections

The handler kept the job entity managed and flushed it from a busy loop,
rewriting the whole output on every iteration. A connection dropped inside
such a flush left pdo believing in a transaction DBAL had forgotten, and the
worker crashed in the retry send with "There is already an active
transaction"; the message was redelivered every redeliver_timeout.

- JobOutputWriter writes output, status and result through DBAL in
  autocommit and resets the connection after a lost connection or a stale
  native transaction
- the job is claimed atomically (planned -> running), redelivered messages
  of running/finished jobs do not run the command again
- the wait loop sleeps 100 ms and writes at most once per poll interval;
  database errors are tolerated up to db_failure_tolerance
- a failed command is a job status; only an unstorable result is thrown,
  as UnrecoverableMessageHandlingException
- a missing job is rejected without retry
- new config job_queue.processing: poll_interval_ms, output_max_bytes,
  db_failure_tolerance, rerun_on_redelivery



* test(handler): add SQLite integration tests for job processing

Runs the real handler with real subprocesses against a temporary SQLite
database. A DBAL driver middleware injects lost connections (including the
stale pdo transaction of TT-SERVER-CQ) and the DBAL logging middleware counts
statements and transactions. Adds pdo_sqlite to the CI matrix.



* docs: document job processing options and messenger recommendations



* fix(handler): store only valid UTF-8 and never let rejected output block the result

- Utf8Stream holds back a character split between two pipe reads and scrubs
  bytes which are not UTF-8; OutputLimiter cuts at a character boundary. A
  strict MySQL utf8mb4 column rejected the broken character (1366) forever.
- Output a working database rejects (not a lost connection) is replaced by a
  note in the loop and in finalize, so it can neither kill a healthy command
  nor keep the result from being stored; a rejected result write is retried
  without output params and status message. Stored messages are bounded.
- The writer keeps the caller's transaction nesting level for every reset,
  not only in the handler's finally block.
- Finalize appends its status message only below the output cap.
- Loading and claiming the job are retried (a lost claim response is
  verified by status + started_at) and end in a recoverable exception; a
  missing job is looked up again before it is rejected.
- A rerun claim appends its note in the claim UPDATE, so MySQL always
  reports the changed row.
- A job deleted mid-run stops its command; Process output buffers are
  cleared after each read; stdout and stderr are parsed separately.
- The controller cancels by a conditional UPDATE, so it never overwrites a
  stored result.
- Tests: handler-level TT-SERVER-CQ guard with a non-ConnectionLost driver
  error plus a stale native transaction (loop and finalize), multibyte cap
  and split reads, rejected output, caller transaction, deleted job, lost
  claim race, pre-claim retries.
- CI: symfony/flex so SYMFONY_REQUIRE applies, DBAL 3 / ORM 2 legs;
  conflict with symfony/dependency-injection 8. Docs and changelog updated.



---------

Co-authored-by: Tomáš Naibrt <tomas@naibrt.cz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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.

2 participants