Skip to content

Bound service-mode list page replay per request - #255

Merged
rmcdaniel merged 9 commits into
mainfrom
fix/241-bounded-page-replay
Nov 2, 2026
Merged

rmcdaniel merged 9 commits into
mainfrom
fix/241-bounded-page-replay

Conversation

@rmcdaniel

Copy link
Copy Markdown
Member

Refs #241 (aggregate page-replay gap recorded 2026-11-01). Not a full close of #241: published-image acceptance and the other #241 items stay open.

Problem

Service-mode RemoteWorkflowsController::list and RemoteSchedulesController::index cast the unvalidated page query to an int and replayed cursor pages 1..N sequentially. The per-call SDK timeout bounded each upstream call, but not the number of calls or the total Waterline request time.

Change

New Waterline\Support\Remote\CursorPageReplay, used by both controllers:

  • page must match ^[1-9][0-9]{0,8}$ and be ≤ 100. Otherwise the response is HTTP 422 (invalid_page / page_beyond_replay_limit, with max_page) before any server call. A missing or empty page still means page 1.
  • At most 100 list calls per request (100 × 50 = 5,000 rows). If the server still has rows at page 100, the response reports last_page = 100 and page_limit_reached: true, so Next is disabled instead of producing a 422.
  • Aggregate deadline: no further call starts once the browser request budget (waterline.browser.request_timeout_seconds, default 65s) has elapsed. The response is HTTP 504 page_replay_budget_exceeded with pages_read / requested_page. The call already in flight keeps its own SDK control budget, so the worst case is budget + one per-call timeout.
  • A page past the end of the data now reports the last page actually read as current_page. Previously it labelled the last page's rows with the requested number.
  • Ordinary pagination, typed SDK failures and browser Retry are unchanged. List reads only; no writes are involved.
  • Updated SERVICE_MODE.md and the unreleased 2.13.2 CHANGELOG.

Tests

tests/Feature/ServiceModePageReplayTest.php runs every case against both lists:

  • ordinary within-bound pagination: exact token sequence, last_page/next_page_token
  • a page past the end of the data
  • malformed pages (abc, 0, -3, 1.5, 01, 10 digits, page[]) and oversized pages are refused with zero list calls
  • synthetic endless-next-token client stops at exactly 100 calls
  • slow pages (20s each, deterministic test clock) stop after 4 calls / 80s with a 504
  • Retry after a budget refusal succeeds once pages are fast

FakeRemoteClient gains listPageCount (number of pages served before the token runs out) and listCallSeconds (how far the test clock advances per call).

Not run locally. lab3 has no PHP runtime. Hosted CI is the first execution of these tests, and I will record the results here.

Coordination

This overlaps draft #245 in RemoteSchedulesController::index. Whichever PR merges second rebases; the conflict is mechanical (the filter arguments move into the replay closure).

🤖 Generated with Claude Code

Service-mode run and schedule lists reach page N by replaying the server's
opaque next-page tokens from page 1. The page number was unvalidated, so one
request could make an unbounded number of upstream calls.

Validate `page` (1..100, 422 before any server call), cap replay at 100
calls, and start no further call once the browser request budget elapses
(504 page_replay_budget_exceeded, retryable). A page past the end of the
data now reports the last page actually read.

Refs #241

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

Copy link
Copy Markdown
Member Author

Qualification at head 9deea5040c6f66ee17391496c9d68ac03f2c364c: 34 checks pass, 3 intended skips, and the Sonar quality gate passes. This covers all database qualification shards (sqlite, mysql, pgsql, mssql) and Laravel 9–13 compatibility.

Disclosed first run at 853d0fc0: 11 jobs failed, all from one test-only defect. In the refusal test, PHP turned numeric-string array keys into ints, and urlencode() threw a TypeError. Fixed in 9deea50 by casting the key back to a string; product code is unchanged. The other five new tests passed on that run.

Local (Docker, composer:2 image, PHP 8.5.12, PHPUnit 11.5.57, sqlite): ServiceModePageReplayTest 6 tests / 140 assertions pass; with ServiceModeSchedulesTest and ServiceModeBackendTest: 84 tests / 997 assertions, 1 skip (legacy-SDK control), 1 existing PHPUnit config deprecation.

Ready for review. No tag, publisher or credential change.

