fix(writer-lease): allow takeover of an expired lease on stores witho… - #210
SanthoshRaaj-KR wants to merge 1 commit into
Conversation
…ut conditional update
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
The fallback overwrite is not atomic with the ownership check and could permit concurrent writers.
Get a fresh assessment by requesting another Copilot review.
Review effort: Lite
Findings: 1
What changed in this PR
Fixes writer-lease recovery after unclean LocalFileSystem restarts and adds regression coverage.
Changes:
- Removes the holder-ID gate from the fallback overwrite.
- Adds an expired-lease takeover test.
| File | Summary |
|---|---|
src/engine/writer_lease.rs |
Updates lease fallback logic and adds coverage. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| Err(ObjectStoreError::NotImplemented { .. }) => { | ||
| // LocalFileSystem lacks conditional update. The ownership guard | ||
| // above already returned NotCellWriter for a lease still live | ||
| // under another process, so everything reaching here is absent, | ||
| // released, expired, or this process's own — none of which needs |
|
Comments Outside DiffThese findings sit on lines the diff does not cover, so they could not be posted inline. Each one leaves this list once its file changes.
|
OpenHack SummarySecurity review of fix(writer-lease): allow takeover of an expired lease on stores witho…. 1 changed file; 1 finding at or above the low reporting threshold. Confidence Score: 2/5Review the findings below before merging. Security merge-readiness rubric: 1 = critical, 2 = high, 3 = medium, 4 = low, 5 = no reportable findings. This score reflects scan findings, not a guarantee of correctness or complete coverage. Files Needing Attention: src/engine/writer_lease.rs Important Files Changed
Prompt To Fix With AIReview the findings for https://github.com/hydra-db/hydradb/pull/210 at commit 33fb235ea51a7f120f566fe7f31edf37c02f5ab7. Verify each finding against the current code before fixing it. Preserve unrelated changes and run focused regression tests.
### Issue 1: [P2] Non-atomic lease takeover on local object stores enables split-brain writers
Vulnerability type: Business logic flaw
src/engine/writer_lease.rs:280
The pull request changes the no-conditional-update path from `Err(ObjectStoreError::NotImplemented { .. }) if same_holder` to an unconditional fallback. On LocalFileSystem, multiple processes can observe an expired lease, compute the same next generation, and pass the non-atomic ownership check before any of them writes. Each then overwrites the lease file and reports success, so more than one process can believe it owns the same cell lease. The deleted behavior failed closed for stale takeovers, preventing this race but also stranding leases after an unclean restart. The regression is therefore the removal of atomic takeover protection, not merely lease recovery.
Recommendation: Do not use an unconditional overwrite as the authorization mechanism for stale lease takeover. Preserve fail-closed behavior for non-same-holder takeovers unless the store provides an atomic compare-and-swap or create-only coordination primitive. For LocalFileSystem, implement an atomic takeover protocol that serializes the read-check-write window (for example, an exclusive lock/create-only coordination object), and include a per-attempt nonce with post-write verification so a contender that loses the race relinquishes the lease and fails or retries. Add a concurrent expired-lease test asserting that at most one contender succeeds.
Last reviewed commit: 33fb235 · View review on OpenHack
|
| // LocalFileSystem lacks conditional update. Overwrite is safe | ||
| // only for the still-valid incumbent; stale takeovers remain | ||
| // fail-closed because they require real compare-and-swap. | ||
| Err(ObjectStoreError::NotImplemented { .. }) => { |
There was a problem hiding this comment.
Non-atomic lease takeover on local object stores enables split-brain writers
Vulnerability type: Business logic flaw
The pull request changes the no-conditional-update path from Err(ObjectStoreError::NotImplemented { .. }) if same_holder to an unconditional fallback. On LocalFileSystem, multiple processes can observe an expired lease, compute the same next generation, and pass the non-atomic ownership check before any of them writes. Each then overwrites the lease file and reports success, so more than one process can believe it owns the same cell lease. The deleted behavior failed closed for stale takeovers, preventing this race but also stranding leases after an unclean restart. The regression is therefore the removal of atomic takeover protection, not merely lease recovery.
Location: src/engine/writer_lease.rs:280
Recommendation:
Do not use an unconditional overwrite as the authorization mechanism for stale lease takeover. Preserve fail-closed behavior for non-same-holder takeovers unless the store provides an atomic compare-and-swap or create-only coordination primitive. For LocalFileSystem, implement an atomic takeover protocol that serializes the read-check-write window (for example, an exclusive lock/create-only coordination object), and include a per-attempt nonce with post-write verification so a contender that loses the race relinquishes the lease and fails or retries. Add a concurrent expired-lease test asserting that at most one contender succeeds.
Prompt To Fix With AI
Review the findings for https://github.com/hydra-db/hydradb/pull/210 at commit 33fb235ea51a7f120f566fe7f31edf37c02f5ab7. Verify each finding against the current code before fixing it. Preserve unrelated changes and run focused regression tests.
### Issue 1: [P2] Non-atomic lease takeover on local object stores enables split-brain writers
Vulnerability type: Business logic flaw
src/engine/writer_lease.rs:280
The pull request changes the no-conditional-update path from `Err(ObjectStoreError::NotImplemented { .. }) if same_holder` to an unconditional fallback. On LocalFileSystem, multiple processes can observe an expired lease, compute the same next generation, and pass the non-atomic ownership check before any of them writes. Each then overwrites the lease file and reports success, so more than one process can believe it owns the same cell lease. The deleted behavior failed closed for stale takeovers, preventing this race but also stranding leases after an unclean restart. The regression is therefore the removal of atomic takeover protection, not merely lease recovery.
Recommendation: Do not use an unconditional overwrite as the authorization mechanism for stale lease takeover. Preserve fail-closed behavior for non-same-holder takeovers unless the store provides an atomic compare-and-swap or create-only coordination primitive. For LocalFileSystem, implement an atomic takeover protocol that serializes the read-check-write window (for example, an exclusive lock/create-only coordination object), and include a per-attempt nonce with post-write verification so a contender that loses the race relinquishes the lease and fails or retries. Add a concurrent expired-lease test asserting that at most one contender succeeds.
TIP: Reply
@openhack-agentor@openhack-agent fix thisto fix this finding. To ask a question, mention@openhack-agentfollowed by your question.
|
I added the requested race test and confirmed the reviewer's concern. The test starts two replacement processes and has both attempt to take over the same expired lease on Both processes acquire the lease and both return generation 2. This confirms that the current patch breaks the single-writer guarantee, so it should not merge in its current form. I don't think there is a small safe fix for this:
With only One possible design is to use generation-suffixed claim files: create I don't think I should make that design decision in this PR, so I've left the PR as a draft for now. I'd appreciate guidance on whether this is the direction you'd like to take. |

Summary
On
LocalFileSystem(CLOUD_PROVIDER=local), a node that comes back from an unclean restart can no longer write. Reads continue returning 200 and/readyzstays green, so the node appears healthy while mutations fail with an opaque 500.Reported in #196.
Root cause
acquire_or_renew_innerrenews a lease withPutMode::Update(version)once a lease file already exists.LocalFileSystemdoes not support conditional updates, so this returnsNotImplemented. There is a fallback to a plainputfor exactly this case, but it was gated onsame_holder.holder_idis a fresh ULID per process (process_holder_id()). After a restart, the new process has a different holder ID from the one stored in the existing lease. The fallback is therefore skipped, andacquire_or_renewreturnsNotImplementedon subsequent write attempts. Reads never reach this path becauseensure_local_writeris write-path only, which is why the failure is invisible to health checks.The
same_holdergate was redundant for safety. The ownership check immediately above the write already returnsNotCellWriterwhen a lease is still active under a different process. Therefore, anything that reaches the write is an absent, released, expired, or already-owned lease. None of these cases requires compare-and-swap for this overwrite.release_storedalready performs this fallback without thesame_holdercheck, so this change also makes the renew and release paths consistent.Why this wasn't caught
A graceful shutdown calls
release_stored, which deletes the lease file onLocalFileSystem. The next start therefore takes thePutMode::Createpath and succeeds.Only an unclean stop leaves the lease file behind.
scripts/runtime_smoke.shalso clears its store on every run, so it does not exercise a restart with existing state.Changes
same_holdercondition from theNotImplementedfallback inacquire_or_renew_innerand document the invariant that makes the plainputsafe.expired_lease_is_recoverable_on_a_store_without_conditional_update.Testing
The new test is a
LocalFileSystemanalogue of the existingfresh_observer_does_not_restart_an_expired_lease_window, which runs onInMemory.InMemoryimplements conditional update and therefore does not reach the branch covered by this fix.The test verifies both sides of the invariant: a restarted process is still refused while the incumbent lease is active, and succeeds once that lease has expired.
Without the fix, the test fails with:
With the fix,
cargo test --locked --libpasses 204/204.restarted_process_with_same_node_id_cannot_share_the_leasestill passes, so a restarted process cannot take over a lease that is genuinely still held.Only the
NotImplementedbranch is changed, so behavior is unchanged for stores that implement conditional updates.Not verified locally
I was unable to run
just fenceorjust stresslocally. Both exercise writer takeover and require the full native feature set, which my environment cannot currently build. These should be verified by CI or a reviewer.Related
CLOUD_PROVIDER=localconfig:put_optswithPutMode::Updateunimplemented by LocalFileSystem #81 reports the sameput_opts/PutMode::Update/NotImplementedfailure onLocalFileSystemand may also be resolved by this change./v1/graphs/{graph_id}/queryhas no working mutation/delete cycle in Docker local mode (?) #196, including standaloneCREATE,CREATE ... RETURN,MERGE, andUNWIND ... CREATE, are separate query-engine limitations and are not addressed by this PR.