fix(collection): call upstream close() and keep the handle alive when destroy() fails (#222) - #238
Merged
Merged
Conversation
… 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #222. Blocks #226, which relies on
close()anddestroy()reportingFAILED_PRECONDITIONwhile an iterator is open.Two defects
1.
close()never called upstreamclose()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 itsStatuswas discarded. So writes were not guaranteed to be on disk whenclose()returned, and close errors were invisible. The docblock already claimed it could throwZVecException, which it never could.It now calls upstream
Collection::close(), new in v0.7.0. The outcome decides the object state:FAILED_PRECONDITION(5, e.g. open iterators)close_locked()releases even when the final flush fails) → free the handle, mark closed, then throwclose()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 collectionzvec_collection_destroy()erased the registry entry unconditionally, even when upstream returned an error. That deleted the C++Collectionwhile 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:
The entry is now erased only on success. On failure upstream leaves the collection untouched, so the handle stays valid — and
destroy()throwing fromcheckStatus()before touching$this->closedalready left the object open and usable. No PHP logic change was needed indestroy(), only the comment on its closed-branchcatch, 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 callsCollection::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
Full suite:
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_destroyedandtest_collection_destroyall pass unchanged — the new behaviour is additive from their point of view.test_null_handle_collection.phptgained a null case,test_ffi_load.phptthe 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_PRECONDITIONbranch ofclose()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