Repository source: docs/plans/production_stall_busy_page_findings_v1.md
Production Stall and Busy Page Findings V1
Question
Production sometimes appears to stall for a long time instead of either returning the requested page or returning the busy page.
This investigation focused on the PHP front-controller path, static HTML artifact fallback, read-model rebuild locking, and write-path locking.
Short Answer
The busy page only covers one narrow failure mode: a Timed out waiting for execution lock: exception that escapes to FrontController::handle().
Several production paths can legitimately block without producing that exception at the outer front-controller level:
a request that wins the execution lock can spend a long time rebuilding the read model;
an anonymous cache-miss request renders the page and then synchronously builds the missing static artifact before PHP finishes the response;
lock timeouts raised during that best-effort artifact build are swallowed, so the request waits and then returns the original page rather than the busy page;
full static artifact builds rebuild the read model without the shared execution lock;
external analysis / agent-reply calls have their own long timeouts and are not part of the busy-page mechanism.
After checking the reported action more closely, a reply containing a hashtag was an even more direct explanation: hashtag-bearing replies bypassed the incremental read-model updater and forced a full read-model rebuild inside the write lock. That specific hot-path issue has now been fixed so labeled thread/reply writes use incremental post and thread-label updates, with full rebuild retained only as fallback. The synchronous cache-miss artifact build can still add a second wait after the reply returns or during the redirected page load.
Relevant Request Flow
public/index.php creates FrontController with:
repository root from
FORUM_REPOSITORY_ROOTdatabase path from
FORUM_DATABASE_PATHstatic HTML root from
FORUM_STATIC_HTML_ROOTorstate/static_html
Source: public/index.php:10-16.
For each request, FrontController::handle() does:
configuration checks;
fingerprinted asset serving;
static artifact lookup for anonymous queryless GETs;
dynamic
Application::handle();best-effort static artifact generation after eligible dynamic misses.
Source: src/ForumRewrite/Host/FrontController.php:29-69.
The busy page is only emitted here:
if (str_starts_with($throwable->getMessage(), 'Timed out waiting for execution lock: ')) {
$this->sendHtml($this->renderBusyError(), 503);
return;
}
Source: src/ForumRewrite/Host/FrontController.php:62-66.
Confirmed Findings
1. Most dynamic routes call ensureReadModel() before routing
Application::handle() skips ensureReadModel() only for /api/version. Everything else reaches ensureReadModel() before route dispatch.
Source: src/ForumRewrite/Application.php:54-64.
If the database is missing, stale, has unreadable metadata, a schema mismatch, a repository-root mismatch, or a repository-head mismatch, the request enters a shared execution lock and may rebuild SQLite before serving the page.
Source: src/ForumRewrite/Application.php:391-445.
Implication:
If a request cannot obtain the lock within
FORUM_EXECUTION_LOCK_TIMEOUT_SECONDS, it can hit the busy page.If a request obtains the lock, it will rebuild under that lock and the user waits for the rebuild instead of seeing the busy page.
If production sets
FORUM_EXECUTION_LOCK_TIMEOUT_SECONDShigher than the default5, lock contention can look like a long stall before the busy page.
2. The execution lock polls until timeout
ExecutionLock::withExclusiveLockTimed() tries flock(... LOCK_EX | LOCK_NB), sleeps 100ms when locked, and repeats until timeout. The default timeout is 5 seconds unless FORUM_EXECUTION_LOCK_TIMEOUT_SECONDS is set.
Source: src/ForumRewrite/Support/ExecutionLock.php:49-79.
Implication:
The busy page is intentionally delayed by the lock timeout.
A high production timeout directly increases "stalls before busy."
3. Cache-miss artifact generation happens after dynamic rendering but before request completion
After Application::handle() returns, the front controller calls:
$this->buildStaticArtifactOnEligibleMiss($method, $requestUri, $cookies, $staticArtifact);
Source: src/ForumRewrite/Host/FrontController.php:54-61.
For eligible anonymous queryless GETs, buildStaticArtifactOnEligibleMiss() constructs StaticArtifactBuilder and calls buildSingleRoute().
Source: src/ForumRewrite/Host/FrontController.php:225-255.
The test suite confirms this is intentional: first request renders from PHP, writes the missing artifact, and the second request serves static HTML.
Source: tests/LocalAppSmokeTest.php:1044-1068.
Implication:
The requested page may already have been echoed, but PHP has not finished the HTTP request.
Under Apache/FastCGI buffering, the browser can still appear stalled until the artifact build finishes.
This path does not send a busy page unless the exception escapes the post-render build method.
4. Lock timeouts during post-render artifact generation are swallowed
buildStaticArtifactOnEligibleMiss() catches all throwables and ignores them:
try {
$builder->buildSingleRoute($requestUri);
} catch (Throwable) {
// Best-effort generation should not affect the current response.
}
Source: src/ForumRewrite/Host/FrontController.php:251-255.
StaticArtifactBuilder::buildSingleRoute() renders the route with a new Application, and that Application still calls ensureReadModel().
Sources:
src/ForumRewrite/Host/StaticArtifactBuilder.php:88-98src/ForumRewrite/Host/StaticArtifactBuilder.php:244-247
Implication:
If post-render artifact generation waits on the execution lock and times out, that timeout is swallowed.
The user sees neither the busy page nor an immediate page; they wait until the timeout or rebuild attempt finishes, then receive the original page.
This is the clearest code-level match for the reported symptom.
5. Full static artifact builds rebuild the read model without the shared execution lock
scripts/build_static_artifacts.php calls StaticArtifactBuilder::build().
Source: scripts/build_static_artifacts.php:15-16.
StaticArtifactBuilder::build() immediately runs ReadModelBuilder::rebuild() with no ExecutionLock.
Source: src/ForumRewrite/Host/StaticArtifactBuilder.php:24-31.
By contrast, scripts/rebuild_read_model.php does wrap rebuilds in ExecutionLock.
Implication:
An operator or deploy process running
build_static_artifacts.phpcan rebuild the SQLite read model outside the web app's shared lock protocol.Web requests may then contend at the SQLite/filesystem layer instead of the application lock layer.
That kind of contention is not guaranteed to become the friendly busy page.
6. Write requests hold the execution lock across git, read-model refresh, and artifact invalidation
LocalWriteService wraps write actions in withTimedWriteLock().
Source: src/ForumRewrite/Write/LocalWriteService.php:43-45 and src/ForumRewrite/Write/LocalWriteService.php:881-898.
Inside that lock, writes can perform git commits and read-model updates. When incremental read-model update is unavailable or fails, the same request can do a full rebuild.
Sources:
src/ForumRewrite/Write/LocalWriteService.php:443-463src/ForumRewrite/Write/LocalWriteService.php:771-779
Implication:
A write that gets the lock can legitimately run for longer than the lock timeout because the timeout only limits waiting to acquire the lock.
Other requests then wait for the lock and may eventually get the busy page.
The writer itself will not return the busy page; it is doing the work.
7. Replying with a hashtag used to force a full read-model rebuild
LocalWriteService::createReply() extracts hashtags from the reply body. If any valid label is found, it writes both:
the normal reply post record;
a
records/thread-labels/*.txtrecord targeting the thread.
Source: src/ForumRewrite/Write/LocalWriteService.php:131-150.
Hashtag extraction ignores quoted lines and accepts lowercase label tokens like #answered or #needs-review.
Source: src/ForumRewrite/Write/LocalWriteService.php:935-958.
The key branch is in synchronizePostDerivedState():
if ($hasThreadLabelWrite || !$this->canIncrementallyUpdateReadModel()) {
$rebuildTimings = $this->refreshDerivedStateAfterCommit($commitSha);
...
}
Source: src/ForumRewrite/Write/LocalWriteService.php:482-494.
Before the fix, createReply() passed $labelRecordPath !== null as $hasThreadLabelWrite, and any reply with a valid hashtag used read_model_rebuild instead of read_model_incremental_update.
The test suite now locks in the fixed behavior:
normal thread/reply writes assert
read_model_incremental_update;labeled writes assert
read_model_incremental_updateand noread_model_rebuild;labeled writes assert post and thread-label incremental sub-timings;
labeled writes still assert immediate
thread_label_addactivity.
Source: tests/WriteApiSmokeTest.php:1024-1050 and tests/WriteApiSmokeTest.php:1124-1144.
Original local reproduction with a temporary writable fixture showed the pre-fix timing shape:
normal reply:
read_model_incremental_update=7.9
total=22.6
hashtag reply:
read_model_rebuild=13.2
read_model_index_posts=2.7
read_model_write_metadata=2.8
total=27.8
Those fixture numbers are small because the fixture repository is tiny. In production, read_model_rebuild scales with all canonical records, all identities, all thread-label records, all post-reaction records, and activity derivation.
Post-fix local reproduction shows hashtag replies no longer rebuild:
normal reply:
read_model_incremental_update=5.2
total=17.8
hashtag reply:
read_model_incremental_update=5.5
read_model_incremental_post_insert_post=0.1
read_model_incremental_thread_label_update_thread_labels=0.1
read_model_incremental_thread_label_refresh_thread_label_activity=0.1
total=17.2
This was the highest-confidence cause for the specific slow action, and the targeted fix is in place.
Likely Production Scenarios
Scenario A: User posts a reply containing a hashtag
The body contains a valid hashtag such as
#answered.createReply()writes the reply record and a thread-label record.The write path commits both records to git.
Before the fix, because a thread-label record was written,
synchronizePostDerivedState()performed a full read-model rebuild.The request held the shared execution lock for the whole write/rebuild.
The posting user saw a slow submit instead of a busy page, because their request owned the lock and was doing the rebuild.
Other users could see busy pages or stalls while waiting behind that lock.
This is the highest-confidence match for the observed slow action. The targeted code path has been changed to use incremental updates.
Scenario B: Redirect or next anonymous page load rebuilds a missing artifact after the hashtag reply
The hashtag reply invalidates the thread/post artifacts.
The browser follows the create-reply redirect to the thread page.
If the redirected GET is anonymous/queryless enough to be artifact-eligible and the artifact is missing, the front controller can render PHP and then synchronously rebuild the missing artifact before finishing the response.
If that post-render artifact build waits on read-model/lock work, the browser can appear stalled and still not see the busy page because that best-effort build catches throwables.
This can stack on top of Scenario A.
Scenario C: Read model is stale after deploy
Static artifact is absent, bypassed by cookie, or not eligible.
Dynamic PHP route enters
ensureReadModel().If no other process holds the lock, this request rebuilds and stalls.
If another process holds the lock past timeout, it returns busy.
This explains a stall when one request "wins" the rebuild.
Scenario D: build_static_artifacts.php runs during traffic
Operator/deploy script starts a static build.
The build rebuilds SQLite without
ExecutionLock.Web requests interact with the same database while the build owns SQLite-level locks.
The app-level busy page may not appear because the contention did not happen through
ExecutionLock.
This should be fixed because it violates the app's own locking model.
Scenario E: Long non-lock operations
Analysis and agent-reply endpoints can call external providers with long timeouts. For example, Dedalus timeouts default to at least 60 seconds in the application setup.
These are not normal page GETs, but they are another class of "not busy page" stalls because they do not use the execution-lock busy mechanism.
Recommendations
Add an incremental path for hashtag-bearing thread/reply writes. Completed.
The code already had
IncrementalReadModelUpdater::applyThreadLabelWrite()for explicit thread-tag writes. The implemented fix makes a post write with labels perform both incremental steps after the combined git commit:applyPostWrite($record, $commitSha)applyThreadLabelWrite($threadId, $commitSha)
This preserves immediate reply visibility, thread label updates, score updates, metadata updates, and
thread_label_addactivity without a full rebuild. Full rebuild remains the fallback if either incremental step fails.Add a regression test for hashtag replies using incremental read-model update. Completed.
The labeled-write expectation now checks an incremental timing shape while keeping assertions for rendered labels and
thread_label_addactivity. A fallback test covers failure during the thread-label incremental step.Stop doing same-request artifact generation after page rendering.
Either remove
buildStaticArtifactOnEligibleMiss()from the request path, put it behind a very small budget, or finish the HTTP response first withfastcgi_finish_request()when available before attempting best-effort artifact generation.Make
StaticArtifactBuilder::build()useExecutionLock.Full static builds should follow the same locking protocol as
scripts/rebuild_read_model.php. This prevents deploy/static-build work from bypassing the busy-page mechanism.Do not swallow lock timeouts after waiting a long time.
If same-request artifact generation stays, it should first check whether the read model is ready and whether the execution lock is currently held. If the lock is held, skip artifact generation immediately rather than waiting and swallowing the timeout.
Add request-phase instrumentation for production.
Log at least:
static artifact hit/miss
dynamic render duration
ensureReadModel()reason and durationlock wait duration
post-render artifact build attempted/skipped/duration
whether
buildStaticArtifactOnEligibleMiss()swallowed an exception
Check production configuration.
Verify:
FORUM_EXECUTION_LOCK_TIMEOUT_SECONDSwhether deploys run
scripts/build_static_artifacts.phpwhile traffic is livewhether
/api/read_model_statusoften showsstale_marker=presentorlock_status=lockedwhether hot artifacts such as
public/index.html,public/threads.html, and thread/profile artifacts are being invalidated and rebuilt repeatedly
Verification Performed
Ran:
php -d zend.assertions=1 -d assert.exception=1 tests/run.php
Result: all tests passed, including LocalAppSmokeTest::testFrontControllerShowsBusyErrorForExecutionLockContention.
That confirms the explicit lock-timeout busy-page path works in the focused test. It does not cover the production symptom where work happens outside that exact escaped-exception path.
Additional pre-fix focused reproduction:
created a temporary writable copy of
tests/fixtures/parity_minimal_v1;rebuilt the read model;
posted one normal reply and one hashtag reply through
LocalWriteService::createReply();confirmed the normal reply used
read_model_incremental_update;confirmed the hashtag reply used
read_model_rebuild.
Post-fix verification:
php -l src/ForumRewrite/Write/LocalWriteService.phpphp -l tests/WriteApiSmokeTest.phpphp -d zend.assertions=1 -d assert.exception=1 tests/run.phprepeated the normal reply versus hashtag reply timing reproduction and confirmed both use
read_model_incremental_update.