Skip to content

feat: reads (read-only HTTP projections) + persist write-path hardening#156

Closed
deligoez wants to merge 18 commits into
mainfrom
9.12.0-reads
Closed

feat: reads (read-only HTTP projections) + persist write-path hardening#156
deligoez wants to merge 18 commits into
mainfrom
9.12.0-reads

Conversation

@deligoez

Copy link
Copy Markdown
Member

Summary

Adds reads — declarative, zero-write HTTP projections of a machine's current state — and hardens the persistence write path. Reads are the query side of a command/query split: endpoints: send events and write history; reads: only look.

Motivation

A frontend polling a machine's status was modelled as a targetless event. Every poll ran the full send()persist() pipeline and appended ~4 rows to machine_events. A long-open screen bloated one machine's history to ~5,458 rows; persist() re-upserted the entire history each time, hit MySQL's 65,535-placeholder ceiling, threw QueryException, and wedged the machine (its status column committed but the events didn't — a desync). Reading state is a query, not an event — in both state-machine theory (you inspect the configuration) and CQRS (queries never produce events).

What's in this PR

reads concept (zero-write by construction)

  • MachineRouter::register(['reads' => true | ['status' => null, 'resume' => [...]]]) → GET, machineId-bound routes.
  • MachineController::handleSnapshot() — restores read-only and returns the standard envelope; never constructs an event, never calls send()/persist(), never locks, never writes a row.
  • ReadDefinition value object (forms: null | string URI-shorthand | options array; validates keys & URI; rejects action/method/unknown).
  • Output shaping reuses the endpoint output parsing (context-key filter / OutputBehavior / fallback).
  • buildResponse() now honours includeAvailableEvents (false omits the key) — this also fixes endpoints, where it was a silent no-op.

Persist write-path hardening (independent of reads; fixes the incident's root cause)

  • Incremental persist — upsert only the dirty slice (history[max(0, watermark-1)..]) instead of the whole history; O(new+1) writes regardless of history length. Watermark captured at restore, advanced after persist; root row stays full; last row keeps the full snapshot.
  • Atomicity — for transactional events, persist() now runs inside the transition's DB::transaction, so a persist failure rolls back the action's committed DB writes (no state/log desync).
  • Prune-safe sequencingsequence_number from max()+1 of in-memory history, not the row count.
  • Restore hardening — an idMap miss (foreign/wrong-class machineId) now throws RestoringStateException (single + parallel paths), so reads map it to a clean 404.

Docs & skill — new docs/laravel-integration/reads.md, persistence write-path-hardening notes, endpoints↔reads cross-links, and the agent-skill updates (concept, nav, anti-pattern) that ship via plugin-dist.

Testing

Full gate green: phpstan 0 errors, 2,659 tests pass, type coverage 100%. New coverage: ReadDefinition, read registration (forms/collision/duplicate/reads-only), snapshot envelope + zero-write + archived restore, output routing, available_events omission (reads and endpoints), 404 (unknown + wrong-class), incremental dirty-slice + bounded-upsert-at-scale + cross-restore round-trip, atomicity rollback and not-wedged recovery, prune-safe sequencing.

Review & audit

Spec converged over 5 adversarial review rounds (2 consecutive clean); implementation verified by a post-implementation audit (round 1 → 6 findings, all fixed; round 2 clean across implementation, engine, and docs/skill).

Notes

  • Purely additive — absent reads, no new routes; persist hardening is backward-compatible (transparent for contiguous histories; atomicity only tightens an existing failure mode).
  • The literal ≥5,462-row MySQL placeholder-ceiling regression is a MySQL-semantics concern (LocalQA); the unit suite proves the upsert stays bounded as history grows, which is what makes the ceiling unreachable.

https://claude.ai/code/session_01Jsh4xaG4TL8GAfCcqZk9PD

deligoez added 12 commits June 22, 2026 22:38
…ions

Part of 9.12.0 reads concept. ReadDefinition normalizes read config
(null | string uri-shorthand | options array), validates keys
(rejects action/method/unknown) and URI (trim, reject empty/slash-only/
placeholder), defaults status=200, available_events=null.

Claude-Session: https://claude.ai/code/session_01Jsh4xaG4TL8GAfCcqZk9PD
- MachineRouter::register() parses reads (true|assoc), relaxes both content
  early-returns for reads-only registrations, validates duplicate URIs and
  GET machineId-bound collisions, registers GET /{machineId}/{uri} read routes.
- MachineController::handleSnapshot() — zero-write read path (restore + buildResponse,
  no send/persist/lock); output split mirrors handleEndpoint; RestoringStateException to 404.
- buildResponse() now honors includeAvailableEvents (omits availableEvents key when false);
  fixes endpoints' available_events too.

Claude-Session: https://claude.ai/code/session_01Jsh4xaG4TL8GAfCcqZk9PD
…-safe seq)

- persist(): upsert only the dirty slice (history[max(0,watermark-1)..]) instead of the
  whole history, keeping the full in-memory baseline walk. Adds State::persistedEventCount
  watermark (captured at restore, advanced after persist). O(new+1) upsert — clears the
  65,535-placeholder ceiling on long histories. Index 0 stays full; last row full snapshot.
- send(): for transactional events, persist() now runs inside the transition's DB transaction
  so a persist failure rolls back the action's committed DB writes (no state/log desync).
