Skip to content

feat: key persisted graphs by name and add per-instance graphs - #23

Merged
lisachenko merged 1 commit into
mainfrom
claude/coroutines-repo-session-he07ux
Aug 16, 2026
Merged

feat: key persisted graphs by name and add per-instance graphs#23
lisachenko merged 1 commit into
mainfrom
claude/coroutines-repo-session-he07ux

Conversation

@lisachenko

Copy link
Copy Markdown
Owner

The substrate half of lisachenko/native-php-coroutines#15 (two concurrent tasks of one class cannot be in flight at once — Option 1, per-instance keys upstream).

The finding

The registry always stored entries under an arbitrary string name; only PersistentStore's public surface pinned that string to a class name, and attach() walks the address-keyed object table — so the class key was API convention, not a load-bearing property of the frozen store. This answers the question #15 hedged on, in the direction its follow-up comment predicted.

What changed

  • persist($name, $object) keys by name. ::class remains the convention for a typed singleton — a conditional return type keeps get(AppConfig::class) inferring — but the key carries no class semantics: two instances of one class live under two names, and the instanceof coupling between key and object is gone.
  • persistInstance($object) persists a graph under a name minted from its own root address ('@' + hex). Instance graphs are many-per-class by construction: none upserts another, any number are live at once, and re-persisting an already-shared root is idempotent through the ordinary upsert accounting (member share counts net out unchanged). The '@' prefix is reserved in persist() so a caller-chosen name can never collide with a minted one and silently release a graph another process reads by address.
  • dropInstance($objectOrAddress) closes the loop. It accepts the address form deliberately: the frozen store's alias-safety check rightly counts the argument itself as a live reference, so frozen-mode callers take addressOfInstance(), release their references, and drop by number. Arena-backed stores skip the alias predicate, so the instance form is fine there.
  • SharedError::capture() uses persistInstance() — one entry per panic. A second panic no longer supersedes the first, so two workers failing near-simultaneously each leave an error their waiter can still attach by the address its own slot carries. Cost: three short strings per panic until family teardown, the arena's ordinary leak-until-teardown economics.

Internally, persist()'s pipeline split into convert() (graph → persistent memory + role stamping) and storeUnder() (upsert registration), shared by both entry points. The registry itself needed no change.

Verification

  • Full suite green on both minors: OK (170 tests, 14473 assertions) under PHP 8.4 (z-engine 8.4.x-dev) and PHP 8.5 (dev-master), ffi.enable=1, opcache.jit=off.
  • New tests: plain-name persistence, two named instances of one class, two instance graphs of one class both live (object count returns to baseline after drops), idempotent re-persist of a shared root, reserved-prefix refusal. The removed testKeyMustNameAClassOfTheInstance pinned the dropped instanceof coupling.
  • Both soaks pass locally at 1500 cycles: SOAK OK (flat memory), SOAK-DROP OK (4.42 kB/cycle against a budget of 6).

Noted, out of scope

Registry writes from two processes at once (a parent persisting a task while a worker persists a panic) are unlocked today; that predates this change and deserves its own issue rather than a ride-along fix — persist allocates far too much to run under an arena mutex, so it needs a design, not a lock.

🤖 Generated with Claude Code

https://claude.ai/code/session_01U5QRWPfmkZME7hDjU6i5EA


Generated by Claude Code

The registry always stored entries under an arbitrary string; only
PersistentStore's public surface pinned that string to a class name,
which made a second persist() of one class an upsert - the superseded
graph released while a sibling process might still be reading it by
address. The class-name key turns out to be pure API convention, not a
load-bearing property of the frozen store, so the convention is now
stated as what it is:

- persist($name, $object) keys by NAME. Passing ::class remains the
  convention for a typed singleton and keeps get(AppConfig::class)
  inferring its type (conditional return), but two instances of one
  class live happily under two names, and the instanceof coupling
  between key and object is gone.

- persistInstance($object) persists a graph under a name minted from
  its own root address ('@' + hex). Instance graphs are many-per-class
  by construction: none upserts another, any number are live at once,
  and re-persisting an already-shared root is idempotent through the
  ordinary upsert accounting. The '@' prefix is reserved so a chosen
  name can never collide with a minted one.

- dropInstance($objectOrAddress) closes the loop; it accepts the
  address form because the frozen store's alias check rightly counts
  the argument itself as a live reference.

SharedError::capture() now uses persistInstance(), so a second panic
no longer supersedes the first: two workers failing near-simultaneously
each leave an error their waiter can still attach by address. The cost
is three short strings per panic until family teardown.

This is the substrate half of
lisachenko/native-php-coroutines#15; the runtime half lifts its
one-task-per-class refusal on top of it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U5QRWPfmkZME7hDjU6i5EA
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