Skip to content

fix(#38): break Transaction<->Snapshot reference cycle - #78

Open
s2x wants to merge 2 commits into
masterfrom
fix/issue-38-snapshot-reference-cycle
Open

fix(#38): break Transaction<->Snapshot reference cycle#78
s2x wants to merge 2 commits into
masterfrom
fix/issue-38-snapshot-reference-cycle

Conversation

@s2x

@s2x s2x commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Closes #38

Problem

Transaction::snapshot() cached the Snapshot instance on the parent transaction:

  • Transaction -> $snapshotInstance -> Snapshot -> $parentTransaction -> Transaction

Both sides held strong references, so refcounts never reached zero on scope exit and
Transaction::__destruct() (i.e. fdb_transaction_destroy()) only ran via the cycle
collector — or at process shutdown when zend.enable_gc=0. The cycle was created on
every snapshot() call: Database::readTransact(), every HighContentionAllocator::allocate()
(i.e. every directory create/open), and Locality. A long-running worker therefore
accumulated undestroyed native FDB transaction handles.

Change

snapshot() now returns a fresh Snapshot per call (as proposed in the issue).
The Snapshot still anchors its parent one-directionally, so the shared native handle
remains valid for the snapshot's lifetime; once both go out of scope, destruction is
deterministic by refcounting alone. Read-only semantics are unchanged.

Verification

Check Result
composer lint (PHPCS + Rector + PHPStan L9) ✅ no errors
composer test:unit ✅ 436 tests
Integration suite (Docker 5-node cluster) ✅ 206 tests
New unit tests vs old code ❌ fail as expected (cycle present)
New integration tests vs old code ❌ fail as expected (90 cyclic objects collected after 30 directory creates)

New tests:

  • tests/Unit/SnapshotLifetimeTest.php — native-free (reflection-built objects, types-only FFI scope): fresh instance per call, snapshot anchors parent, deterministic destruction without gc_collect_cycles(), destructor-run observable.
  • tests/Integration/TransactionLifecycleTest.php — weak-ref loop over createTransaction()+snapshot()+getReadVersion(); directory creation loop leaves zero cyclic garbage.

Docs: lifetime/ownership notes added to docs/transactions.md. Changelog entry added under [Unreleased] → Fixed.

Transaction::snapshot() cached the Snapshot on the parent transaction,
closing a reference cycle (Transaction -> snapshotInstance ->
Snapshot -> parentTransaction) that kept both objects alive by refcount
and deferred fdb_transaction_destroy() to the cycle collector - or to
process shutdown when zend.enable_gc=0. Long-running workers calling
readTransact(), directory operations (HighContentionAllocator) or
Locality accumulated undestroyed native transaction handles.

snapshot() now returns a fresh Snapshot per call. The Snapshot still
anchors its parent one-directionally, so the shared native handle stays
valid for the snapshot's lifetime and both are freed deterministically
on scope exit.

- Unit: tests/Unit/SnapshotLifetimeTest.php (native-free, reflection-
  built; asserts fresh instance per call, anchor semantics, and
  refcount-only destruction without gc_collect_cycles)
- Integration: tests/Integration/TransactionLifecycleTest.php (weak-ref
  loop over createTransaction+snapshot; directory creation leaves no
  cyclic garbage). Both fail against the previous implementation.
- Docs: snapshot lifetime/ownership notes in docs/transactions.md
Readonly properties may only be reflection-initialized from their
declaring class scope; on PHP < 8.4 a subclass scope (Transaction for
props declared on ReadTransaction) is rejected. Resolve the
ReflectionProperty against ReadTransaction::class explicitly.
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.

[Memory] Transaction/Snapshot reference cycle defers native transaction destruction

1 participant