Skip to content

fix(collection): call upstream close() and keep the handle alive when destroy() fails (#222) - #238

Merged
s2x merged 1 commit into
mainfrom
fix/222-close-destroy-handle
Sep 29, 2026
Merged

s2x merged 1 commit into
mainfrom
fix/222-close-destroy-handle

Conversation

@s2x

@s2x s2x commented Sep 29, 2026

Copy link
Copy Markdown
Member

Closes #222. Blocks #226, which relies on close() and destroy() reporting FAILED_PRECONDITION while an iterator is open.

Two defects

1. close() never called upstream close()

ZVec::close() only dropped our reference from the global collections registry. The real close — flush, release open files, release the collection lock — ran later inside the upstream destructor, and its Status was discarded. So writes were not guaranteed to be on disk when close() returned, and close errors were invisible. The docblock already claimed it could throw ZVecException, which it never could.

It now calls upstream Collection::close(), new in v0.7.0. The outcome decides the object state:

outcome behaviour
FAILED_PRECONDITION (5, e.g. open iterators) upstream changed nothing → throw, keep the registry entry, keep the object open and usable
any other error upstream has already released its resources (close_locked() releases even when the final flush fails) → free the handle, mark closed, then throw
OK free the handle, mark closed

close() stays idempotent. __destruct() no longer propagates — PHP turns an exception raised during shutdown into a fatal error — so on failure it falls back to dropping the registry reference, which is the only way to reclaim the C++ object.

2. A failed destroy() freed the native collection

zvec_collection_destroy() erased the registry entry unconditionally, even when upstream returned an error. That deleted the C++ Collection while the PHP object still held the raw pointer, so the next method call used freed memory.

Reproduced before the fix by destroying a read-only collection, which upstream rejects:

destroy rejected: INVALID_ARGUMENT
fetch after failed destroy: 1        # was: "ZVecException: collection is already closed."

The entry is now erased only on success. On failure upstream leaves the collection untouched, so the handle stays valid — and destroy() throwing from checkStatus() before touching $this->closed already left the object open and usable. No PHP logic change was needed in destroy(), only the comment on its closed-branch catch, which claimed it prevented an "orphaned" C++ object. Before the fix the erase always happened, so that call was dead code.

A note on upstream's own C API

zvec_collection_close() deliberately does not mirror upstream's C API, where the function of the same name only deletes the handle and never calls Collection::close(). We follow the Python SDK, which calls the real close and raises on error — otherwise there would be no status to report and #226's guard could not work.

Verification

count=1 id=1                                       # close() flushed with no explicit flush()
second close: ok                                   # idempotent
closed: Collection is closed. Open with ZVec::open() to continue.
close after destroy: ok
destructor flushed: 7

Full suite:

Number of tests : 204               204
Tests skipped   :   0 (  0.0%)
Tests failed    :   0 (  0.0%)
Tests passed    : 202 ( 99.0%)

test_lifecycle_close_twice, test_close_vs_destroy, test_closed_collection_protection, test_lifecycle_destroy_after_close, test_lifecycle_destroy_then_destruct, test_lifecycle_method_on_destroyed and test_collection_destroy all pass unchanged — the new behaviour is additive from their point of view. test_null_handle_collection.phpt gained a null case, test_ffi_load.phpt the new symbol.

AGENTS.md's Collection Lifecycle section now states the rule that caused defect 2: do not free the handle before the status is known.

Note

The FAILED_PRECONDITION branch of close() is implemented and reasoned about, but not yet reachable by a test — it needs an open document iterator, which is #226. The test for it lands there. The other three branches are covered now.

🤖 Generated with Claude Code

… destroy() fails (#222)

Two defects in collection teardown, one cosmetic-looking and one
memory-unsafe.

close() never called upstream close()

ZVec::close() only dropped our reference from the global collections
registry. The real close -- flush pending writes, release open files,
release the collection lock -- ran later, inside the upstream destructor,
and its returned Status was discarded. So two things were wrong: writes
were not guaranteed to be on disk when close() returned, and any close
error was silently dropped. The docblock already claimed it could throw
ZVecException, which it never could.

It now calls upstream Collection::close(), new in zvec v0.7.0, and the
outcome decides the object state:

- FAILED_PRECONDITION (code 5, e.g. document iterators are open): upstream
  changed nothing, so throw and keep both the registry entry and the open
  state. The collection stays usable.
- any other error: upstream has already released its resources -- its
  close_locked() releases even when the final flush fails -- so free the
  handle, mark the object closed, and *then* throw. Leaving it looking
  open would be a lie with a freed C++ object behind it.

close() stays idempotent. __destruct() no longer propagates: PHP turns an
exception raised during shutdown into a fatal error, so on failure it
falls back to dropping the registry reference, which is the only way to
reclaim the C++ object.

A failed destroy() freed the native collection

zvec_collection_destroy() erased the registry entry unconditionally, even
when upstream returned an error. That deleted the C++ Collection while the
PHP object still held the raw pointer, so the next method call used freed
memory.

Reproduced before the fix by destroying a read-only collection, which
upstream rejects with INVALID_ARGUMENT:

    destroy rejected: INVALID_ARGUMENT
    fetch after failed destroy: 1        # was: "collection is already closed."

The entry is now erased only on success. On failure upstream leaves the
collection untouched, so the handle stays valid and destroy() throwing
from checkStatus() before touching $this->closed leaves the object open
and usable, which is what the PHP code already assumed.

destroy() itself needed no logic change; only the comment on its
closed-branch catch block, which previously said it prevented an
"orphaned" C++ object. Before this fix the erase always happened, so that
call was dead code.

Note zvec_collection_close() deliberately does not mirror upstream's own C
API, where zvec_collection_close() only deletes the handle and never
calls Collection::close(). We follow the Python SDK, which calls the real
close and raises on error.

Tests: 204/204, 0 skipped, 0 failed, 2 expected fail.

- tests/bug_0058.phpt: failed destroy() on a read-only collection, then
  fetch() and a directory check.
- tests/test_collection_close_real.phpt: close() flushes with no explicit
  flush(), the lock is released so the path reopens, second close is a
  no-op, close() after destroy() is a no-op, and the destructor path
  flushes.
- test_null_handle_collection.phpt: null-handle case for the new function.
- test_lifecycle_close_twice, test_close_vs_destroy,
  test_closed_collection_protection, test_lifecycle_destroy_after_close,
  test_lifecycle_destroy_then_destruct, test_lifecycle_method_on_destroyed
  and test_collection_destroy all pass unchanged.
@s2x
s2x merged commit 23c117e into main Sep 29, 2026
16 of 18 checks passed
@s2x
s2x deleted the fix/222-close-destroy-handle branch September 29, 2026 09:02
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.

fix(collection): make close() call upstream Collection::close() and report errors; keep failed destroy() from freeing the handle

1 participant