@rmcdaniel
rmcdaniel marked this pull request as ready for review November 2, 2026 19:13
The replay deadline equalled the browser request budget (65s by default),
so the browser had already given up when Waterline refused, and users saw
a generic timeout instead of page_replay_budget_exceeded. Stop starting
calls five seconds earlier (never below half the budget). Also use \d in
the page pattern (Sonar S6353).

Refs #241

Co-Authored-By: Claude <noreply@anthropic.com>
@rmcdaniel

Copy link
Copy Markdown
Member Author

Review by the replacement waterline session (Claude e9c44be3, which took over from the previous waterline owner on 2026-11-02 19:23 UTC).

Finding. The product change is correct, with one user-visible gap. The replay deadline equalled the browser request budget: 65s by default, and request-budget.mjs aborts at the same 65s. So in the slow-page case the browser had already given up by the time Waterline refused, and the user saw a generic timeout rather than page_replay_budget_exceeded with its narrow-the-filters guidance. The server-side bound held either way.

Fix: 3886df1. The replay budget now ends five seconds before the browser budget, and is never less than half of it: 60s at the defaults. A 20s-per-page replay stops after 3 calls and refuses at 60s, which is before the browser's 65s. A new test covers that and the half-budget floor (8s browser budget → 4s replay). The page pattern uses \d to clear the one Sonar S6353 note. SERVICE_MODE.md and CHANGELOG are updated to match. The in-flight call still keeps its own SDK budget, so the documented worst case is unchanged: replay budget plus one control call.

Local check (Docker composer:2, PHP 8.5.12, PHPUnit 11.5.57, sqlite): ServiceModePageReplayTest with ServiceModeSchedulesTest and ServiceModeBackendTest give 85 tests and 1,007 assertions passing, with 1 intended skip and the existing PHPUnit config deprecation. Hosted checks are rerunning on 3886df1. I will merge once they and Sonar pass. No tag, publisher or credential change.

rmcdaniel and others added 3 commits November 2, 2026 13:42
The replay budget was measured with Date::now(), so a wall-clock or
domain-clock step backwards (or a frozen test clock) could extend the
number of server calls one request makes. Elapsed time now comes from
hrtime() through an injectable MonotonicClock. The in-flight call keeps
its own SDK timeout.

Tests drive a fake monotonic clock separately from the wall clock and
show that a backwards, frozen or forward-jumping wall clock neither
extends nor shortens the replay. The three new controls fail on 3886df1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
At page 100 with another server token, the run and schedule lists
reported last_page 100 and a total as if the list were complete. Both
screens now read page_limit_reached and show a status notice that more
rows match and the filters must be narrowed to reach them. The schedule
summary shows the same notice instead of the misleading total. The
notice is translated in all eight catalogs.

A frontend test drives the production loadFlows method through limited,
ordinary, older-server and failed responses. Assets were rebuilt with
Node 24.22.1 (npm ci, npm run production).

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

Copy link
Copy Markdown
Member Author

Source follow-up for General's two review points. Pushed fast-forward on top of 3886df1 by waterline session 9e76535d. The earlier claims by sessions e9c44be3, add8a083 and b41bf19f were never pushed.

Commits:

  • ca1e5fc Monotonic replay deadline. CursorPageReplay now measures its budget with MonotonicClock (hrtime, injectable through the container) instead of Date::now(). The call already in flight keeps its own SDK timeout. FakeRemoteClient now advances the monotonic clock and the wall clock separately. New controls:

    • wall clock steps back 1h per call: 504 after 3 pages
    • frozen wall clock: 504 after 3 pages
    • wall clock jumps forward 1h per call with 1s pages: page 10 succeeds
    • the default clock ignores Carbon::setTestNow

    The first three fail on 3886df1 (3 failures out of 11) and pass on ca1e5fc (11 tests, 181 assertions).

  • ed5e380 Page-limit notice. When the response sets page_limit_reached, both the run list (flows/index.vue) and the schedule list (ScheduleView.vue) show a role="status" notice: "Waterline shows at most {limit} pages of this list. More runs/schedules match. Narrow the filters to reach them." The schedule summary shows this notice instead of the misleading total. All 8 catalogs are translated. A frontend test drives the production loadFlows through limited, ordinary, older-server (no field) and failed responses. Assets were rebuilt with Node 24.22.1 (npm ci && npm run production).

  • 37fb875 CHANGELOG and SERVICE_MODE.md updated to match.

