From e61c1171ff53997ca98af867c253650c0934263a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 06:56:54 +0000 Subject: [PATCH] feat: key persisted graphs by name and add per-instance graphs 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 Claude-Session: https://claude.ai/code/session_01U5QRWPfmkZME7hDjU6i5EA --- README.md | 8 +- src/Ipc/SharedError.php | 22 ++-- src/PersistentStore.php | 214 +++++++++++++++++++++++++--------- tests/PersistentStoreTest.php | 85 +++++++++++++- 4 files changed, 252 insertions(+), 77 deletions(-) diff --git a/README.md b/README.md index 8e1b28a..0baa1fd 100644 --- a/README.md +++ b/README.md @@ -452,11 +452,15 @@ store's own shutdown function. ```php $store = PersistentStore::boot(); // register/reattach the persistent module -$store->persist(User::class, $o): User; // convert + return canonical instance (T of class-string) -$store->attach(): array; // class-string => instance for this request (idempotent) +$store->persist(User::class, $o): User; // convert + return canonical instance; the key is a NAME, + // ::class by convention so get() keeps its inference +$store->persistInstance($o): User; // per-instance graph named by its own root address: + // any number of one class live at once, none upserts another +$store->attach(): array; // name => instance for this request (idempotent) $store->get(User::class): ?User; // canonical instance or null $store->has(User::class): bool; $store->drop(User::class): bool; // remove the entry + reclaim what nobody shares +$store->dropInstance($o /* or address */): bool; // same, for an instance graph $store->objectCount(): int; // live persistent clones (shared ones counted once) $store->detach(): void; // runs automatically at request shutdown diff --git a/src/Ipc/SharedError.php b/src/Ipc/SharedError.php index 83180f1..23b4331 100644 --- a/src/Ipc/SharedError.php +++ b/src/Ipc/SharedError.php @@ -25,15 +25,13 @@ * an arena-resident string. The result slot then carries its ADDRESS, and the waiting * process attaches the same object rather than parsing anything. * - * ## One error entry per store, deliberately + * ## One entry per panic * - * The object is persisted under this class as its storage key, so capturing a second error - * replaces the first (persist() is an upsert). That fits the shape of the panic path - a - * worker captures the failure that ended its task and the waiter reads it - and it keeps the - * arena from filling up with the error graphs of a crash loop. A consumer that needs several - * live panics at once should copy the three strings out of the object it attached; in arena - * mode nothing is freed while the family lives (blocks are reclaimed only at teardown), but - * a superseded object leaves the registry and can no longer be attached by address. + * Each capture is its own instance graph ({@see PersistentStore::persistInstance()}), so a + * second panic never supersedes the first: two workers failing near-simultaneously each + * leave an error a waiter can still attach by the address its own slot carries. The cost is + * three short strings per panic, held until the family tears down - the ordinary + * leak-until-teardown economics of the arena, and a panic is not a hot path. */ final class SharedError { @@ -46,10 +44,6 @@ final class SharedError /** * Moves a Throwable's description into the arena and returns the shared object's address * - * Returns the address rather than the instance on purpose: holding the persistent - * instance would make the NEXT capture fail, since the store refuses to release a graph - * the request can still reach. - * * @return int Address of the shared error-info object, for a result slot record */ public static function capture(PersistentStore $store, \Throwable $error): int @@ -59,8 +53,8 @@ public static function capture(PersistentStore $store, \Throwable $error): int $info->message = $error->getMessage(); $info->trace = $error->getTraceAsString(); - $store->persist(self::class, $info); - $address = $store->addressOf(self::class); + $shared = $store->persistInstance($info); + $address = $store->addressOfInstance($shared); \assert($address !== null); return $address; diff --git a/src/PersistentStore.php b/src/PersistentStore.php index 1e1f23b..341ea9b 100644 --- a/src/PersistentStore.php +++ b/src/PersistentStore.php @@ -40,12 +40,16 @@ * (frozen semantics), releases request-owned caches and hides the objects from * teardown. * - * A persisted entry is a whole object GRAPH (see Persister), keyed by a class-string. The - * graph is described as a list of MEMBER objects living in one process-wide object table, - * so entries may share members: persisting an object that already belongs to another entry - * references the existing clone instead of copying it, and identity holds across entries - * and across requests. Every object counts how many entries reference it, which is what - * lets drop() reclaim memory without ever pulling an object out from under a live graph. + * A persisted entry is a whole object GRAPH (see Persister), keyed by a NAME. The name is + * whatever the caller wants to find the graph by later: passing `::class` is the convention + * for a typed singleton (`$store->get(AppConfig::class)` then infers its type), and + * persistInstance() mints a name from the graph's own root address for graphs that are many + * per class and looked up by address rather than by name. The graph is described as a list + * of MEMBER objects living in one process-wide object table, so entries may share members: + * persisting an object that already belongs to another entry references the existing clone + * instead of copying it, and identity holds across entries and across requests. Every + * object counts how many entries reference it, which is what lets drop() reclaim memory + * without ever pulling an object out from under a live graph. * * persist() returns a NEW canonical persistent instance: zvals embed zend_object * pointers directly, so existing references to the source object cannot be retargeted. @@ -76,6 +80,16 @@ final class PersistentStore */ public const int SHARED_HANDLE_SENTINEL = 0xFFFFFFFF; + /** + * First byte of every name persistInstance() mints, refused in caller-chosen names + * + * The reservation is what keeps the two keying schemes from colliding: an instance + * graph's name is derived from its root address, so a caller-chosen name that could + * spell the same string would let an ordinary persist() silently upsert - that is, + * release - a graph some other process is still reading by address. + */ + private const string INSTANCE_PREFIX = '@'; + /** * Stores booted during this request, keyed by module name (request-scoped: PHP * statics reset per request, exactly like the shutdown functions the stores arm) @@ -88,7 +102,7 @@ final class PersistentStore private Persister $persister; - /** @var array Materialized canonical graph roots for this request */ + /** @var array Materialized canonical graph roots for this request */ private array $instances = []; /** @@ -319,12 +333,16 @@ public static function detachActiveStores(): void * are referenced rather than copied: a graph may reach into another entry's graph, * and both entries then own the shared objects jointly. * - * Storage is keyed by class (or interface) name, so static analyzers infer the - * instance type from the key: `$store->get(AppConfig::class)` is an AppConfig. The - * instance is immediately live for the current request; on later requests it is + * Storage is keyed by NAME - any non-empty string the caller wants to find the graph + * by later. Passing `::class` is the convention for a typed singleton, and it is what + * keeps `$store->get(AppConfig::class)` inferring its type; but the key carries no + * class semantics of its own, so two instances of one class live happily under two + * names. A graph that is many-per-class and never looked up by name belongs to + * {@see self::persistInstance()} instead, which mints the name from the root address. + * The instance is immediately live for the current request; on later requests it is * re-materialized by attach() under the same key. * - * Persisting over an existing key is an upsert: the previous graph is released with + * Persisting over an existing name is an upsert: the previous graph is released with * exactly the same accounting as drop(), including the alias-safety check - so a * request that still holds instances of objects only the previous graph referenced * gets a RuntimeException instead of freed memory under its feet. @@ -346,26 +364,93 @@ public static function detachActiveStores(): void * * @template T of object * - * @param class-string $className Storage key; the object must be an instance of it - * @param T $object - * @param bool $mutable Persist as a SHARED MUTABLE graph (arena mode only) + * @param string $name Storage key; `::class` by convention for a typed singleton + * @param T $object + * @param bool $mutable Persist as a SHARED MUTABLE graph (arena mode only) * * @return T The canonical persistent instance */ - public function persist(string $className, object $object, bool $mutable = false): object + public function persist(string $name, object $object, bool $mutable = false): object { - if (!$object instanceof $className) { + if ($name === '' || $name[0] === self::INSTANCE_PREFIX) { throw new \InvalidArgumentException(sprintf( - 'Storage key %s must name a class or interface of the persisted instance %s', - $className, - get_class($object), + "'%s' cannot name a persisted graph: the empty name names nothing, and the '%s' " + . 'prefix is reserved for instance graphs, whose names persistInstance() mints ' + . 'from their own root address', + $name, + self::INSTANCE_PREFIX, )); } if ($mutable && $this->allocator === null) { - throw SharedMutationException::requiresSharedMode($className); + throw SharedMutationException::requiresSharedMode($name); } $this->attach(); + /** @var T */ + return $this->storeUnder($name, $this->convert($name, $object, $mutable)); + } + + /** + * Persists a graph under a name minted from its own root address + * + * The per-instance counterpart of persist(): where a name is a slot one graph occupies + * at a time - persisting a second AppConfig under `AppConfig::class` supersedes the + * first - an instance graph is one of MANY. A parallel task, a captured panic, a job + * payload: each instance gets its own entry, none upserts another, and any number of + * the same class are live at once. Nothing here is looked up by a name the caller + * chose; the graph's identity is its root's address ({@see self::sharedIdOf()}), which + * is exactly what the minted name records. + * + * Persisting an already-shared root again is idempotent: it resolves to the same name, + * and the generic upsert accounting nets every member's share count out unchanged. + * + * @template T of object + * + * @param T $object + * @param bool $mutable Persist as a SHARED MUTABLE graph (arena mode only) + * + * @return T The canonical persistent instance + */ + public function persistInstance(object $object, bool $mutable = false): object + { + if ($mutable && $this->allocator === null) { + throw SharedMutationException::requiresSharedMode($object::class); + } + $this->attach(); + + $entry = $this->convert($object::class, $object, $mutable); + + /** @var T */ + return $this->storeUnder(self::instanceKey($entry->root()), $entry); + } + + /** + * Drops an instance graph, by the shared instance persistInstance() returned or by its address + * + * The address form exists for the frozen store's alias discipline: drop() refuses to free + * a graph the request can still reach, and the instance passed as an argument IS such a + * reference - so a frozen-mode caller takes {@see self::addressOfInstance()} first, + * releases every reference, and drops by the number. Arena-backed stores skip the alias + * predicate (see guardedCandidates()), so the instance form is fine there. + * + * Returns false for a graph this store does not share - dropping what is not there is + * not an error, exactly as with drop(). + */ + public function dropInstance(object|int $target): bool + { + $address = \is_int($target) ? $target : $this->addressOfInstance($target); + + return $address !== null && $this->drop(self::instanceKey($address)); + } + + /** + * Converts a graph into persistent memory and stamps its role, without registering it + * + * @param string $label What to blame in a mode-conflict message: the entry name for + * persist(), the class name for persistInstance() + */ + private function convert(string $label, object $object, bool $mutable): PersistedEntry + { $entry = $this->persister->persistObject( $object, fn (int $address): ?PersistedObject => $this->registry->findObject($address), @@ -377,39 +462,46 @@ public function persist(string $className, object $object, bool $mutable = false foreach ($entry->members as $address) { $existing = $this->registry->findObject($address); if ($existing !== null && $existing->mutable !== $mutable) { - throw SharedMutationException::modeConflict($className, $existing->className, $mutable); + throw SharedMutationException::modeConflict($label, $existing->className, $mutable); } } foreach ($entry->created as $created) { $created->mutable = $mutable; } + return $entry; + } + + /** + * Registers a converted graph under its name, upserting whatever lived there before + */ + private function storeUnder(string $name, PersistedEntry $entry): object + { // Hydrated BEFORE the upsert overwrites the record, and released AFTER the new // members were share-incremented: an object belonging to both generations must // never transit through a share count of zero. Members the new graph keeps // referencing are protected, so only the truly superseded ones are candidates - $previous = $this->registry->findEntry($className); + $previous = $this->registry->findEntry($name); $candidates = []; if ($previous !== null) { - $candidates = $this->guardedCandidates($className, $previous, $entry->members); + $candidates = $this->guardedCandidates($name, $previous, $entry->members); } - $this->registry->store($className, $entry); + $this->registry->store($name, $entry); if ($previous !== null) { // The name already points at the new record - only the superseded generation // has to be released, never the key itself - $this->releaseEntry($className, $previous, $candidates, false); + $this->releaseEntry($name, $previous, $candidates, false); } - /** @var T */ - return $this->materialize($className, $entry); + return $this->materialize($name, $entry); } /** * Removes a persisted graph and reclaims every object no other entry still references * - * Returns false when nothing is stored under $className - dropping what is not there + * Returns false when nothing is stored under $name - dropping what is not there * is not an error. Objects shared with other entries survive with their share count * decremented; only members that no entry references anymore are freed (their sealed * arrays, snapshot buffers, clone blocks and metadata tables all go back to the @@ -426,23 +518,23 @@ public function persist(string $className, object $object, bool $mutable = false * entry's arrays must not be used after drop() returns; across requests the question * cannot arise, since request memory dies with its request. * - * @param class-string $className Storage key of the graph to remove + * @param string $name Storage key of the graph to remove * * @return bool Whether an entry was actually removed */ - public function drop(string $className): bool + public function drop(string $name): bool { // Attach first so handle state is consistent no matter when drop() is called $this->attach(); - $entry = $this->registry->findEntry($className); + $entry = $this->registry->findEntry($name); if ($entry === null) { return false; } - $candidates = $this->guardedCandidates($className, $entry, []); + $candidates = $this->guardedCandidates($name, $entry, []); - $this->releaseEntry($className, $entry, $candidates, true); + $this->releaseEntry($name, $entry, $candidates, true); return true; } @@ -450,7 +542,7 @@ public function drop(string $className): bool /** * Re-registers every persisted object for the current request * - * @return array class-string key => canonical graph root + * @return array entry name => canonical graph root */ public function attach(): array { @@ -464,8 +556,8 @@ public function attach(): array $this->register($address, $object->object); } - foreach ($this->registry->allEntries() as $className => $entry) { - $this->instances[$className] = self::instanceOf($this->rootObjectOf($entry)); + foreach ($this->registry->allEntries() as $name => $entry) { + $this->instances[$name] = self::instanceOf($this->rootObjectOf($entry)); } $this->armShutdown(); @@ -475,20 +567,19 @@ public function attach(): array } /** + * The graph root stored under $name, or null - `::class` names keep their inference + * * @template T of object * - * @param class-string $className + * @param class-string|string $name * - * @return T|null + * @return ($name is class-string ? T|null : object|null) */ - public function get(string $className): ?object + public function get(string $name): ?object { $this->attach(); - $instance = $this->instances[$className] ?? null; - \assert($instance === null || $instance instanceof $className); - - return $instance; + return $this->instances[$name] ?? null; } /** @@ -503,11 +594,11 @@ public function get(string $className): ?object * and hand out identical handle numbers, so handles collide by construction. The address * is the only stable identity across processes. * - * @param class-string $className + * @param string $name */ - public function addressOf(string $className): ?int + public function addressOf(string $name): ?int { - $entry = $this->registry->findEntry($className); + $entry = $this->registry->findEntry($name); return $entry?->root(); } @@ -537,6 +628,17 @@ public function addressOfInstance(object $instance): ?int return $this->registry->findObject($address) !== null ? $address : null; } + /** + * The entry name persistInstance() mints for a graph rooted at $address + * + * Deterministic on purpose: any process of the family can reconstruct it from the + * address alone, which is all dropInstance() needs and all a sibling ever holds. + */ + private static function instanceKey(int $address): string + { + return sprintf('%s%x', self::INSTANCE_PREFIX, $address); + } + /** * Materializes the persistent object living at $address for the current request * @@ -755,11 +857,11 @@ public function repairedSlotCount(): int } /** - * @param class-string $className + * @param string $name */ - public function has(string $className): bool + public function has(string $name): bool { - return $this->registry->has($className); + return $this->registry->has($name); } /** @@ -858,10 +960,10 @@ public function detach(): void * * @return list Members that releasing this entry would reclaim */ - private function guardedCandidates(string $className, PersistedEntry $entry, array $protected): array + private function guardedCandidates(string $name, PersistedEntry $entry, array $protected): array { - $hadInstance = isset($this->instances[$className]); - unset($this->instances[$className]); + $hadInstance = isset($this->instances[$name]); + unset($this->instances[$name]); // A member whose last referencing entry is this one, and which no successor keeps $candidates = []; @@ -889,14 +991,14 @@ private function guardedCandidates(string $className, PersistedEntry $entry, arr continue; } if ($hadInstance) { - $this->instances[$className] = self::instanceOf($this->rootObjectOf($entry)); + $this->instances[$name] = self::instanceOf($this->rootObjectOf($entry)); } throw new \RuntimeException(sprintf( 'Cannot release %s: the request still holds a reference to the persisted %s instance ' . 'that would be freed. Release every variable, property and array element pointing at ' . 'the graph (unset() them, or let their scope end) before dropping or replacing the entry.', - $className, + $name, $candidate->className, )); } @@ -914,7 +1016,7 @@ private function guardedCandidates(string $className, PersistedEntry $entry, arr * @param bool $unlink Whether the NAME still points at this entry * (false for the superseded half of an upsert) */ - private function releaseEntry(string $className, PersistedEntry $entry, array $candidates, bool $unlink): void + private function releaseEntry(string $name, PersistedEntry $entry, array $candidates, bool $unlink): void { // A bucket pointing at a freed clone would be walked at request shutdown foreach ($candidates as $candidate) { @@ -922,7 +1024,7 @@ private function releaseEntry(string $className, PersistedEntry $entry, array $c } if ($unlink) { - $this->registry->removeEntry($className, $entry); + $this->registry->removeEntry($name, $entry); } else { $this->registry->discardEntry($entry); } diff --git a/tests/PersistentStoreTest.php b/tests/PersistentStoreTest.php index 166f25e..dfbad98 100644 --- a/tests/PersistentStoreTest.php +++ b/tests/PersistentStoreTest.php @@ -18,8 +18,8 @@ /** * The persistent module and registry survive for the whole test process (that is the - * feature): persist() is an upsert per class-string key, so every test re-persists its - * own fresh state and detaches what it attached. + * feature): persist() is an upsert per name, so every test re-persists its own fresh + * state and detaches what it attached. */ class PersistentStoreTest extends TestCase { @@ -185,11 +185,86 @@ public function testInternalClassIsRejected(): void $this->store->persist(\ArrayObject::class, new \ArrayObject([1, 2, 3])); } - public function testKeyMustNameAClassOfTheInstance(): void + public function testAGraphMayBePersistedUnderAPlainName(): void + { + $persisted = $this->store->persist('primary-config', $this->makeConfig()); + + self::assertSame($persisted, $this->store->get('primary-config')); + self::assertTrue($this->store->has('primary-config')); + + unset($persisted); + $this->store->drop('primary-config'); + } + + public function testTwoInstancesOfOneClassLiveUnderTwoNames(): void + { + $first = $this->makeConfig(); + $second = $this->makeConfig(); + + $second->label = 'secondary'; + + $left = $this->store->persist('config-left', $first); + $right = $this->store->persist('config-right', $second); + + self::assertNotSame($left, $right); + self::assertSame('primary', $left->label); + self::assertSame('secondary', $right->label); + + unset($left, $right); + $this->store->drop('config-left'); + $this->store->drop('config-right'); + } + + public function testTwoInstanceGraphsOfOneClassAreBothLive(): void + { + $baseline = $this->store->objectCount(); + + $first = $this->makeConfig(); + $second = $this->makeConfig(); + + $second->label = 'secondary'; + + $left = $this->store->persistInstance($first); + $right = $this->store->persistInstance($second); + + self::assertNotSame($left, $right); + self::assertSame('primary', $left->label); + self::assertSame('secondary', $right->label); + self::assertSame($baseline + 2, $this->store->objectCount()); + + // The frozen store's alias discipline: release the instances, then drop by address. + $leftAddress = $this->store->addressOfInstance($left); + $rightAddress = $this->store->addressOfInstance($right); + self::assertNotNull($leftAddress); + self::assertNotNull($rightAddress); + unset($left, $right); + + self::assertTrue($this->store->dropInstance($leftAddress)); + self::assertTrue($this->store->dropInstance($rightAddress)); + self::assertSame($baseline, $this->store->objectCount()); + } + + public function testPersistingASharedInstanceAgainIsIdempotent(): void + { + $first = $this->store->persistInstance($this->makeConfig()); + $again = $this->store->persistInstance($first); + + self::assertSame($first, $again); + + // One drop suffices: the re-persist resolved to the same entry instead of minting one. + $address = $this->store->addressOfInstance($first); + self::assertNotNull($address); + unset($first, $again); + + self::assertTrue($this->store->dropInstance($address)); + self::assertFalse($this->store->dropInstance($address)); + } + + public function testTheInstanceNamePrefixIsReserved(): void { $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessageMatches('/must name a class or interface/'); - $this->store->persist(\DateTimeInterface::class, $this->makeConfig()); + $this->expectExceptionMessageMatches('/reserved for instance graphs/'); + $this->store->persist('@f00', $this->makeConfig()); } public function testDynamicPropertyIsRejected(): void