<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:blog="https://jonesrussell.github.io/blog/ns"><channel><title>Queue on Web Developer Blog</title><link>https://jonesrussell.github.io/blog/tags/queue/</link><description>Recent content in Queue on Web Developer Blog</description><image><title>Web Developer Blog</title><url>https://jonesrussell.github.io/blog/images/og-default.png</url><link>https://jonesrussell.github.io/blog/images/og-default.png</link></image><generator>Hugo -- 0.166.0</generator><language>en-us</language><lastBuildDate>Fri, 11 Sep 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://jonesrussell.github.io/blog/tags/queue/feed.xml" rel="self" type="application/rss+xml"/><item><title>Fixing a silent message-drop bug in Waaseyaa's queue worker</title><link>https://jonesrussell.github.io/blog/unhandled-queue-message-silent-ack/</link><pubDate>Fri, 11 Sep 2026 00:00:00 +0000</pubDate><guid>https://jonesrussell.github.io/blog/unhandled-queue-message-silent-ack/</guid><category>php</category><category>waaseyaa</category><blog:tag>php</blog:tag><blog:tag>waaseyaa</blog:tag><blog:tag>queue</blog:tag><blog:tag>reliability</blog:tag><description>How Waaseyaa&amp;rsquo;s queue worker silently acknowledged persistent messages with no matching handler, and the fail-closed fix that routes them through retry and the failed-job repository instead.</description><content:encoded><![CDATA[<p>Ahnii!</p>
<p><a href="https://github.com/waaseyaa/framework">Waaseyaa</a>&rsquo;s <code>packages/queue</code> package runs background jobs through a <code>Worker</code> that pops a message off a transport and hands it to whichever handler in its roster <code>supports()</code> that message type. If nothing in the roster claimed a message, the worker didn&rsquo;t fail the delivery — it just finished the loop, returned normally, and acknowledged the message as done. Here&rsquo;s the bug, the fix, and why &ldquo;no handler matched&rdquo; needs to be a failure, not a no-op.</p>
<h2 id="the-bug-an-empty-loop-still-counts-as-success">The Bug: An Empty Loop Still Counts as Success</h2>
<p><code>Worker::handleMessage()</code> walked the handler list looking for the first one that supported the message:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">private</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">handleMessage</span>(<span style="color:#a6e22e">object</span> $message)<span style="color:#f92672">:</span> <span style="color:#a6e22e">void</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">foreach</span> ($this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">handlers</span> <span style="color:#66d9ef">as</span> $handler) {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> ($handler<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">supports</span>($message)) {
</span></span><span style="display:flex;"><span>            $handler<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">handle</span>($message);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">return</span>;
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>If no handler supports the message, the loop just runs out. The method returns void either way, so from the caller&rsquo;s point of view a message nobody handled looks identical to a message a handler successfully processed. <code>processJob()</code> treats that return as success and acks the delivery:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">try</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// ... envelope/occurrence handling ...
</span></span></span><span style="display:flex;"><span>    $this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">handleMessage</span>($message);
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// ...
</span></span></span><span style="display:flex;"><span>    $this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">transport</span><span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">ack</span>($raw[<span style="color:#e6db74">&#39;id&#39;</span>]);
</span></span><span style="display:flex;"><span>} <span style="color:#66d9ef">catch</span> (<span style="color:#a6e22e">\Throwable</span> $e) {
</span></span><span style="display:flex;"><span>    $this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">handleFailure</span>($raw, $queue, $message, $e, $options, $envelope<span style="color:#f92672">?-&gt;</span><span style="color:#a6e22e">occurrence</span>);
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><code>QueueInterface</code>/<code>DbalQueue</code> accept any object for dispatch, not just <code>Job</code> — but the worker&rsquo;s own handler roster, by default, only knows how to run <code>Job</code>. Dispatch a plain message object with no registered handler and the worker pulls it off the queue, runs an empty loop, and acks it. No retry, no dead-letter row, nothing written to the failed-job repository. The durable row just disappears, and nothing downstream can tell the difference between &ldquo;handled&rdquo; and &ldquo;nobody was listening.&rdquo;</p>
<h2 id="the-fix-throw-instead-of-falling-through">The Fix: Throw Instead of Falling Through</h2>
<p>The fix adds a typed, payload-free exception:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">final</span> <span style="color:#66d9ef">class</span> <span style="color:#a6e22e">UnhandledQueueMessage</span> <span style="color:#66d9ef">extends</span> <span style="color:#a6e22e">\RuntimeException</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">__construct</span>(<span style="color:#a6e22e">object</span> $message)
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">parent</span><span style="color:#f92672">::</span><span style="color:#a6e22e">__construct</span>(<span style="color:#a6e22e">sprintf</span>(
</span></span><span style="display:flex;"><span>            <span style="color:#e6db74">&#39;No queue handler supports message type &#34;%s&#34;.&#39;</span>,
</span></span><span style="display:flex;"><span>            $message<span style="color:#f92672">::</span><span style="color:#a6e22e">class</span>,
</span></span><span style="display:flex;"><span>        ));
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>and <code>handleMessage()</code> throws it once the roster is exhausted instead of returning:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">private</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">handleMessage</span>(<span style="color:#a6e22e">object</span> $message)<span style="color:#f92672">:</span> <span style="color:#a6e22e">void</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">foreach</span> ($this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">handlers</span> <span style="color:#66d9ef">as</span> $handler) {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> ($handler<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">supports</span>($message)) {
</span></span><span style="display:flex;"><span>            $handler<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">handle</span>($message);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">return</span>;
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">throw</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">UnhandledQueueMessage</span>($message);
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>That single <code>throw</code> is enough to route an unsupported message through machinery <code>processJob()</code> already had for every other kind of failure. A few things about that path are worth calling out:</p>
<ul>
<li><strong>It reuses the existing retry policy.</strong> The <code>catch</code> block hands the exception to <code>handleFailure()</code>, which applies the worker&rsquo;s normal bounded retry/backoff — <code>Job::$tries</code> for jobs, <code>WorkerOptions::$maxTries</code> otherwise (three attempts by default for non-<code>Job</code> messages).</li>
<li><strong>The failure record names only the class, not the payload.</strong> <code>UnhandledQueueMessage</code>&rsquo;s message is <code>No queue handler supports message type &quot;...&quot;</code> — useful for an operator, safe to log.</li>
<li><strong>Ordering matters.</strong> The failed-job row is persisted <em>before</em> the delivery is rejected. If the failed-job repository itself is down, the rejection doesn&rsquo;t happen either, so the message stays reserved for lease recovery instead of being lost a second way.</li>
<li><strong>Nothing about dispatch changed.</strong> <code>QueueInterface</code> still accepts any object. This isn&rsquo;t about narrowing what you&rsquo;re allowed to queue — it&rsquo;s about not silently discarding what the worker can&rsquo;t run.</li>
</ul>
<p>The package README now says this out loud instead of leaving it to be discovered:</p>
<blockquote>
<p>Persistent dispatch accepts any object, but successful consumption requires a supporting worker handler. If no handler supports an accepted message, <code>Worker</code> raises a typed <code>UnhandledQueueMessage</code> failure and applies its configured bounded retry/backoff policy. On exhaustion, the signed payload and failure are stored in the failed-job repository before the delivery is rejected; it is never silently acknowledged.</p>
</blockquote>
<h2 id="verifying-it">Verifying It</h2>
<p>A new <code>QueueServiceProviderUnhandledMessageTest</code> drives the fix through the real database-backed composition — <code>QueueServiceProvider</code>, <code>DbalQueue</code>, <code>DbalTransport</code>, and <code>DatabaseFailedJobRepository</code> — instead of a bare <code>Worker</code> in isolation:</p>
<table>
	<thead>
			<tr>
					<th>Test</th>
					<th>What it proves</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><code>acceptedUnsupportedMessageRetriesThenFailsDurablyInsteadOfBeingAcknowledged</code></td>
					<td>An unsupported message is released once on the first attempt, then durably failed and recorded on the second — never silently acked</td>
			</tr>
			<tr>
					<td><code>firstSupportingCustomHandlerExecutesOnceAndAcknowledgesNormally</code></td>
					<td>A message with a matching handler still runs exactly once and acks normally</td>
			</tr>
			<tr>
					<td><code>providerJobHandlerStillExecutesVoidJobAndAcknowledgesNormally</code></td>
					<td>Existing <code>Job</code> dispatch through the provider is unaffected</td>
			</tr>
	</tbody>
</table>
<p>A fourth case, <code>failedRepositoryOutagePreservesUnsupportedDeliveryForLeaseRecovery</code>, goes the other way on purpose: it&rsquo;s added to the existing <code>WorkerTest</code> and wires up a bare <code>Worker</code> with a stub <code>FailedJobRepositoryInterface</code> that throws on <code>record()</code> — not something you can provoke on demand through the real <code>DatabaseFailedJobRepository</code>. That&rsquo;s the test that would have caught a version of this fix that traded &ldquo;silently ack an unhandled message&rdquo; for &ldquo;silently lose it if the failed-job store is unavailable&rdquo;: when the stub throws, the delivery stays <code>in_progress</code> for lease recovery instead of vanishing a second way.</p>
<p>Both files pass alongside the rest of the suite. The queue package&rsquo;s unit and contract tests ran at <strong>236 tests, 629 assertions</strong> after the change — the provider-composition cases alone account for 3 tests and 40 assertions, the outage control for 1 test and 5 assertions.</p>
<h2 id="the-general-lesson">The General Lesson</h2>
<p>A dispatch loop that runs out of candidates without doing anything is easy to write and easy to miss, because &ldquo;no handler matched&rdquo; doesn&rsquo;t read like an error — it reads like the absence of work. But a message pulled off a durable queue was never optional work; it&rsquo;s a promise made to whoever dispatched it. If your dispatch loop can finish without ever calling a handler, and the caller can&rsquo;t tell that apart from a real success, you don&rsquo;t have a queue — you have a way to accept work and then forget it happened. The fix here was one <code>throw</code> statement. What made it correct was that it plugged into retry, backoff, and dead-letter paths that already existed, instead of inventing a bespoke error path for one more corner case.</p>
<p>Baamaapii</p>
]]></content:encoded></item></channel></rss>