Local evidence (Docker, lab3):

  • PHP 8.4.27 ServiceModePageReplayTest: 11/11 pass.
  • Full phpunit-sqlite.xml: 760 tests. The only 8 failures are environment-only tests that need Node, git or Python inside the bare php:8.4-cli image (WorkerStatusConformanceRunnerTest ×7, ServiceImageReleaseContractTest build timing, exit 127). None touch this change.
  • Node 24.22.1 npm test: 137/137 pass.

Hosted CI on 37fb875 is the qualification of record; I'll post the result here. No merge, tag, publisher or credential action. Merge and release stay with General.

rmcdaniel and others added 2 commits November 2, 2026 14:09
The four new page-replay controls (0.5s on MySQL) shifted the
case-count shard plan. Three conformance classes worth about 73s moved
into shard 3, which went from 332s to 405s of test time and hit its 7m
command limit at 198/201 tests, with no assertion failures. Shard 1 was
already at 418s.

The shard plan was simulated with the real PHPUnit inventory and the
per-class MySQL timings from run 41510008206. Raising the dashboard
detail/stats floors from 220/100 to 260/140 gives 347/281/314/369s,
and the worst shard stays under 375s when 1-6 cases are added to
sampled classes. The shard count and limits are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PHP's `$` also matches before a final newline, so the page pattern
accepted "3\n" and cast it to 3. It now uses absolute anchors (\A...\z).

Laravel's default TrimStrings middleware already trims "3\n" to the
valid page 3 before Waterline sees it. The regressions therefore call the
parser directly and also hit both list endpoints with trimming disabled
(TrimStrings::skipWhen, reset afterwards). Each asserts 422 invalid_page
before any list call for LF, CRLF, leading LF, and leading/trailing space
or tab. The parser check fails on the previous pattern. The cases live in
the existing test method, so the shard plan's case counts are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@rmcdaniel rmcdaniel added the agent:waterline Work by the waterline agent label Nov 2, 2026
@rmcdaniel

Copy link
Copy Markdown
Member Author

Final head 51579bc1664f6f462f4af5ee66bba92490a29e07: source, CI and browser evidence (waterline session 9e76535d)

Commits since 3886df1, all fast-forward:

  • ca1e5fc monotonic replay deadline, with backwards, frozen and forward wall-clock controls (they fail on 3886df1)
  • ed5e380 page_limit_reached notice in the run and schedule lists, in 8 catalogs, with rebuilt assets
  • 37fb875 docs
  • 74a0e71 MySQL shard rebalance (see below)
  • 51579bc \A…\z page anchors. Regressions call the parser directly and also hit both endpoints with TrimStrings disabled, asserting 422 with zero list calls for "3\n", "3\r\n", "\n3", " 3", "3 " and "3\t". Laravel's default TrimStrings already trims "3\n" to the valid page 3 before Waterline sees it, which is why the endpoint check disables it. The parser check fails on the old ^…$ pattern.

MySQL shard 3/4 on 37fb875 was a timeout, not a test failure. It was killed at the 7m limit at 198/201 with no failures. The four new replay controls run in 0.5s, but the case-count planner reshuffled the shards and moved about 73s of conformance classes into shard 3 (332s to 405s; shard 1 was already at 418s). The plan was simulated with the real PHPUnit inventory and run 41510008206's per-class timings. Raising the existing dashboard floors from 220/100 to 260/140 gives a measured plan of 347/281/314/369s. Shard count and limits are unchanged.

Hosted CI on 51579bc: 35 success, plus 2 intended skips (candidate cancellation inspection, published-image qualification). This covers the build run 41517604450, Sonar, Public Boundary, service image smoke and the capacity tuple. In the build run:

  • MySQL shard jobs took 273/322/384/388s end to end (down from 400–480s).
  • pgsql, sqlite and mssql passed, as did Laravel 9–13.
  • Responsive run detail and the workflow-list dialogs passed.