- State::setCurrentEventBehavior(): sequence_number from max(history)+1, not count — prune-safe.
- restoreCurrentStateDefinition(): idMap miss throws RestoringStateException (foreign/wrong-class
  machineId) instead of a raw undefined-key error, so reads map it to 404 uniformly.

Claude-Session: https://claude.ai/code/session_01Jsh4xaG4TL8GAfCcqZk9PD
…dening

- ReadDefinitionTest: value forms, key/URI rejection, normalization.
- ReadSnapshotTest: registration (true/assoc/reads-only/collision/duplicate), envelope,
  zero-write, output shaping (array + OutputBehavior), available_events omit, 404 (unknown + wrong-class).
- IncrementalPersistTest: dirty-slice-only upsert + cross-restore context round-trip.
- PersistAtomicityTest: persist failure rolls back the action's DB write.
- SequenceNumberTest: prune-safe max()+1 sequencing.

Claude-Session: https://claude.ai/code/session_01Jsh4xaG4TL8GAfCcqZk9PD
…links

- New docs/laravel-integration/reads.md (concept, API, options, envelope, output, errors, optional migration).
- persistence.md: Write-Path Hardening note (incremental dirty-slice upsert, prune-safe sequencing)
  + transactional persist-in-transaction clarification.
- endpoints.md: commands-vs-queries cross-link to reads.
- VitePress sidebar: add Reads under Laravel Integration.

Claude-Session: https://claude.ai/code/session_01Jsh4xaG4TL8GAfCcqZk9PD
Ships with the release via plugin-dist per the skill ride-along rule.

Claude-Session: https://claude.ai/code/session_01Jsh4xaG4TL8GAfCcqZk9PD
…est gaps)

- restoreCurrentStateDefinition: multi-value (parallel) idMap miss now also throws
  RestoringStateException (uniform 404 for foreign machineIds).
- MachineRouter collision check: also rejects machineId-bound GET forwarded endpoints.
- Tests: large-history (>=5462 rows) persists without placeholder exhaustion; atomicity
  test now also asserts not-wedged recovery via trigger injection; archived-machine read
  returns 200; endpoint-side available_events=false omits the key.

Claude-Session: https://claude.ai/code/session_01Jsh4xaG4TL8GAfCcqZk9PD
…scale

Restoring 5500 rows pushed the parallel suite near its 300s budget. Replace with a
600-row history that asserts the persist upsert stays bounded (<50 rows) regardless of
history size — a stronger, portable proof the placeholder ceiling cannot be reached.
The literal >=5462-row MySQL-ceiling regression belongs in LocalQA (MySQL semantics).

Claude-Session: https://claude.ai/code/session_01Jsh4xaG4TL8GAfCcqZk9PD
CI's Rector check (PHP 8.5) flags these as RemoveNullNamedArgOnNullDefaultParamRector —
the ChildMachineCompletionJob constructor param defaults to null, so the explicit null
named args are no-ops. Pre-existing lines surfaced by the cache-fresh CI run; removing
them is behavior-preserving (344 delegation/job tests still pass).

Claude-Session: https://claude.ai/code/session_01Jsh4xaG4TL8GAfCcqZk9PD
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jun 23, 2026

Copy link
Copy Markdown

Deploying event-machine with  Cloudflare Pages  Cloudflare Pages

Latest commit: bd20ab5
Status:⚡️  Build in progress...

View logs

…older)

The 'empty/`/`/placeholder' shorthand read like a typo with stray slashes.

Claude-Session: https://claude.ai/code/session_01Jsh4xaG4TL8GAfCcqZk9PD
Comment thread src/Routing/MachineController.php Outdated
…output discriminator

Addresses review feedback (tkaratug): the output-split discriminator was duplicated in
three places (handleSnapshot, handleEndpoint, forwarded-response path). Extract to one
private helper so all three call sites share it and a future output-form change can't
drift across copies. Behavior-identical (phpstan 0, output/endpoint/forwarded tests pass).

Claude-Session: https://claude.ai/code/session_01Jsh4xaG4TL8GAfCcqZk9PD
deligoez added 4 commits June 23, 2026 16:18
…) + archived-read caveat

Follow-ups from final PR review:
- Test the multi-value/parallel idMap-miss path: reading a parallel machine's id under a
  different class hits findCommonParallelAncestor's RestoringStateException → 404 (was untested).
- Add tests/LocalQA/IncrementalPersistCeilingQATest.php: the literal >=5462-row MySQL
  placeholder-ceiling regression (MySQL-only; unit suite proves the bound portably).
- Make the zero-write guarantee's archived-machine exception explicit in reads.md and SKILL.md.

Claude-Session: https://claude.ai/code/session_01Jsh4xaG4TL8GAfCcqZk9PD
…t docblock

Note that only restoreStateFromRootEventId() sets the watermark; a State built another way
(fresh machine, or scenario/job re-hydration bypassing restore) keeps it at 0, so that
instance's next persist() writes the full history — correct, just not incremental. Addresses
review feedback; comment-only, no behavior change.

Claude-Session: https://claude.ai/code/session_01Jsh4xaG4TL8GAfCcqZk9PD
…vel 13 support)

Local tags were stale when the version was first chosen; the remote already had 9.12.0
(Laravel 13, PR #154). Rename the spec file, title, task plan, and tp marker to 9.13.0.

Claude-Session: https://claude.ai/code/session_01Jsh4xaG4TL8GAfCcqZk9PD
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