Repository navigation
fix(handler): run jobs without open transactions and survive lost connections - #9
Merged
Merged
Conversation
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
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>
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.
Problem
In production (depoto-server, Sentry TT-SERVER-CQ, 156 events) the
messenger:consume job_messageworker crashes withThere 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)andflush(), which rewrites the whole, growingoutputinside an ORM transaction.defaultconnection, fails inbeginTransaction(), and the worker exits before it canrejectthe message.outputhas no size cap, which most likely ends atmax_allowed_packet.Changes
JobOutputWriter: every write goes through DBAL in autocommit, never inside a transaction.append(with a cap),readStatus, atomicmarkRunningclaim.finalize: a cancelled job stays cancelled.cancelis 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.JobMessageHandlerrewrite:UnrecoverableMessageHandlingException.RecoverableMessageHandlingException(nothing has run yet).rerun_on_redelivery).poll_interval_ms.db_failure_tolerance; output stays in memory and the command keeps running.Utf8Streamnever splits a multi-byte character and replaces invalid bytes.OutputLimitercaps the output (4 MB by default,0= unlimited).OutputParamsParseraccumulates params across chunks, including a line split between two chunks.job_queue.processing:poll_interval_ms(1000),output_max_bytes(4194304),db_failure_tolerance(30),rerun_on_redelivery(false).JobController::canceluses a conditional UPDATE, so it cannot overwrite a finished result.retry_strategy.max_retries: 0and--keepalive,doctrine_transactionmiddleware,psr/logadded to require,ext-pdo_sqliteto 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.tools: flex, soSYMFONY_REQUIREtakes effect, and gains DBAL 3 / ORM 2 legs.Behaviour changes (minor bump → 2.2.0)
outputParamsare written at finalize and accumulated.Jobentity after the claim.Tests
88 tests / 393 assertions:
Processand a DBAL fault-injection middleware;resetConnection()is a no-op);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, smallmax_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)
composer update tomatom/jobqueuebundle.max_retries: 0for thejob_messagetransport.🤖 Generated with Claude Code