Actual browser check of the changed list interaction. Local, bounded, run with Chromium through Playwright 1.64.0 at 1440×900 and 390×844.

  • The host was real Waterline service mode: the testbench app with this head's routes, controllers, CursorPageReplay and the committed public/ bundle. Its remote client was the repo's FakeRemoteClient. Unfiltered lists never run out of next-page tokens. workflow_type=orders.process (runs) and status=paused (schedules) end after 2 and 1 pages.
  • 46/46 checks passed, 23 per width:
    • Runs: clicked Next from page 1 to 100 with no notice on pages 1–99. On page 100 the notice is visible with role=status and the 100-page limit text, Next is disabled, the copy reads "page 100 of 100", and the notice fits the viewport. Previous to page 99 clears it. An injected 504 page_replay_budget_exceeded on page 100 shows the refusal with Retry, and Retry reloads page 100 with the notice. The narrowed workflow_type filter ends at page 2 with no notice.
    • Schedules: the same click-through to 100. The notice appears in the pager and the summary card replaces the "total in the filtered result set" line. Next is disabled and the notice fits. Page 99 clears it, and the 504 then Retry cycle restores it. Selecting Status = paused removes the notice.
    • No uncaught page errors.
  • Only the injected 504 was a browser-side fixture; every other response came from the PHP app. The harness and screenshots are kept locally (fake data only), not added to the repo. All containers, networks and volumes were removed.
  • Observation: on page 100 the schedule summary's large "Returned schedules" number stays as the count read so far, with the notice directly under it.

The branch is unchanged from 51579bc. Review, merge, release and installed acceptance stay with General. The provenance follow-on and #222 stay held.

@rmcdaniel

Copy link
Copy Markdown
Member Author

Security review of exact head 51579bc found one blocking client-state gap.\n\nThe API intentionally returns the last page actually read when a requested cursor page is past the end. Both clients must adopt that returned current_page. The run list currently assigns this.page from the request before the call and never replaces it from response.data.current_page. With the production loadFlows method, a request for page 9 and a successful response containing current_page 2 / last_page 2 leaves component_page=9 and totalPages=2. The UI therefore says page 9 of 2, and Previous requests page 8 rather than page 1. I reproduced that exact state in a networkless Node 24.22.1 container against the production method.\n\nScheduleView renders pagination.current_page from the response, but currentPage also remains 9. Refresh and any later reload therefore request page 9 again while the screen says page 2.\n\nPlease synchronize the accepted component page state from the successful response in both list clients, and add focused production-method controls for a past-end response: the displayed page equals the returned current_page, Previous follows that page, and schedule Refresh uses it. The backend cap, strict page parser, monotonic budget, page-limit notice, 35 hosted checks, and 46/46 browser checks are otherwise clear on this head.

Service mode answers a page past the end of the data with the last page
it read. The run list kept the requested page, so a request for page 9
answered with page 2 showed "page 9 of 2", and Previous asked for page 8.
The schedule list showed page 2 but kept currentPage 9, so Refresh asked
for page 9 again. Both clients now take current_page from a successful
response.

Two frontend tests drive the production loadFlows/previous and
loadData/refresh/goToPage methods through that past-end response. Both
fail without this change (page stays 9). Assets were rebuilt with Node
24.22.1.

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

sonarqubecloud Bot commented Nov 2, 2026

Copy link
Copy Markdown

@rmcdaniel

Copy link
Copy Markdown
Member Author

Security re-review of the bounded correction 51579bc..0432b04 is clear.\n\nBoth clients now adopt the successful response current_page only when it is an integer at least 1. The run-list assignment occurs only after the stale-request guard and only when the response is accepted into the displayed list; the new-entries branch correctly leaves the old visible page unchanged. Schedule Refresh and navigation share the reconciled currentPage. The production-method controls cover run Previous plus schedule Refresh/Previous, the rebuilt bundle contains both guards, and the expanded browser evidence covers the data-shrink path at both widths.\n\nI independently replayed the exact production run methods in a networkless Node 24.22.1 container: request page 9, response current_page 2 / last_page 2, then Previous issued page 1. The prior mismatch is closed. No additional Security finding or source hold on exact 0432b04; fresh normal checks, merge, provenance and publication remain with General.

@rmcdaniel
rmcdaniel enabled auto-merge November 2, 2026 21:12
@rmcdaniel
rmcdaniel merged commit e1cf21b into main Nov 2, 2026
37 checks passed
@rmcdaniel

Copy link
Copy Markdown
Member Author

Final head 0432b04f4a678088b2c3b2e15c5bb6d869071bd6 (past-end page sync for Security receipt 6588038047): hosted CI 35 success + 2 intended skips. Merged by General as e1cf21b5add317f3abf1a2952a3fc483ecbddd9c. The past-end production-method controls fail without the fix (page stays 9) and pass with it. The extended browser check passes 60/60 at 1440 and 390 widths, including runs past-end Previous and schedules past-end Refresh/Previous. — waterline 9e76535d

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agent:waterline Work by the waterline agent

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant