FLPATH-4769 | Fix status consumer race by persisting instance before dispatch - #46
FLPATH-4769 | Fix status consumer race by persisting instance before dispatch#46jordigilh wants to merge 2 commits into
Conversation
PR Summary by QodoFix create/status race by persisting instance before provider dispatch
AI Description
Diagram
High-Level Assessment
Files changed (5)
|
Code Review by Qodo
1. Delete/create orphan race
|
| created, err := s.store.ServiceTypeInstance().Create(ctx, instance) | ||
| if err != nil { | ||
| log.Error("Failed to create instance in store", "instance_id", *instanceID, "error", err) | ||
| return nil, service.NewInternalError(fmt.Sprintf("failed to create database record for instance %s: %v", providerResponse.ID, err)) | ||
| return nil, service.NewInternalError(fmt.Sprintf("failed to create database record for instance %s: %v", *instanceID, err)) |
There was a problem hiding this comment.
1. Delete/create orphan race 🐞 Bug ≡ Correctness
Because CreateInstance now persists the row before calling the provider, a caller-supplied instance ID can be deleted while the provider create request is still in flight. DeleteInstance treats provider 404 as success and then HardDelete removes the DB row, so the provider can still finish creating the resource and subsequent status events are Ack()’d as not-found, leaving an orphaned provider instance with no DB record.
Agent Prompt
### Issue description
`CreateInstance` now persists an instance row before dispatching to the provider. This makes the (potentially caller-supplied) instance ID visible to other API calls while the provider request is still in flight. In that window, `DeleteInstance` can:
1) successfully `Get()` the placeholder row,
2) call provider DELETE,
3) treat provider `404` as success,
4) `HardDelete` the DB record,
while the in-flight provider create may still succeed afterward, leaving an orphaned provider resource with no DB row and future status events being discarded.
### Issue Context
- The API supports a caller-supplied instance ID (`CreateInstanceParams.Id`), so a client can know the ID before `CreateInstance` returns.
- Provider deletes treat `404` as non-error.
### Fix Focus Areas
- internal/sp/service/resource_manager/service_type_instance.go[85-113]
- internal/sp/service/resource_manager/service_type_instance.go[217-371]
### Suggested fix direction
Implement a state-aware lifecycle guard so delete cannot “win” against an in-flight create:
- If the instance is still in the placeholder status (`StatusPending`), either:
- reject non-deferred deletes with a conflict/try-later error, **or**
- force the delete into deferred/scheduled deletion (do not hard-delete immediately), **or**
- treat provider `404` as retryable (not success) when deleting a `StatusPending` instance, and schedule cleanup instead of hard-deleting.
- Ensure the DB record remains until you can conclusively determine the create outcome and/or guarantee the provider resource is gone.
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
| providerResponse, err := s.createInstanceWithProvider(ctx, provider.Endpoint, request, instanceID) | ||
| if err != nil { | ||
| log.Error("Provider provisioning failed", "instance_id", *instanceID, "provider_name", providerName, "error", err) | ||
| if delErr := s.store.ServiceTypeInstance().HardDelete(ctx, *instanceID); delErr != nil { | ||
| log.Error("Failed to roll back placeholder instance after provider failure", "instance_id", *instanceID, "error", delErr) |
There was a problem hiding this comment.
2. Rollback deletes updated row 🐞 Bug ☼ Reliability
On provider create errors, CreateInstance unconditionally HardDelete’s the placeholder row even though the new flow explicitly allows real status events to update that row while the provider call is in flight. For ambiguous failures (e.g., timeouts/connection errors or provider-side acceptance with client-side error), this can erase the only tracking record for a potentially-created resource and causes later status events to be permanently discarded as ErrInstanceNotFound.
Agent Prompt
### Issue description
`CreateInstance` now persists a placeholder instance record and allows async status updates to land during the provider create call. However, on provider error it unconditionally `HardDelete`s the row. If the provider error is ambiguous (network/timeout) and the provider actually proceeded (or if a real status event already updated the row), this rollback deletes meaningful lifecycle state and makes subsequent status events get Ack()'d as not-found.
### Issue Context
- The PR already introduced `UpdateStatusIfPending` to prevent the synchronous provider response from overwriting newer async state, acknowledging mid-flight status arrival.
- The same mid-flight arrival can happen before the provider call returns an error.
### Fix Focus Areas
- internal/sp/service/resource_manager/service_type_instance.go[105-134]
- internal/sp/consumer/consumer.go[150-160]
### Suggested fix direction
Make rollback state-aware:
- Before deleting on provider error, re-check the DB row:
- If it is still `StatusPending` (and not otherwise marked), then rollback-delete as today.
- If it has moved past `StatusPending` (or has other indicators that async processing has begun), do **not** hard-delete; instead leave the record for reconciliation and/or mark a failure state that can be healed by later status events.
- Optionally introduce a dedicated “create_in_flight/dispatch_started” marker to distinguish placeholder from real provider-emitted statuses that might coincidentally equal `PENDING`.
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
InstanceService.CreateInstance dispatched to the provider before persisting the instance's DB row. Any status event for that instance (e.g. from StatusConsumer) arriving while the create request was still in flight found no record, hit ErrInstanceNotFound, and was unconditionally Ack'd - silently and permanently discarding it. Persist a PENDING placeholder row first, then dispatch, then apply the provider's response status via a new conditional UpdateStatusIfPending so a newer status already applied mid-dispatch is never regressed by the provider's own (possibly stale) synchronous response. Roll the placeholder back via HardDelete if the provider call fails, preserving the existing "no artifact on failed create" contract. This brings the implementation in line with the already-approved sp-resource-manager and sp-resource-status-reader design docs, which both assume the DB row exists before any status event can arrive. Fixes: [FLPATH-4769](https://redhat.atlassian.net/browse/FLPATH-4769) Closes dcm-project#44 --- Assisted-By: Claude (Anthropic) Signed-off-by: Jordi Gil <jgil@redhat.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1bfe0c9 to
279c395
Compare
|
Draft, not seeking review yet. This PR fixes the original status-consumer race (closes #44), but reviewing this diff surfaced two additional correctness gaps: a delete/create orphan race, and a rollback that can discard a status update that already landed. Closing those requires a new correctness contract that isn't in the design doc yet, so the fix is gated on documenting it first: dcm-project/enhancements#102. Holding this PR in draft until that enhancement PR is approved/merged. Once it lands, I'll push the additional commits here, update the description, and mark this ready for review. Tracking: FLPATH-4769 |
| // newer, real status must win over this synchronous response, which may | ||
| // already be stale by the time it arrives (this is the same class of | ||
| // hazard this fix closes, one step later in the flow). | ||
| applied, err := s.store.ServiceTypeInstance().UpdateStatusIfPending(ctx, *instanceID, rmstore.StatusPending, providerResponse.Status, "") |
There was a problem hiding this comment.
Not sure why we need to update it from Pending to the provider's status. Initially, we agreed providers will send PROVISINONING as response status on acceptance but since we are moving the setting it wihin the create function, I believe we don't need to persist the provider's status here. Also, we will be moving to publishing for CloudEvents for the create and delete.
There was a problem hiding this comment.
Agreed. The per-type status vocabularies are actually well-defined and each SP follows theirs correctly, so the value itself isn't the issue — but UpdateStatusIfPending exists specifically to lose to a real status event, and nothing downstream reads it. Not worth the complexity, especially given the CloudEvent move.
Dropping the post-dispatch UpdateStatusIfPending call/branching and BAC-5 (it only existed to guard that write). Rollback-on-failure stays.
…Consumer CreateInstance persisted the provider's synchronous HTTP response status (via UpdateStatusIfPending) after a successful dispatch. Per-service-type status vocabularies are well-defined and providers follow them correctly, but the write itself added complexity for no real benefit: it's designed to lose to a real status event, and StatusConsumer's async update supersedes it within ~100ms-2s in practice. Nothing downstream reads the value off the create response either. Drop the post-dispatch UpdateStatusIfPending call and its applied/re-fetch branching - the row stays PENDING after a successful dispatch, and StatusConsumer owns every subsequent transition. Remove BAC-5 and its test, since it only existed to guard this write, and delete UpdateStatusIfPending itself (store method + interface + mock stub) now that CreateInstance was its only caller. Addresses review feedback on dcm-project#46. --- Assisted-By: Claude (Anthropic) Signed-off-by: Jordi Gil <jgil@redhat.com> Co-authored-by: Cursor <cursoragent@cursor.com> Signed-off-by: Jordi Gil <jgil@redhat.com>
|
Closing as this gap will be covered this milestone. |
Summary
InstanceService.CreateInstancedispatched the create request to the provider's REST endpoint before persisting the instance's own DB row. Any status event for that instance (e.g. fromStatusConsumer) arriving while the create request was still in flight found no record, hitErrInstanceNotFound, and was unconditionallyAck()'d — silently and permanently discarded, with noMaxDeliver/backoff configured to self-heal it.Fixes #44. Closes FLPATH-4769.
Why this approach (reorder, not bounded JetStream retry)
The issue proposed two options. This isn't a coin-flip between two valid architectures — the already-approved (7-reviewer) design docs for this exact subsystem mandate persist-before-dispatch, so today's code is a bug relative to spec, not an open design question:
enhancements/sp-resource-manager/sp-resource-manager.md— the creation-flow sequence diagram and steps both place "Creates an instance record in the database with statusPENDING" before "Publishes a creation request CloudEvent to the agent's topic".enhancements/sp-resource-status-reader/sp-resource-status-reader.mdstates the consumer "Updates existing instance records created duringPOST /api/v1/service-type-instances", and that a not-found is only expected "when an instance has been deleted before the status event was processed or when events arrive out of order" — i.e. it assumes the record already exists, which today's ordering violates.MarkForDeletion→ persistSCHEDULED→ dispatch via backgroundScheduler) already follows persist-then-act.Rejected alternative (bounded JetStream
Nak/MaxDeliverretry): technically feasible, but no design doc anywhere prescribes bus-level redelivery for this consumer, andUpdateStatushas no sequencing guard — a redelivered stale event could regress status backward if a newer legitimate event already landed in the meantime. Reordering closes the race at its structural source instead of papering over it with retries.What changed
internal/sp/service/resource_manager/service_type_instance.goCreateInstance:PENDINGplaceholder row before dispatching to the provider.HardDeletethe placeholder (preserves the existing "no artifact on failed create" contract) before returning the error.PENDING.StatusConsumerowns every subsequent transition — the provider's synchronous response status is no longer persisted (see review discussion: per-type status vocabularies are well-defined and providers follow them correctly, but the value is designed to lose to a real async status event anyway, which lands within ~100ms-2s in practice, and nothing downstream reads it off the create response).Tests (TDD, BAC-driven)
All new/changed tests stay at the same tier as the existing suite (Ginkgo component tests, in-memory SQLite,
httptestmock provider) — the race is closed structurally insideCreateInstance, so no integration/E2E coverage is needed and the test pyramid shape is preserved.ErrInstanceNotFound), passes after the fix.GetInstance."creates a new instance"test) and all other existingCreateInstancetests are unchanged and remain green.No changes needed to
internal/sp/consumer/consumer.goor its tests — the fix closes the race at the source, so the consumer's existing "unconditionalAckon not-found" behavior becomes correct as originally designed.Full
internal/spsuite passes with-race.Assisted-By: Claude (Anthropic)
Made with Cursor