Skip to content

refactor(car-sharing): harden the trip saga (idempotency, compensation order, error mapping)#35

Merged
intech merged 1 commit into
mainfrom
chore/car-sharing-harden-saga
Jun 23, 2026
Merged

refactor(car-sharing): harden the trip saga (idempotency, compensation order, error mapping)#35
intech merged 1 commit into
mainfrom
chore/car-sharing-harden-saga

Conversation

@intech

@intech intech commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Why

Mirrors the four CodeRabbit "Major" hardenings raised on the hris onboarding saga (#31) into the gold-standard car-sharing saga, so the two flagship examples stay in lockstep. Each is an example-grade simplification today; this makes the saga genuinely retry-safe.

What

  • Idempotent reservation (F3). reserveVehicle is a lock acquisition, not a create — read-back equivalence would re-admit double-booking (no holder to disambiguate). So add holder_id to ReserveVehicleRequest + a holder column to vehicles: ReserveVehicle stamps the holder and, on a held vehicle, succeeds only when the same holder re-reserves (a Temporal retry observing its own commit); a different holder stays FAILED_PRECONDITION. ReleaseVehicle clears the holder. (hris feat(hris): durable onboarding saga (Temporal) — Phase 5a #31 uses the complementary read-back technique, since its step 1 is a create-by-id.)
  • GetTrip error mapping (F2). The blanket catch {} mapped any query failure to a terminal status. Narrowed: only WorkflowNotFoundError / QueryNotRegisteredError / QueryRejectedError (run closed/gone) fall back to describe(); a transient failure surfaces as Code.Unavailable instead of a bogus SETTLED/CANCELLED trip.
  • Compensation ordering (F4). Register markTripCancelled (step 2) and voidTab (step 5) before their forward calls, so an ambiguous failure that committed the side effect is still unwound (both comps no-op on uncommitted state). Step 1 and step 6 (refundCharge needs the charge id) stay register-after.
  • Dockerfile (F1). Replace the inaccurate "1.0.0 pinned" note (deps are ^1.0.0 caret ranges) with an honest no-committed-lockfile rationale pointing production users at --frozen-lockfile; flagship no-lockfile policy kept.

Verification

  • New regression tests: same-holder reserve is idempotent; a different holder is rejected (no double-booking); GetTrip surfaces Unavailable on a transient query failure; recordTrip/openTab failure unwinds the pre-registered compensation (proves F4's register-before).
  • Drizzle migration + journal committed; PGlite e2e runs them.
  • pnpm typecheck ✓ · pnpm test 44/44 ✓ (was 39).

Companion: the same hardenings land on hris #31 (read-back for F3 there).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Implemented idempotent vehicle reservation system to prevent double-booking during transient retries
  • Bug Fixes

    • Improved error handling in trip queries to distinguish between closed workflows and transient failures
  • Documentation

    • Enhanced saga compensation pattern documentation and idempotency guarantees
  • Tests

    • Expanded test coverage for idempotent operations and compensation logic verification

…n order, error mapping)

Apply the same hardenings CodeRabbit flagged on the hris onboarding saga to the
gold-standard car-sharing saga so the two examples stay in lockstep.

- Idempotent reservation (F3): add `holder_id` to ReserveVehicleRequest + a
  `holder` column to the vehicles table. ReserveVehicle now stamps the holder
  and, on a held vehicle, returns success only when the SAME holder re-reserves
  (a Temporal retry observing its own commit) — a different holder is still
  FAILED_PRECONDITION (no double-booking). ReleaseVehicle clears the holder.
  reserveVehicle is a lock-acquisition (not a create), so read-back equivalence
  is unsafe here; a holder key is the correct idempotency technique.
- GetTrip error mapping (F2): the blanket `catch {}` mapped ANY query failure to
  a terminal status. Narrow it: only WorkflowNotFoundError / QueryNotRegistered /
  QueryRejected (run closed/gone) fall back to describe(); a transient failure is
  surfaced as Code.Unavailable instead of being reported as a SETTLED/CANCELLED
  trip.
- Compensation ordering (F4): register markTripCancelled (step 2) and voidTab
  (step 5) BEFORE their forward calls, so an ambiguous failure that committed the
  side effect is still unwound. Both comps are no-ops on uncommitted state. Step 1
  and step 6 (refundCharge needs the charge id) stay register-after.
- Dockerfile (F1): replace the inaccurate "1.0.0 pinned" note with an honest
  no-committed-lockfile rationale that points production users at
  `--frozen-lockfile`; keep the flagship no-lockfile policy.

New regression tests: same-holder reserve is idempotent; a different holder is
rejected; GetTrip surfaces Unavailable on a transient query failure. Migration +
journal committed. typecheck + tests: 42/42 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MdeH7fExPmiRHRirGuvGk3
@coderabbitai

coderabbitai Bot commented Jun 23, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The PR adds a nullable holder column to the vehicles table and a holder_id field to ReserveVehicleRequest, making vehicle reservation idempotent across Temporal retries. The TripWorkflow saga's compensation registration order is corrected so markTripCancelled and voidTab are pushed before their corresponding forward steps. TripService.getTrip gains precise error classification to return Code.Unavailable on transient query failures instead of incorrectly falling back to terminal status.

Changes

Idempotent Reservation, Saga Robustness, and Error Classification

Layer / File(s) Summary
DB migration and schema contract for holder column
car-sharing/drizzle/0001_sturdy_wendell_vaughn.sql, car-sharing/drizzle/meta/0001_snapshot.json, car-sharing/drizzle/meta/_journal.json, car-sharing/src/db/schema.ts
Adds the nullable holder text column to the vehicles table via migration SQL, Drizzle snapshot, and journal entry; reflects it in the TypeScript schema and JSDoc with idempotency semantics.
Proto contract: holder_id on ReserveVehicleRequest
car-sharing/proto/fleet/v1/fleet.proto
Extends ReserveVehicleRequest with string holder_id = 2 and documents the idempotent same-holder and conflicting different-holder behaviors.
Fleet service idempotent reserve and holder-clearing release
car-sharing/src/services/fleetService.ts
The atomic UPDATE in reserveVehicle now stamps holder; when the update matches no row, a same-holderId match on the existing row returns success (retry), while a mismatch throws FAILED_PRECONDITION. releaseVehicle now clears holder to null.
Temporal activity and saga compensation timing
car-sharing/src/temporal/activities.ts, car-sharing/src/temporal/workflows.ts
Threads holderId: tripId into the reserveVehicle activity; reorders compensation pushes so markTripCancelled is registered before recordTrip and voidTab before openTab, enabling correct LIFO unwind on ambiguous failures.
TripService closed vs transient error classification
car-sharing/src/services/tripService.ts
Introduces isClosedOrMissingRun to detect WorkflowNotFoundError/QueryNotRegisteredError/QueryRejectedError; getTrip falls back to handle.describe() only for those cases and throws ConnectError(Code.Unavailable) for all other query failures.
Activity, workflow, and e2e tests
car-sharing/tests/activity/activities.test.ts, car-sharing/tests/workflow/tripWorkflow.test.ts, car-sharing/tests/e2e/e2e.test.ts
Updates activity tests to pass holderId; adds same-holder idempotency and different-holder conflict coverage; adds workflow tests for compensation unwind on recordTrip and openTab failures; adds e2e test asserting Code.Unavailable on transient query errors.
README and Dockerfile documentation
car-sharing/README.md, car-sharing/Dockerfile
Documents compensation registration timing and holder_id idempotency in the README; rewrites the Dockerfile comment to note the absent lockfile and recommend --frozen-lockfile for production.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • Connectum-Framework/examples#26: Introduced the Drizzle+Postgres storage layer for reserveVehicle/releaseVehicle in fleetService.ts that this PR extends with the holder field and idempotent retry logic.

Poem

🐇 A holder field hops into the schema tonight,
No double-bookings shall darken my sight!
Compensations now stack in the right LIFO order,
Retries re-reserve right up to the border.
Transient errors at last get their due —
Code.Unavailable where confusion once grew! ✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main changes: hardening the trip saga through idempotency, compensation order, and error mapping refinements.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch chore/car-sharing-harden-saga

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 Buf (1.71.0)
car-sharing/proto/fleet/v1/fleet.proto

Failure: no .proto files were targeted. This can occur if no .proto files are found in your input, --path points to files that do not exist, or --exclude-path excludes all files.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added the type:chore Maintenance: refactoring, dependencies, CI/CD label Jun 23, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@car-sharing/src/services/fleetService.ts`:
- Around line 141-142: The WHERE clause in the vehicle release update on line
141-142 only checks the vehicle ID, creating a double-booking vulnerability if
retries occur after response loss. Add a holder field to ReleaseVehicleRequest
and thread it through the activity and workflow to the releaseVehicle method.
Modify the WHERE clause to include an additional condition that verifies the
current holder matches the expected holder using eq(vehicles.holder,
expectedHolder), ensuring the update only clears the lock if held by the same
reservation that requested the release. Treat the case where holder is already
null as idempotent success to prevent errors on legitimate retries.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 586cb44b-0db3-41e3-85ee-989e3a989485

📥 Commits

Reviewing files that changed from the base of the PR and between 7619fb2 and ad0d096.

📒 Files selected for processing (14)
  • car-sharing/Dockerfile
  • car-sharing/README.md
  • car-sharing/drizzle/0001_sturdy_wendell_vaughn.sql
  • car-sharing/drizzle/meta/0001_snapshot.json
  • car-sharing/drizzle/meta/_journal.json
  • car-sharing/proto/fleet/v1/fleet.proto
  • car-sharing/src/db/schema.ts
  • car-sharing/src/services/fleetService.ts
  • car-sharing/src/services/tripService.ts
  • car-sharing/src/temporal/activities.ts
  • car-sharing/src/temporal/workflows.ts
  • car-sharing/tests/activity/activities.test.ts
  • car-sharing/tests/e2e/e2e.test.ts
  • car-sharing/tests/workflow/tripWorkflow.test.ts

Comment on lines +141 to 142
.set({ available: true, status: VehicleStatus.AVAILABLE, holder: null, updatedAt: new Date() })
.where(eq(vehicles.id, req.id))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Guard release by the reservation holder.

Line 141 clears the lock using only id in the WHERE clause. If a releaseVehicle compensation commits but its activity response is lost, a retry can run after another trip reserves the now-available vehicle and then clear that other trip’s holder, reopening double-booking. Thread the expected holder through ReleaseVehicleRequest/activity/workflow and only clear when the stored holder matches, while treating an already-available holder = null row as idempotent success.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@car-sharing/src/services/fleetService.ts` around lines 141 - 142, The WHERE
clause in the vehicle release update on line 141-142 only checks the vehicle ID,
creating a double-booking vulnerability if retries occur after response loss.
Add a holder field to ReleaseVehicleRequest and thread it through the activity
and workflow to the releaseVehicle method. Modify the WHERE clause to include an
additional condition that verifies the current holder matches the expected
holder using eq(vehicles.holder, expectedHolder), ensuring the update only
clears the lock if held by the same reservation that requested the release.
Treat the case where holder is already null as idempotent success to prevent
errors on legitimate retries.

@intech intech merged commit 7009b66 into main Jun 23, 2026
7 checks passed
@intech intech deleted the chore/car-sharing-harden-saga branch June 23, 2026 14:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type:chore Maintenance: refactoring, dependencies, CI/CD

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant