feat(durability): productionize DBOS — entrypoint wiring, resume proof, validated procedure (#154) - #474

Merged
AndresL230 merged 2 commits into
mainfrom
feat/b7-154-dbos-durability
Jul 30, 2026
Merged

feat(durability): productionize DBOS — entrypoint wiring, resume proof, validated procedure (#154)#474
AndresL230 merged 2 commits into
mainfrom
feat/b7-154-dbos-durability

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Closes#154. Last item in the agent-migration epic #152.

ADR 0011 shipped the durable shim off + unvalidated: main.py never constructed or launched DBOS (the shim's import-time activation "trusted that DBOS() was called in main.py" — no such call existed), the DBOS_DATABASE_URL precondition was docstring-only, requirements-durable.txt was promised but never created, and neither mode had any test coverage.

What this does

  • services/durable.py: activation now requires DBOS_ENABLED=true + importable dbos + non-empty DBOS_DATABASE_URL (code matches its docstring). New init_dbos()/shutdown_dbos() called from main.py's _lifespan: construct the singleton (system_database_url config — the deprecated database_url key provisions a separate app DB used only by @DBOS.transaction, which we don't use) and DBOS.launch(). Fail-loud contract: any construct/launch failure under an explicit opt-in raises at startup (No startup env validation + silent HMAC-less SESSION_SECRET fallback #174 posture) — a silent passthrough would look durable while resuming nothing. Decorate-before-construct-before-launch verified against dbos==2.28.0 internals (registrations land on a lazily-created global registry the later DBOS.__init__ picks up).
  • requirements-durable.txt: the opt-in extra (dbos>=2.28,<3), deliberately outside requirements.txt/lock.
  • tests/test_durable_shim.py (11 hermetic tests, no dbos needed): passthrough parity + every precondition combination via env-patched reload of a fake dbos module, with pristine-state restore so the already-imported agents.document decorations are untouched.
  • tests/test_dbos_resume.py (opt-in, RUN_DBOS_RESUME=1): subprocess crash/resume proof — step 1 checkpoints, step 2 os._exit(42)s the process, relaunch auto-recovers the PENDING workflow, and step 1's side-effect counter shows exactly one invocation (resume at last completed step). Plus process_document parity under real DBOS in function mode. Exercised twice against dbos 2.28 + a real throwaway Postgres (implementer + an independent re-run): 2 passed in 3.56s.
  • Streaming crash-safety ([P2] Document pipeline robustness: post-result fallback double-fire, empty-OCR persistence, blocking IO, wrong size message #132 scenario): the streaming replay test now also pins zero re-inserts on an X-Request-ID retry after a crash-past-result (agents already pinned as not re-run; the fix(backend): close the #136 contract-audit tail + #340 + #72 #464 guard keeps exactly-one-result).
  • ADR 0011 → accepted (shipped + validated, default off): corrected activation procedure (DBOS.launch() runs system-DB migrations itself and auto-recovers PENDING workflows — the old dbos migrate step was stale), resume monitoring (startup INFO line captured by Logfire per [P2] Observability: activate Logfire for ops/error/LLM tracing #119 + dbos.workflow_status SQL), and the intentional streaming-route asymmetry reaffirmed. DBOS's system schema is DBOS-managed, deliberately outside db/migrations/.

Gates

  • Backend suite: 1480 passed, 31 skipped (was 1469/29; +11 shim tests, +2 opt-in skips), ruff clean.
  • Lock-universe check (pydantic-ai 1.107 scratch venv): all [P2] Agent platform: productionize DBOS durability + crash-safe streaming upload #154-touched areas green; the only failures are pre-existing scratch-venv dep gaps (docling/GOT-OCR/googleapiclient) that reproduce identically on unmodified main.
  • Default posture unchanged: flag off → byte-identical passthrough behavior, e2e lanes unaffected.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added opt-in durable execution for synchronous document uploads, enabling workflow recovery after crashes.
    • Added optional installation support for the durable execution feature.
    • Preserved existing behavior when durable execution is disabled or unavailable.
  • Bug Fixes

    • Improved upload replay handling to prevent duplicate processing, database records, and agent executions.
  • Documentation

    • Clarified durable and non-durable upload paths, recovery behavior, setup, and monitoring.

…f, validated procedure (#154)
ADR 0011 shipped the durable shim off + unvalidated: main.py never
constructed/launched DBOS (the shim 'trusted' an init that didn't exist),
the DBOS_DATABASE_URL precondition was docstring-only, and nothing tested
either mode.
- services/durable.py: enforce the DATABASE_URL precondition at activation;
new init_dbos()/shutdown_dbos() — construct + DBOS.launch() from the
lifespan, fail-loud when the operator explicitly opted in (#174 posture).
Decorate-before-construct-before-launch order verified against
dbos==2.28.0's registry internals.
- main.py: init_dbos() after validate_config(), shutdown_dbos() on teardown.
- requirements-durable.txt: the opt-in extra ADR 0011 promised (dbos>=2.28,<3),
never in requirements.txt/lock.
- tests/test_durable_shim.py: 11 hermetic tests over both modes and every
precondition combination (fake dbos module + reload, pristine restore).
- tests/test_dbos_resume.py: opt-in (RUN_DBOS_RESUME=1) subprocess crash/
resume proof — step1 runs EXACTLY once across an os._exit crash and the
workflow completes on relaunch recovery — plus process_document parity
under real DBOS. Exercised against dbos 2.28 + a real Postgres.
- test_documents_routes.py: streaming replay now also pins zero re-inserts
(the #132 crash-after-result scenario, with the #464 exactly-one-result
guard).
- ADR 0011 → accepted (shipped + validated, default off): corrected
activation procedure (launch() migrates + auto-recovers; 'dbos migrate'
was stale), resume monitoring (startup INFO line -> Logfire per #119,
workflow_status SQL), streaming-asymmetry reaffirmed.
Closes#154
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@supabase

supabaseBot commented Jul 30, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 30, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-stagingcd637c4Commit Preview URL

Branch Preview URL
Jul 30 2026, 04:09 PM

@coderabbitai

coderabbitaiBot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:21 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 3deaa77e-df7d-40e1-85f0-ac20afed8916

📥 Commits

Reviewing files that changed from the base of the PR and between e1f38bb and cd637c4.

📒 Files selected for processing (6)
  • backend/agents/document.py
  • backend/routes/documents.py
  • backend/services/durable.py
  • backend/tests/test_dbos_resume.py
  • backend/tests/test_durable_shim.py
  • docs/decisions/0011-durable-execution-dbos.md
📝 Walkthrough

Walkthrough

DBOS lifecycle handling is added to the FastAPI lifespan, with configuration-gated decorators and opt-in dependency installation. Hermetic tests cover shim modes and failures; subprocess tests cover crash recovery and output parity. Streaming replay tests verify no duplicate document writes, and architecture documentation is updated.

Changes

Durable execution

Layer / File(s)Summary
Durable shim contracts and lifecycle
backend/services/durable.py
DBOS activation now requires the enablement flag and a database URL, with passthrough fallback, explicit initialization, and non-raising shutdown.
FastAPI lifespan integration
backend/main.py, backend/requirements-durable.txt
Application startup launches DBOS before serving requests, shutdown destroys it after the events worker stops, and an opt-in DBOS dependency file is added.
Shim mode and failure validation
backend/tests/test_durable_shim.py
Hermetic tests cover passthrough decorators, degraded configuration, delegated decorators, lifecycle calls, and launch failures.
Resume and replay validation
backend/tests/test_dbos_resume.py, backend/tests/test_documents_routes.py
Opt-in subprocess tests cover checkpoint resume and DBOS output parity; streaming replay tests assert no duplicate document insert.
Architecture and ADR updates
docs/architecture.md, docs/decisions/0011-durable-execution-dbos.md
Documentation records DBOS activation, resume monitoring, streaming-route behavior, replay guarantees, and deferred durability areas.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

  • SaplingLearn/Sapling#67: Introduced the DBOS durable-execution scaffolding extended here with lifecycle initialization and shutdown wiring.

Sequence Diagram(s)

sequenceDiagram
participant FastAPI
participant services.durable
participant DBOS
participant Postgres
FastAPI->>services.durable: init_dbos()
services.durable->>DBOS: construct with system_database_url
DBOS->>Postgres: launch and access DBOS state
FastAPI-->>FastAPI: serve requests
FastAPI->>services.durable: shutdown_dbos()
services.durable->>DBOS: destroy()
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Linked Issues check⚠️ WarningMost #154 goals are covered, but the summary shows monitoring documentation only, not the required observability wiring.Add the DBOS resume monitoring integration to observability, or split that requirement into a separate follow-up PR.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe title clearly summarizes the DBOS durability productionization work and the resume/activation updates.
Out of Scope Changes check✅ PassedThe changes stay focused on DBOS durability, crash-resume tests, and documentation updates with no clear unrelated scope.
Docstring Coverage✅ PassedNo functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Fix failing CI checks
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/b7-154-dbos-durability

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.

@coderabbitaicoderabbitaiBot 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: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
backend/main.py (1)

85-97: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

init_dbos() failure leaks the events-service worker thread.

init_dbos() is documented to raise when DBOS_ENABLED=true and construction/launch fails. It's called at line 92, after events_service.start_worker() (line 88) but before yield (line 93). If it raises, the generator never reaches yield, so events_service.shutdown() (line 96) and shutdown_dbos() (line 97) never run — the drain thread and any queued rows are abandoned on the failed-startup path, contradicting the "last batch... isn't lost" intent of the shutdown comment.

Reorder so init_dbos() runs before starting the events worker, or wrap the sequence to flush/stop events_service on failure before re-raising.

🔧 Suggested reorder
- from services import events_service- events_service.start_worker()- # ADR 0011 / `#154`: construct + launch DBOS when DBOS_ENABLED=true; no-op- # passthrough otherwise. Fails loudly (raises) if the operator opted in- # and launch fails — see services/durable.py::init_dbos.- init_dbos()+ # ADR 0011 / `#154`: construct + launch DBOS when DBOS_ENABLED=true; no-op+ # passthrough otherwise. Fails loudly (raises) if the operator opted in+ # and launch fails — see services/durable.py::init_dbos. Run before+ # starting the events worker so a failed launch doesn't leak that thread.+ init_dbos()+ from services import events_service+ events_service.start_worker()
yield
🤖 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 `@backend/main.py` around lines 85 - 97, Reorder the startup sequence in the
application lifespan flow so init_dbos() completes before
events_service.start_worker() is called. Preserve the existing yield and
shutdown ordering, ensuring a DBOS initialization failure occurs before the
events worker starts and cannot leak that thread.
🤖 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 `@docs/decisions/0011-durable-execution-dbos.md`:
- Around line 44-50: Update docs/decisions/0011-durable-execution-dbos.md lines
44-50 to describe test_pipeline_identical_with_dbos_on as asserting parity for
category, abstract, and concept names, rather than byte-for-byte output
equality. Update lines 117-122 to state that classifier_agent.run is patched
with an AsyncMock and asserted not called, replacing “unmocked-but-spied”
wording.
- Around line 26-29: Update the `backend/requirements-durable.txt` description
so `dbos>=2.28,<3` is identified as a tested compatible range rather than
pinned; alternatively, change the requirement to an exact `dbos==2.28.0` pin and
retain the pinned wording.
---
Outside diff comments:
In `@backend/main.py`:
- Around line 85-97: Reorder the startup sequence in the application lifespan
flow so init_dbos() completes before events_service.start_worker() is called.
Preserve the existing yield and shutdown ordering, ensuring a DBOS
initialization failure occurs before the events worker starts and cannot leak
that thread.
🪄 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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 22eba8b8-500e-4aef-81fa-affa1d8cc3ac

📥 Commits

Reviewing files that changed from the base of the PR and between 83f5195 and e1f38bb.

📒 Files selected for processing (8)
  • backend/main.py
  • backend/requirements-durable.txt
  • backend/services/durable.py
  • backend/tests/test_dbos_resume.py
  • backend/tests/test_documents_routes.py
  • backend/tests/test_durable_shim.py
  • docs/architecture.md
  • docs/decisions/0011-durable-execution-dbos.md

Comment on lines +26 to +29
3. **`backend/requirements-durable.txt` now exists** (`dbos>=2.28,<3`,
pinned to the version this update's verification ran against). Never
added to `requirements.txt`/`requirements.lock` — durability stays
opt-in and the hermetic suite runs with `dbos` NOT installed.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Do not describe a compatible-version range as pinned.

dbos>=2.28,<3 permits later 2.x releases; it is not pinned to 2.28.0. Either use dbos==2.28.0 or describe this as a tested compatible range.

🤖 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 `@docs/decisions/0011-durable-execution-dbos.md` around lines 26 - 29, Update
the `backend/requirements-durable.txt` description so `dbos>=2.28,<3` is
identified as a tested compatible range rather than pinned; alternatively,
change the requirement to an exact `dbos==2.28.0` pin and retain the pinned
wording.

Comment on lines +44 to +50
`test_pipeline_identical_with_dbos_on` runs
`agents.document.process_document` for real under DBOS
(`SAPLING_MODEL_MODE=function`) and asserts its output matches the
function-mode constants byte-for-byte — proof that wrapping the
pipeline in `@durable_workflow`/`@durable_step` changes durability,
not behavior. Both tests were run against a real `dbos==2.28.0` +
a throwaway Postgres during this update and passed; see the file

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Align the ADR’s test claims with the actual assertions.

  • docs/decisions/0011-durable-execution-dbos.md#L44-L50: describe this as parity for category, abstract, and concept names, or expand the test to compare the complete result; it does not currently prove byte-for-byte output equality.
  • docs/decisions/0011-durable-execution-dbos.md#L117-L122: replace “unmocked-but-spied” with wording that classifier_agent.run is patched with an AsyncMock and asserted not called.
📍 Affects 1 file
  • docs/decisions/0011-durable-execution-dbos.md#L44-L50 (this comment)
  • docs/decisions/0011-durable-execution-dbos.md#L117-L122
🤖 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 `@docs/decisions/0011-durable-execution-dbos.md` around lines 44 - 50, Update
docs/decisions/0011-durable-execution-dbos.md lines 44-50 to describe
test_pipeline_identical_with_dbos_on as asserting parity for category, abstract,
and concept names, rather than byte-for-byte output equality. Update lines
117-122 to state that classifier_agent.run is patched with an AsyncMock and
asserted not called, replacing “unmocked-but-spied” wording.

…doc claims
- /upload/sync now pins the DBOS workflow id to doc:{user_id}:{request_id}
(user-scoped — X-Request-ID is client-supplied), so a client retry
attaches to the SAME workflow: completed -> recorded result, crashed ->
resume at last completed step. Graph merge wrapped as _step_apply_graph
so resume never re-runs the one real side effect.
- init_dbos now RAISES on flag-on-but-preconditions-unmet (missing URL /
failed import) instead of silently degrading an explicit opt-in; shim
tests pin both raise paths.
- Doc corrections: pre-#154 flag-on raised DBOSException per call (502s),
not 'silently nothing'; step-outside-workflow runs the plain function on
dbos 2.28 (not undefined); only database_url is deprecated.
- New opt-in proof test_pipeline_crash_resume_via_workflow_id: real
process_document crash mid-_run_workers -> same-id retry -> classify
step runs EXACTLY once across both phases. All 3 resume tests exercised
green against dbos 2.28 + a real Postgres.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

No issue scored above the posting threshold (five review passes: CLAUDE.md compliance, shallow bug scan, git history + code-comment compliance, prior-PR comments; scored 25–75). All six sub-threshold findings were fixed anyway in cd637c4:

  1. Durability was decorative for the product: production code never assigned a DBOS workflow id, so a client retry started a fresh workflow (re-running every agent step) and background recovery completed workflows nobody consumed — while the graph merge sat unwrapped in the workflow body, re-executed on every resume. Fixed: /upload/sync pins doc:{user_id}:{request_id} via the new services.durable.workflow_id helper (user-scoped — X-Request-ID is client-supplied, an unscoped id would allow cross-user workflow poisoning), and the merge is now a checkpointed _step_apply_graph. A new opt-in test proves the real pipeline resumes at the last completed step across a crash (classify runs exactly once over both phases); all three resume tests exercised green against dbos 2.28 + a real Postgres.

https://github.com/SaplingLearn/Sapling/blob/e1f38bb2c96de78372adba17e59b45048e60f22c/backend/agents/document.py#L150-L162

  1. init_dbos() silently degraded an explicit DBOS_ENABLED=true opt-in to passthrough when DBOS_DATABASE_URL was missing or the import failed, contradicting its own "fail-loud" contract — it now raises RuntimeError naming the failed precondition, with both paths pinned in tests.

3–4. Doc-accuracy: the ADR/docstring claimed the pre-#154 flag-on state "silently did nothing" (it actually raised DBOSException per call → deterministic 502s, per dbos/_core.py's registry guard), and agents/document.py called step-outside-workflow "undefined behavior" (dbos 2.28 deterministically runs the plain function). Both corrected, plus a deprecated-key wording fix.

The remaining pass observation — no pre-merge e2e-lane run reported yet — is the next pipeline stage; the full local cycle (Playwright + oracles under the stack lock) runs before merge and results will be posted here.

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Pre-merge local e2e cycle at cd637c4 under the stack lock (SAPLING_MODEL_MODE=function): make e2e-up clean, Playwright 29/29 passed (1.1m), e2e_oracles exit 0, clean teardown (RESULT up=0 pw=0 oracles=0 down=0). Flag-off no-op confirmed empirically: the lifespan's init_dbos() logged the passthrough line and every journey behaved identically.

@AndresL230
AndresL230 merged commit 9edfcf5 into mainJul 30, 2026
6 of 8 checks passed
@AndresL230
AndresL230 deleted the feat/b7-154-dbos-durability branch July 30, 2026 16:10
Sign up for freeto 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.

[P2] Agent platform: productionize DBOS durability + crash-safe streaming upload

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

feat(durability): productionize DBOS — entrypoint wiring, resume proof, validated procedure (#154) - #474

Merged
AndresL230 merged 2 commits into
mainfrom
feat/b7-154-dbos-durability
Jul 30, 2026
Merged

feat(durability): productionize DBOS — entrypoint wiring, resume proof, validated procedure (#154)#474
AndresL230 merged 2 commits into
mainfrom
feat/b7-154-dbos-durability

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Closes#154. Last item in the agent-migration epic #152.

ADR 0011 shipped the durable shim off + unvalidated: main.py never constructed or launched DBOS (the shim's import-time activation "trusted that DBOS() was called in main.py" — no such call existed), the DBOS_DATABASE_URL precondition was docstring-only, requirements-durable.txt was promised but never created, and neither mode had any test coverage.

What this does

  • services/durable.py: activation now requires DBOS_ENABLED=true + importable dbos + non-empty DBOS_DATABASE_URL (code matches its docstring). New init_dbos()/shutdown_dbos() called from main.py's _lifespan: construct the singleton (system_database_url config — the deprecated database_url key provisions a separate app DB used only by @DBOS.transaction, which we don't use) and DBOS.launch(). Fail-loud contract: any construct/launch failure under an explicit opt-in raises at startup (No startup env validation + silent HMAC-less SESSION_SECRET fallback #174 posture) — a silent passthrough would look durable while resuming nothing. Decorate-before-construct-before-launch verified against dbos==2.28.0 internals (registrations land on a lazily-created global registry the later DBOS.__init__ picks up).
  • requirements-durable.txt: the opt-in extra (dbos>=2.28,<3), deliberately outside requirements.txt/lock.
  • tests/test_durable_shim.py (11 hermetic tests, no dbos needed): passthrough parity + every precondition combination via env-patched reload of a fake dbos module, with pristine-state restore so the already-imported agents.document decorations are untouched.
  • tests/test_dbos_resume.py (opt-in, RUN_DBOS_RESUME=1): subprocess crash/resume proof — step 1 checkpoints, step 2 os._exit(42)s the process, relaunch auto-recovers the PENDING workflow, and step 1's side-effect counter shows exactly one invocation (resume at last completed step). Plus process_document parity under real DBOS in function mode. Exercised twice against dbos 2.28 + a real throwaway Postgres (implementer + an independent re-run): 2 passed in 3.56s.
  • Streaming crash-safety ([P2] Document pipeline robustness: post-result fallback double-fire, empty-OCR persistence, blocking IO, wrong size message #132 scenario): the streaming replay test now also pins zero re-inserts on an X-Request-ID retry after a crash-past-result (agents already pinned as not re-run; the fix(backend): close the #136 contract-audit tail + #340 + #72 #464 guard keeps exactly-one-result).
  • ADR 0011 → accepted (shipped + validated, default off): corrected activation procedure (DBOS.launch() runs system-DB migrations itself and auto-recovers PENDING workflows — the old dbos migrate step was stale), resume monitoring (startup INFO line captured by Logfire per [P2] Observability: activate Logfire for ops/error/LLM tracing #119 + dbos.workflow_status SQL), and the intentional streaming-route asymmetry reaffirmed. DBOS's system schema is DBOS-managed, deliberately outside db/migrations/.

Gates

  • Backend suite: 1480 passed, 31 skipped (was 1469/29; +11 shim tests, +2 opt-in skips), ruff clean.
  • Lock-universe check (pydantic-ai 1.107 scratch venv): all [P2] Agent platform: productionize DBOS durability + crash-safe streaming upload #154-touched areas green; the only failures are pre-existing scratch-venv dep gaps (docling/GOT-OCR/googleapiclient) that reproduce identically on unmodified main.
  • Default posture unchanged: flag off → byte-identical passthrough behavior, e2e lanes unaffected.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added opt-in durable execution for synchronous document uploads, enabling workflow recovery after crashes.
    • Added optional installation support for the durable execution feature.
    • Preserved existing behavior when durable execution is disabled or unavailable.
  • Bug Fixes

    • Improved upload replay handling to prevent duplicate processing, database records, and agent executions.
  • Documentation

    • Clarified durable and non-durable upload paths, recovery behavior, setup, and monitoring.

…f, validated procedure (#154)
ADR 0011 shipped the durable shim off + unvalidated: main.py never
constructed/launched DBOS (the shim 'trusted' an init that didn't exist),
the DBOS_DATABASE_URL precondition was docstring-only, and nothing tested
either mode.
- services/durable.py: enforce the DATABASE_URL precondition at activation;
new init_dbos()/shutdown_dbos() — construct + DBOS.launch() from the
lifespan, fail-loud when the operator explicitly opted in (#174 posture).
Decorate-before-construct-before-launch order verified against
dbos==2.28.0's registry internals.
- main.py: init_dbos() after validate_config(), shutdown_dbos() on teardown.
- requirements-durable.txt: the opt-in extra ADR 0011 promised (dbos>=2.28,<3),
never in requirements.txt/lock.
- tests/test_durable_shim.py: 11 hermetic tests over both modes and every
precondition combination (fake dbos module + reload, pristine restore).
- tests/test_dbos_resume.py: opt-in (RUN_DBOS_RESUME=1) subprocess crash/
resume proof — step1 runs EXACTLY once across an os._exit crash and the
workflow completes on relaunch recovery — plus process_document parity
under real DBOS. Exercised against dbos 2.28 + a real Postgres.
- test_documents_routes.py: streaming replay now also pins zero re-inserts
(the #132 crash-after-result scenario, with the #464 exactly-one-result
guard).
- ADR 0011 → accepted (shipped + validated, default off): corrected
activation procedure (launch() migrates + auto-recovers; 'dbos migrate'
was stale), resume monitoring (startup INFO line -> Logfire per #119,
workflow_status SQL), streaming-asymmetry reaffirmed.
Closes#154
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@supabase

supabaseBot commented Jul 30, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 30, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-stagingcd637c4Commit Preview URL

Branch Preview URL
Jul 30 2026, 04:09 PM

@coderabbitai

coderabbitaiBot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:21 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 3deaa77e-df7d-40e1-85f0-ac20afed8916

📥 Commits

Reviewing files that changed from the base of the PR and between e1f38bb and cd637c4.

📒 Files selected for processing (6)
  • backend/agents/document.py
  • backend/routes/documents.py
  • backend/services/durable.py
  • backend/tests/test_dbos_resume.py
  • backend/tests/test_durable_shim.py
  • docs/decisions/0011-durable-execution-dbos.md
📝 Walkthrough

Walkthrough

DBOS lifecycle handling is added to the FastAPI lifespan, with configuration-gated decorators and opt-in dependency installation. Hermetic tests cover shim modes and failures; subprocess tests cover crash recovery and output parity. Streaming replay tests verify no duplicate document writes, and architecture documentation is updated.

Changes

Durable execution

Layer / File(s)Summary
Durable shim contracts and lifecycle
backend/services/durable.py
DBOS activation now requires the enablement flag and a database URL, with passthrough fallback, explicit initialization, and non-raising shutdown.
FastAPI lifespan integration
backend/main.py, backend/requirements-durable.txt
Application startup launches DBOS before serving requests, shutdown destroys it after the events worker stops, and an opt-in DBOS dependency file is added.
Shim mode and failure validation
backend/tests/test_durable_shim.py
Hermetic tests cover passthrough decorators, degraded configuration, delegated decorators, lifecycle calls, and launch failures.
Resume and replay validation
backend/tests/test_dbos_resume.py, backend/tests/test_documents_routes.py
Opt-in subprocess tests cover checkpoint resume and DBOS output parity; streaming replay tests assert no duplicate document insert.
Architecture and ADR updates
docs/architecture.md, docs/decisions/0011-durable-execution-dbos.md
Documentation records DBOS activation, resume monitoring, streaming-route behavior, replay guarantees, and deferred durability areas.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

  • SaplingLearn/Sapling#67: Introduced the DBOS durable-execution scaffolding extended here with lifecycle initialization and shutdown wiring.

Sequence Diagram(s)

sequenceDiagram
participant FastAPI
participant services.durable
participant DBOS
participant Postgres
FastAPI->>services.durable: init_dbos()
services.durable->>DBOS: construct with system_database_url
DBOS->>Postgres: launch and access DBOS state
FastAPI-->>FastAPI: serve requests
FastAPI->>services.durable: shutdown_dbos()
services.durable->>DBOS: destroy()
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Linked Issues check⚠️ WarningMost #154 goals are covered, but the summary shows monitoring documentation only, not the required observability wiring.Add the DBOS resume monitoring integration to observability, or split that requirement into a separate follow-up PR.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe title clearly summarizes the DBOS durability productionization work and the resume/activation updates.
Out of Scope Changes check✅ PassedThe changes stay focused on DBOS durability, crash-resume tests, and documentation updates with no clear unrelated scope.
Docstring Coverage✅ PassedNo functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Fix failing CI checks
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/b7-154-dbos-durability

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.

@coderabbitaicoderabbitaiBot 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: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
backend/main.py (1)

85-97: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

init_dbos() failure leaks the events-service worker thread.

init_dbos() is documented to raise when DBOS_ENABLED=true and construction/launch fails. It's called at line 92, after events_service.start_worker() (line 88) but before yield (line 93). If it raises, the generator never reaches yield, so events_service.shutdown() (line 96) and shutdown_dbos() (line 97) never run — the drain thread and any queued rows are abandoned on the failed-startup path, contradicting the "last batch... isn't lost" intent of the shutdown comment.

Reorder so init_dbos() runs before starting the events worker, or wrap the sequence to flush/stop events_service on failure before re-raising.

🔧 Suggested reorder
- from services import events_service- events_service.start_worker()- # ADR 0011 / `#154`: construct + launch DBOS when DBOS_ENABLED=true; no-op- # passthrough otherwise. Fails loudly (raises) if the operator opted in- # and launch fails — see services/durable.py::init_dbos.- init_dbos()+ # ADR 0011 / `#154`: construct + launch DBOS when DBOS_ENABLED=true; no-op+ # passthrough otherwise. Fails loudly (raises) if the operator opted in+ # and launch fails — see services/durable.py::init_dbos. Run before+ # starting the events worker so a failed launch doesn't leak that thread.+ init_dbos()+ from services import events_service+ events_service.start_worker()
yield
🤖 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 `@backend/main.py` around lines 85 - 97, Reorder the startup sequence in the
application lifespan flow so init_dbos() completes before
events_service.start_worker() is called. Preserve the existing yield and
shutdown ordering, ensuring a DBOS initialization failure occurs before the
events worker starts and cannot leak that thread.
🤖 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 `@docs/decisions/0011-durable-execution-dbos.md`:
- Around line 44-50: Update docs/decisions/0011-durable-execution-dbos.md lines
44-50 to describe test_pipeline_identical_with_dbos_on as asserting parity for
category, abstract, and concept names, rather than byte-for-byte output
equality. Update lines 117-122 to state that classifier_agent.run is patched
with an AsyncMock and asserted not called, replacing “unmocked-but-spied”
wording.
- Around line 26-29: Update the `backend/requirements-durable.txt` description
so `dbos>=2.28,<3` is identified as a tested compatible range rather than
pinned; alternatively, change the requirement to an exact `dbos==2.28.0` pin and
retain the pinned wording.
---
Outside diff comments:
In `@backend/main.py`:
- Around line 85-97: Reorder the startup sequence in the application lifespan
flow so init_dbos() completes before events_service.start_worker() is called.
Preserve the existing yield and shutdown ordering, ensuring a DBOS
initialization failure occurs before the events worker starts and cannot leak
that thread.
🪄 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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 22eba8b8-500e-4aef-81fa-affa1d8cc3ac

📥 Commits

Reviewing files that changed from the base of the PR and between 83f5195 and e1f38bb.

📒 Files selected for processing (8)
  • backend/main.py
  • backend/requirements-durable.txt
  • backend/services/durable.py
  • backend/tests/test_dbos_resume.py
  • backend/tests/test_documents_routes.py
  • backend/tests/test_durable_shim.py
  • docs/architecture.md
  • docs/decisions/0011-durable-execution-dbos.md

Comment on lines +26 to +29
3. **`backend/requirements-durable.txt` now exists** (`dbos>=2.28,<3`,
pinned to the version this update's verification ran against). Never
added to `requirements.txt`/`requirements.lock` — durability stays
opt-in and the hermetic suite runs with `dbos` NOT installed.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Do not describe a compatible-version range as pinned.

dbos>=2.28,<3 permits later 2.x releases; it is not pinned to 2.28.0. Either use dbos==2.28.0 or describe this as a tested compatible range.

🤖 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 `@docs/decisions/0011-durable-execution-dbos.md` around lines 26 - 29, Update
the `backend/requirements-durable.txt` description so `dbos>=2.28,<3` is
identified as a tested compatible range rather than pinned; alternatively,
change the requirement to an exact `dbos==2.28.0` pin and retain the pinned
wording.

Comment on lines +44 to +50
`test_pipeline_identical_with_dbos_on` runs
`agents.document.process_document` for real under DBOS
(`SAPLING_MODEL_MODE=function`) and asserts its output matches the
function-mode constants byte-for-byte — proof that wrapping the
pipeline in `@durable_workflow`/`@durable_step` changes durability,
not behavior. Both tests were run against a real `dbos==2.28.0` +
a throwaway Postgres during this update and passed; see the file

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Align the ADR’s test claims with the actual assertions.

  • docs/decisions/0011-durable-execution-dbos.md#L44-L50: describe this as parity for category, abstract, and concept names, or expand the test to compare the complete result; it does not currently prove byte-for-byte output equality.
  • docs/decisions/0011-durable-execution-dbos.md#L117-L122: replace “unmocked-but-spied” with wording that classifier_agent.run is patched with an AsyncMock and asserted not called.
📍 Affects 1 file
  • docs/decisions/0011-durable-execution-dbos.md#L44-L50 (this comment)
  • docs/decisions/0011-durable-execution-dbos.md#L117-L122
🤖 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 `@docs/decisions/0011-durable-execution-dbos.md` around lines 44 - 50, Update
docs/decisions/0011-durable-execution-dbos.md lines 44-50 to describe
test_pipeline_identical_with_dbos_on as asserting parity for category, abstract,
and concept names, rather than byte-for-byte output equality. Update lines
117-122 to state that classifier_agent.run is patched with an AsyncMock and
asserted not called, replacing “unmocked-but-spied” wording.

…doc claims
- /upload/sync now pins the DBOS workflow id to doc:{user_id}:{request_id}
(user-scoped — X-Request-ID is client-supplied), so a client retry
attaches to the SAME workflow: completed -> recorded result, crashed ->
resume at last completed step. Graph merge wrapped as _step_apply_graph
so resume never re-runs the one real side effect.
- init_dbos now RAISES on flag-on-but-preconditions-unmet (missing URL /
failed import) instead of silently degrading an explicit opt-in; shim
tests pin both raise paths.
- Doc corrections: pre-#154 flag-on raised DBOSException per call (502s),
not 'silently nothing'; step-outside-workflow runs the plain function on
dbos 2.28 (not undefined); only database_url is deprecated.
- New opt-in proof test_pipeline_crash_resume_via_workflow_id: real
process_document crash mid-_run_workers -> same-id retry -> classify
step runs EXACTLY once across both phases. All 3 resume tests exercised
green against dbos 2.28 + a real Postgres.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

No issue scored above the posting threshold (five review passes: CLAUDE.md compliance, shallow bug scan, git history + code-comment compliance, prior-PR comments; scored 25–75). All six sub-threshold findings were fixed anyway in cd637c4:

  1. Durability was decorative for the product: production code never assigned a DBOS workflow id, so a client retry started a fresh workflow (re-running every agent step) and background recovery completed workflows nobody consumed — while the graph merge sat unwrapped in the workflow body, re-executed on every resume. Fixed: /upload/sync pins doc:{user_id}:{request_id} via the new services.durable.workflow_id helper (user-scoped — X-Request-ID is client-supplied, an unscoped id would allow cross-user workflow poisoning), and the merge is now a checkpointed _step_apply_graph. A new opt-in test proves the real pipeline resumes at the last completed step across a crash (classify runs exactly once over both phases); all three resume tests exercised green against dbos 2.28 + a real Postgres.

https://github.com/SaplingLearn/Sapling/blob/e1f38bb2c96de78372adba17e59b45048e60f22c/backend/agents/document.py#L150-L162

  1. init_dbos() silently degraded an explicit DBOS_ENABLED=true opt-in to passthrough when DBOS_DATABASE_URL was missing or the import failed, contradicting its own "fail-loud" contract — it now raises RuntimeError naming the failed precondition, with both paths pinned in tests.

3–4. Doc-accuracy: the ADR/docstring claimed the pre-#154 flag-on state "silently did nothing" (it actually raised DBOSException per call → deterministic 502s, per dbos/_core.py's registry guard), and agents/document.py called step-outside-workflow "undefined behavior" (dbos 2.28 deterministically runs the plain function). Both corrected, plus a deprecated-key wording fix.

The remaining pass observation — no pre-merge e2e-lane run reported yet — is the next pipeline stage; the full local cycle (Playwright + oracles under the stack lock) runs before merge and results will be posted here.

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Pre-merge local e2e cycle at cd637c4 under the stack lock (SAPLING_MODEL_MODE=function): make e2e-up clean, Playwright 29/29 passed (1.1m), e2e_oracles exit 0, clean teardown (RESULT up=0 pw=0 oracles=0 down=0). Flag-off no-op confirmed empirically: the lifespan's init_dbos() logged the passthrough line and every journey behaved identically.

@AndresL230
AndresL230 merged commit 9edfcf5 into mainJul 30, 2026
6 of 8 checks passed
@AndresL230
AndresL230 deleted the feat/b7-154-dbos-durability branch July 30, 2026 16:10
Sign up for freeto 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.

[P2] Agent platform: productionize DBOS durability + crash-safe streaming upload

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(durability): productionize DBOS — entrypoint wiring, resume proof, validated procedure (#154) - #474

Merged
AndresL230 merged 2 commits into
mainfrom
feat/b7-154-dbos-durability
Jul 30, 2026
Merged

feat(durability): productionize DBOS — entrypoint wiring, resume proof, validated procedure (#154)#474
AndresL230 merged 2 commits into
mainfrom
feat/b7-154-dbos-durability

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Closes#154. Last item in the agent-migration epic #152.

ADR 0011 shipped the durable shim off + unvalidated: main.py never constructed or launched DBOS (the shim's import-time activation "trusted that DBOS() was called in main.py" — no such call existed), the DBOS_DATABASE_URL precondition was docstring-only, requirements-durable.txt was promised but never created, and neither mode had any test coverage.

What this does

  • services/durable.py: activation now requires DBOS_ENABLED=true + importable dbos + non-empty DBOS_DATABASE_URL (code matches its docstring). New init_dbos()/shutdown_dbos() called from main.py's _lifespan: construct the singleton (system_database_url config — the deprecated database_url key provisions a separate app DB used only by @DBOS.transaction, which we don't use) and DBOS.launch(). Fail-loud contract: any construct/launch failure under an explicit opt-in raises at startup (No startup env validation + silent HMAC-less SESSION_SECRET fallback #174 posture) — a silent passthrough would look durable while resuming nothing. Decorate-before-construct-before-launch verified against dbos==2.28.0 internals (registrations land on a lazily-created global registry the later DBOS.__init__ picks up).
  • requirements-durable.txt: the opt-in extra (dbos>=2.28,<3), deliberately outside requirements.txt/lock.
  • tests/test_durable_shim.py (11 hermetic tests, no dbos needed): passthrough parity + every precondition combination via env-patched reload of a fake dbos module, with pristine-state restore so the already-imported agents.document decorations are untouched.
  • tests/test_dbos_resume.py (opt-in, RUN_DBOS_RESUME=1): subprocess crash/resume proof — step 1 checkpoints, step 2 os._exit(42)s the process, relaunch auto-recovers the PENDING workflow, and step 1's side-effect counter shows exactly one invocation (resume at last completed step). Plus process_document parity under real DBOS in function mode. Exercised twice against dbos 2.28 + a real throwaway Postgres (implementer + an independent re-run): 2 passed in 3.56s.
  • Streaming crash-safety ([P2] Document pipeline robustness: post-result fallback double-fire, empty-OCR persistence, blocking IO, wrong size message #132 scenario): the streaming replay test now also pins zero re-inserts on an X-Request-ID retry after a crash-past-result (agents already pinned as not re-run; the fix(backend): close the #136 contract-audit tail + #340 + #72 #464 guard keeps exactly-one-result).
  • ADR 0011 → accepted (shipped + validated, default off): corrected activation procedure (DBOS.launch() runs system-DB migrations itself and auto-recovers PENDING workflows — the old dbos migrate step was stale), resume monitoring (startup INFO line captured by Logfire per [P2] Observability: activate Logfire for ops/error/LLM tracing #119 + dbos.workflow_status SQL), and the intentional streaming-route asymmetry reaffirmed. DBOS's system schema is DBOS-managed, deliberately outside db/migrations/.

Gates

  • Backend suite: 1480 passed, 31 skipped (was 1469/29; +11 shim tests, +2 opt-in skips), ruff clean.
  • Lock-universe check (pydantic-ai 1.107 scratch venv): all [P2] Agent platform: productionize DBOS durability + crash-safe streaming upload #154-touched areas green; the only failures are pre-existing scratch-venv dep gaps (docling/GOT-OCR/googleapiclient) that reproduce identically on unmodified main.
  • Default posture unchanged: flag off → byte-identical passthrough behavior, e2e lanes unaffected.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added opt-in durable execution for synchronous document uploads, enabling workflow recovery after crashes.
    • Added optional installation support for the durable execution feature.
    • Preserved existing behavior when durable execution is disabled or unavailable.
  • Bug Fixes

    • Improved upload replay handling to prevent duplicate processing, database records, and agent executions.
  • Documentation

    • Clarified durable and non-durable upload paths, recovery behavior, setup, and monitoring.

…f, validated procedure (#154)
ADR 0011 shipped the durable shim off + unvalidated: main.py never
constructed/launched DBOS (the shim 'trusted' an init that didn't exist),
the DBOS_DATABASE_URL precondition was docstring-only, and nothing tested
either mode.
- services/durable.py: enforce the DATABASE_URL precondition at activation;
new init_dbos()/shutdown_dbos() — construct + DBOS.launch() from the
lifespan, fail-loud when the operator explicitly opted in (#174 posture).
Decorate-before-construct-before-launch order verified against
dbos==2.28.0's registry internals.
- main.py: init_dbos() after validate_config(), shutdown_dbos() on teardown.
- requirements-durable.txt: the opt-in extra ADR 0011 promised (dbos>=2.28,<3),
never in requirements.txt/lock.
- tests/test_durable_shim.py: 11 hermetic tests over both modes and every
precondition combination (fake dbos module + reload, pristine restore).
- tests/test_dbos_resume.py: opt-in (RUN_DBOS_RESUME=1) subprocess crash/
resume proof — step1 runs EXACTLY once across an os._exit crash and the
workflow completes on relaunch recovery — plus process_document parity
under real DBOS. Exercised against dbos 2.28 + a real Postgres.
- test_documents_routes.py: streaming replay now also pins zero re-inserts
(the #132 crash-after-result scenario, with the #464 exactly-one-result
guard).
- ADR 0011 → accepted (shipped + validated, default off): corrected
activation procedure (launch() migrates + auto-recovers; 'dbos migrate'
was stale), resume monitoring (startup INFO line -> Logfire per #119,
workflow_status SQL), streaming-asymmetry reaffirmed.
Closes#154
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@supabase

supabaseBot commented Jul 30, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 30, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-stagingcd637c4Commit Preview URL

Branch Preview URL
Jul 30 2026, 04:09 PM

@coderabbitai

coderabbitaiBot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:21 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 3deaa77e-df7d-40e1-85f0-ac20afed8916

📥 Commits

Reviewing files that changed from the base of the PR and between e1f38bb and cd637c4.

📒 Files selected for processing (6)
  • backend/agents/document.py
  • backend/routes/documents.py
  • backend/services/durable.py
  • backend/tests/test_dbos_resume.py
  • backend/tests/test_durable_shim.py
  • docs/decisions/0011-durable-execution-dbos.md
📝 Walkthrough

Walkthrough

DBOS lifecycle handling is added to the FastAPI lifespan, with configuration-gated decorators and opt-in dependency installation. Hermetic tests cover shim modes and failures; subprocess tests cover crash recovery and output parity. Streaming replay tests verify no duplicate document writes, and architecture documentation is updated.

Changes

Durable execution

Layer / File(s)Summary
Durable shim contracts and lifecycle
backend/services/durable.py
DBOS activation now requires the enablement flag and a database URL, with passthrough fallback, explicit initialization, and non-raising shutdown.
FastAPI lifespan integration
backend/main.py, backend/requirements-durable.txt
Application startup launches DBOS before serving requests, shutdown destroys it after the events worker stops, and an opt-in DBOS dependency file is added.
Shim mode and failure validation
backend/tests/test_durable_shim.py
Hermetic tests cover passthrough decorators, degraded configuration, delegated decorators, lifecycle calls, and launch failures.
Resume and replay validation
backend/tests/test_dbos_resume.py, backend/tests/test_documents_routes.py
Opt-in subprocess tests cover checkpoint resume and DBOS output parity; streaming replay tests assert no duplicate document insert.
Architecture and ADR updates
docs/architecture.md, docs/decisions/0011-durable-execution-dbos.md
Documentation records DBOS activation, resume monitoring, streaming-route behavior, replay guarantees, and deferred durability areas.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

  • SaplingLearn/Sapling#67: Introduced the DBOS durable-execution scaffolding extended here with lifecycle initialization and shutdown wiring.

Sequence Diagram(s)

sequenceDiagram
participant FastAPI
participant services.durable
participant DBOS
participant Postgres
FastAPI->>services.durable: init_dbos()
services.durable->>DBOS: construct with system_database_url
DBOS->>Postgres: launch and access DBOS state
FastAPI-->>FastAPI: serve requests
FastAPI->>services.durable: shutdown_dbos()
services.durable->>DBOS: destroy()
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Linked Issues check⚠️ WarningMost #154 goals are covered, but the summary shows monitoring documentation only, not the required observability wiring.Add the DBOS resume monitoring integration to observability, or split that requirement into a separate follow-up PR.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe title clearly summarizes the DBOS durability productionization work and the resume/activation updates.
Out of Scope Changes check✅ PassedThe changes stay focused on DBOS durability, crash-resume tests, and documentation updates with no clear unrelated scope.
Docstring Coverage✅ PassedNo functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Fix failing CI checks
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/b7-154-dbos-durability

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.

@coderabbitaicoderabbitaiBot 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: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
backend/main.py (1)

85-97: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

init_dbos() failure leaks the events-service worker thread.

init_dbos() is documented to raise when DBOS_ENABLED=true and construction/launch fails. It's called at line 92, after events_service.start_worker() (line 88) but before yield (line 93). If it raises, the generator never reaches yield, so events_service.shutdown() (line 96) and shutdown_dbos() (line 97) never run — the drain thread and any queued rows are abandoned on the failed-startup path, contradicting the "last batch... isn't lost" intent of the shutdown comment.

Reorder so init_dbos() runs before starting the events worker, or wrap the sequence to flush/stop events_service on failure before re-raising.

🔧 Suggested reorder
- from services import events_service- events_service.start_worker()- # ADR 0011 / `#154`: construct + launch DBOS when DBOS_ENABLED=true; no-op- # passthrough otherwise. Fails loudly (raises) if the operator opted in- # and launch fails — see services/durable.py::init_dbos.- init_dbos()+ # ADR 0011 / `#154`: construct + launch DBOS when DBOS_ENABLED=true; no-op+ # passthrough otherwise. Fails loudly (raises) if the operator opted in+ # and launch fails — see services/durable.py::init_dbos. Run before+ # starting the events worker so a failed launch doesn't leak that thread.+ init_dbos()+ from services import events_service+ events_service.start_worker()
yield
🤖 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 `@backend/main.py` around lines 85 - 97, Reorder the startup sequence in the
application lifespan flow so init_dbos() completes before
events_service.start_worker() is called. Preserve the existing yield and
shutdown ordering, ensuring a DBOS initialization failure occurs before the
events worker starts and cannot leak that thread.
🤖 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 `@docs/decisions/0011-durable-execution-dbos.md`:
- Around line 44-50: Update docs/decisions/0011-durable-execution-dbos.md lines
44-50 to describe test_pipeline_identical_with_dbos_on as asserting parity for
category, abstract, and concept names, rather than byte-for-byte output
equality. Update lines 117-122 to state that classifier_agent.run is patched
with an AsyncMock and asserted not called, replacing “unmocked-but-spied”
wording.
- Around line 26-29: Update the `backend/requirements-durable.txt` description
so `dbos>=2.28,<3` is identified as a tested compatible range rather than
pinned; alternatively, change the requirement to an exact `dbos==2.28.0` pin and
retain the pinned wording.
---
Outside diff comments:
In `@backend/main.py`:
- Around line 85-97: Reorder the startup sequence in the application lifespan
flow so init_dbos() completes before events_service.start_worker() is called.
Preserve the existing yield and shutdown ordering, ensuring a DBOS
initialization failure occurs before the events worker starts and cannot leak
that thread.
🪄 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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 22eba8b8-500e-4aef-81fa-affa1d8cc3ac

📥 Commits

Reviewing files that changed from the base of the PR and between 83f5195 and e1f38bb.

📒 Files selected for processing (8)
  • backend/main.py
  • backend/requirements-durable.txt
  • backend/services/durable.py
  • backend/tests/test_dbos_resume.py
  • backend/tests/test_documents_routes.py
  • backend/tests/test_durable_shim.py
  • docs/architecture.md
  • docs/decisions/0011-durable-execution-dbos.md

Comment on lines +26 to +29
3. **`backend/requirements-durable.txt` now exists** (`dbos>=2.28,<3`,
pinned to the version this update's verification ran against). Never
added to `requirements.txt`/`requirements.lock` — durability stays
opt-in and the hermetic suite runs with `dbos` NOT installed.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Do not describe a compatible-version range as pinned.

dbos>=2.28,<3 permits later 2.x releases; it is not pinned to 2.28.0. Either use dbos==2.28.0 or describe this as a tested compatible range.

🤖 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 `@docs/decisions/0011-durable-execution-dbos.md` around lines 26 - 29, Update
the `backend/requirements-durable.txt` description so `dbos>=2.28,<3` is
identified as a tested compatible range rather than pinned; alternatively,
change the requirement to an exact `dbos==2.28.0` pin and retain the pinned
wording.

Comment on lines +44 to +50
`test_pipeline_identical_with_dbos_on` runs
`agents.document.process_document` for real under DBOS
(`SAPLING_MODEL_MODE=function`) and asserts its output matches the
function-mode constants byte-for-byte — proof that wrapping the
pipeline in `@durable_workflow`/`@durable_step` changes durability,
not behavior. Both tests were run against a real `dbos==2.28.0` +
a throwaway Postgres during this update and passed; see the file

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Align the ADR’s test claims with the actual assertions.

  • docs/decisions/0011-durable-execution-dbos.md#L44-L50: describe this as parity for category, abstract, and concept names, or expand the test to compare the complete result; it does not currently prove byte-for-byte output equality.
  • docs/decisions/0011-durable-execution-dbos.md#L117-L122: replace “unmocked-but-spied” with wording that classifier_agent.run is patched with an AsyncMock and asserted not called.
📍 Affects 1 file
  • docs/decisions/0011-durable-execution-dbos.md#L44-L50 (this comment)
  • docs/decisions/0011-durable-execution-dbos.md#L117-L122
🤖 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 `@docs/decisions/0011-durable-execution-dbos.md` around lines 44 - 50, Update
docs/decisions/0011-durable-execution-dbos.md lines 44-50 to describe
test_pipeline_identical_with_dbos_on as asserting parity for category, abstract,
and concept names, rather than byte-for-byte output equality. Update lines
117-122 to state that classifier_agent.run is patched with an AsyncMock and
asserted not called, replacing “unmocked-but-spied” wording.

…doc claims
- /upload/sync now pins the DBOS workflow id to doc:{user_id}:{request_id}
(user-scoped — X-Request-ID is client-supplied), so a client retry
attaches to the SAME workflow: completed -> recorded result, crashed ->
resume at last completed step. Graph merge wrapped as _step_apply_graph
so resume never re-runs the one real side effect.
- init_dbos now RAISES on flag-on-but-preconditions-unmet (missing URL /
failed import) instead of silently degrading an explicit opt-in; shim
tests pin both raise paths.
- Doc corrections: pre-#154 flag-on raised DBOSException per call (502s),
not 'silently nothing'; step-outside-workflow runs the plain function on
dbos 2.28 (not undefined); only database_url is deprecated.
- New opt-in proof test_pipeline_crash_resume_via_workflow_id: real
process_document crash mid-_run_workers -> same-id retry -> classify
step runs EXACTLY once across both phases. All 3 resume tests exercised
green against dbos 2.28 + a real Postgres.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

No issue scored above the posting threshold (five review passes: CLAUDE.md compliance, shallow bug scan, git history + code-comment compliance, prior-PR comments; scored 25–75). All six sub-threshold findings were fixed anyway in cd637c4:

  1. Durability was decorative for the product: production code never assigned a DBOS workflow id, so a client retry started a fresh workflow (re-running every agent step) and background recovery completed workflows nobody consumed — while the graph merge sat unwrapped in the workflow body, re-executed on every resume. Fixed: /upload/sync pins doc:{user_id}:{request_id} via the new services.durable.workflow_id helper (user-scoped — X-Request-ID is client-supplied, an unscoped id would allow cross-user workflow poisoning), and the merge is now a checkpointed _step_apply_graph. A new opt-in test proves the real pipeline resumes at the last completed step across a crash (classify runs exactly once over both phases); all three resume tests exercised green against dbos 2.28 + a real Postgres.

https://github.com/SaplingLearn/Sapling/blob/e1f38bb2c96de78372adba17e59b45048e60f22c/backend/agents/document.py#L150-L162

  1. init_dbos() silently degraded an explicit DBOS_ENABLED=true opt-in to passthrough when DBOS_DATABASE_URL was missing or the import failed, contradicting its own "fail-loud" contract — it now raises RuntimeError naming the failed precondition, with both paths pinned in tests.

3–4. Doc-accuracy: the ADR/docstring claimed the pre-#154 flag-on state "silently did nothing" (it actually raised DBOSException per call → deterministic 502s, per dbos/_core.py's registry guard), and agents/document.py called step-outside-workflow "undefined behavior" (dbos 2.28 deterministically runs the plain function). Both corrected, plus a deprecated-key wording fix.

The remaining pass observation — no pre-merge e2e-lane run reported yet — is the next pipeline stage; the full local cycle (Playwright + oracles under the stack lock) runs before merge and results will be posted here.

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Pre-merge local e2e cycle at cd637c4 under the stack lock (SAPLING_MODEL_MODE=function): make e2e-up clean, Playwright 29/29 passed (1.1m), e2e_oracles exit 0, clean teardown (RESULT up=0 pw=0 oracles=0 down=0). Flag-off no-op confirmed empirically: the lifespan's init_dbos() logged the passthrough line and every journey behaved identically.

@AndresL230
AndresL230 merged commit 9edfcf5 into mainJul 30, 2026
6 of 8 checks passed
@AndresL230
AndresL230 deleted the feat/b7-154-dbos-durability branch July 30, 2026 16:10
Sign up for freeto 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.

[P2] Agent platform: productionize DBOS durability + crash-safe streaming upload

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(durability): productionize DBOS — entrypoint wiring, resume proof, validated procedure (#154) - #474

Merged
AndresL230 merged 2 commits into
mainfrom
feat/b7-154-dbos-durability
Jul 30, 2026
Merged

feat(durability): productionize DBOS — entrypoint wiring, resume proof, validated procedure (#154)#474
AndresL230 merged 2 commits into
mainfrom
feat/b7-154-dbos-durability

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Closes#154. Last item in the agent-migration epic #152.

ADR 0011 shipped the durable shim off + unvalidated: main.py never constructed or launched DBOS (the shim's import-time activation "trusted that DBOS() was called in main.py" — no such call existed), the DBOS_DATABASE_URL precondition was docstring-only, requirements-durable.txt was promised but never created, and neither mode had any test coverage.

What this does

  • services/durable.py: activation now requires DBOS_ENABLED=true + importable dbos + non-empty DBOS_DATABASE_URL (code matches its docstring). New init_dbos()/shutdown_dbos() called from main.py's _lifespan: construct the singleton (system_database_url config — the deprecated database_url key provisions a separate app DB used only by @DBOS.transaction, which we don't use) and DBOS.launch(). Fail-loud contract: any construct/launch failure under an explicit opt-in raises at startup (No startup env validation + silent HMAC-less SESSION_SECRET fallback #174 posture) — a silent passthrough would look durable while resuming nothing. Decorate-before-construct-before-launch verified against dbos==2.28.0 internals (registrations land on a lazily-created global registry the later DBOS.__init__ picks up).
  • requirements-durable.txt: the opt-in extra (dbos>=2.28,<3), deliberately outside requirements.txt/lock.
  • tests/test_durable_shim.py (11 hermetic tests, no dbos needed): passthrough parity + every precondition combination via env-patched reload of a fake dbos module, with pristine-state restore so the already-imported agents.document decorations are untouched.
  • tests/test_dbos_resume.py (opt-in, RUN_DBOS_RESUME=1): subprocess crash/resume proof — step 1 checkpoints, step 2 os._exit(42)s the process, relaunch auto-recovers the PENDING workflow, and step 1's side-effect counter shows exactly one invocation (resume at last completed step). Plus process_document parity under real DBOS in function mode. Exercised twice against dbos 2.28 + a real throwaway Postgres (implementer + an independent re-run): 2 passed in 3.56s.
  • Streaming crash-safety ([P2] Document pipeline robustness: post-result fallback double-fire, empty-OCR persistence, blocking IO, wrong size message #132 scenario): the streaming replay test now also pins zero re-inserts on an X-Request-ID retry after a crash-past-result (agents already pinned as not re-run; the fix(backend): close the #136 contract-audit tail + #340 + #72 #464 guard keeps exactly-one-result).
  • ADR 0011 → accepted (shipped + validated, default off): corrected activation procedure (DBOS.launch() runs system-DB migrations itself and auto-recovers PENDING workflows — the old dbos migrate step was stale), resume monitoring (startup INFO line captured by Logfire per [P2] Observability: activate Logfire for ops/error/LLM tracing #119 + dbos.workflow_status SQL), and the intentional streaming-route asymmetry reaffirmed. DBOS's system schema is DBOS-managed, deliberately outside db/migrations/.

Gates

  • Backend suite: 1480 passed, 31 skipped (was 1469/29; +11 shim tests, +2 opt-in skips), ruff clean.
  • Lock-universe check (pydantic-ai 1.107 scratch venv): all [P2] Agent platform: productionize DBOS durability + crash-safe streaming upload #154-touched areas green; the only failures are pre-existing scratch-venv dep gaps (docling/GOT-OCR/googleapiclient) that reproduce identically on unmodified main.
  • Default posture unchanged: flag off → byte-identical passthrough behavior, e2e lanes unaffected.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added opt-in durable execution for synchronous document uploads, enabling workflow recovery after crashes.
    • Added optional installation support for the durable execution feature.
    • Preserved existing behavior when durable execution is disabled or unavailable.
  • Bug Fixes

    • Improved upload replay handling to prevent duplicate processing, database records, and agent executions.
  • Documentation

    • Clarified durable and non-durable upload paths, recovery behavior, setup, and monitoring.

…f, validated procedure (#154)
ADR 0011 shipped the durable shim off + unvalidated: main.py never
constructed/launched DBOS (the shim 'trusted' an init that didn't exist),
the DBOS_DATABASE_URL precondition was docstring-only, and nothing tested
either mode.
- services/durable.py: enforce the DATABASE_URL precondition at activation;
new init_dbos()/shutdown_dbos() — construct + DBOS.launch() from the
lifespan, fail-loud when the operator explicitly opted in (#174 posture).
Decorate-before-construct-before-launch order verified against
dbos==2.28.0's registry internals.
- main.py: init_dbos() after validate_config(), shutdown_dbos() on teardown.
- requirements-durable.txt: the opt-in extra ADR 0011 promised (dbos>=2.28,<3),
never in requirements.txt/lock.
- tests/test_durable_shim.py: 11 hermetic tests over both modes and every
precondition combination (fake dbos module + reload, pristine restore).
- tests/test_dbos_resume.py: opt-in (RUN_DBOS_RESUME=1) subprocess crash/
resume proof — step1 runs EXACTLY once across an os._exit crash and the
workflow completes on relaunch recovery — plus process_document parity
under real DBOS. Exercised against dbos 2.28 + a real Postgres.
- test_documents_routes.py: streaming replay now also pins zero re-inserts
(the #132 crash-after-result scenario, with the #464 exactly-one-result
guard).
- ADR 0011 → accepted (shipped + validated, default off): corrected
activation procedure (launch() migrates + auto-recovers; 'dbos migrate'
was stale), resume monitoring (startup INFO line -> Logfire per #119,
workflow_status SQL), streaming-asymmetry reaffirmed.
Closes#154
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@supabase

supabaseBot commented Jul 30, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 30, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-stagingcd637c4Commit Preview URL

Branch Preview URL
Jul 30 2026, 04:09 PM

@coderabbitai

coderabbitaiBot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:21 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 3deaa77e-df7d-40e1-85f0-ac20afed8916

📥 Commits

Reviewing files that changed from the base of the PR and between e1f38bb and cd637c4.

📒 Files selected for processing (6)
  • backend/agents/document.py
  • backend/routes/documents.py
  • backend/services/durable.py
  • backend/tests/test_dbos_resume.py
  • backend/tests/test_durable_shim.py
  • docs/decisions/0011-durable-execution-dbos.md
📝 Walkthrough

Walkthrough

DBOS lifecycle handling is added to the FastAPI lifespan, with configuration-gated decorators and opt-in dependency installation. Hermetic tests cover shim modes and failures; subprocess tests cover crash recovery and output parity. Streaming replay tests verify no duplicate document writes, and architecture documentation is updated.

Changes

Durable execution

Layer / File(s)Summary
Durable shim contracts and lifecycle
backend/services/durable.py
DBOS activation now requires the enablement flag and a database URL, with passthrough fallback, explicit initialization, and non-raising shutdown.
FastAPI lifespan integration
backend/main.py, backend/requirements-durable.txt
Application startup launches DBOS before serving requests, shutdown destroys it after the events worker stops, and an opt-in DBOS dependency file is added.
Shim mode and failure validation
backend/tests/test_durable_shim.py
Hermetic tests cover passthrough decorators, degraded configuration, delegated decorators, lifecycle calls, and launch failures.
Resume and replay validation
backend/tests/test_dbos_resume.py, backend/tests/test_documents_routes.py
Opt-in subprocess tests cover checkpoint resume and DBOS output parity; streaming replay tests assert no duplicate document insert.
Architecture and ADR updates
docs/architecture.md, docs/decisions/0011-durable-execution-dbos.md
Documentation records DBOS activation, resume monitoring, streaming-route behavior, replay guarantees, and deferred durability areas.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

  • SaplingLearn/Sapling#67: Introduced the DBOS durable-execution scaffolding extended here with lifecycle initialization and shutdown wiring.

Sequence Diagram(s)

sequenceDiagram
participant FastAPI
participant services.durable
participant DBOS
participant Postgres
FastAPI->>services.durable: init_dbos()
services.durable->>DBOS: construct with system_database_url
DBOS->>Postgres: launch and access DBOS state
FastAPI-->>FastAPI: serve requests
FastAPI->>services.durable: shutdown_dbos()
services.durable->>DBOS: destroy()
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Linked Issues check⚠️ WarningMost #154 goals are covered, but the summary shows monitoring documentation only, not the required observability wiring.Add the DBOS resume monitoring integration to observability, or split that requirement into a separate follow-up PR.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe title clearly summarizes the DBOS durability productionization work and the resume/activation updates.
Out of Scope Changes check✅ PassedThe changes stay focused on DBOS durability, crash-resume tests, and documentation updates with no clear unrelated scope.
Docstring Coverage✅ PassedNo functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Fix failing CI checks
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/b7-154-dbos-durability

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.

@coderabbitaicoderabbitaiBot 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: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
backend/main.py (1)

85-97: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

init_dbos() failure leaks the events-service worker thread.

init_dbos() is documented to raise when DBOS_ENABLED=true and construction/launch fails. It's called at line 92, after events_service.start_worker() (line 88) but before yield (line 93). If it raises, the generator never reaches yield, so events_service.shutdown() (line 96) and shutdown_dbos() (line 97) never run — the drain thread and any queued rows are abandoned on the failed-startup path, contradicting the "last batch... isn't lost" intent of the shutdown comment.

Reorder so init_dbos() runs before starting the events worker, or wrap the sequence to flush/stop events_service on failure before re-raising.

🔧 Suggested reorder
- from services import events_service- events_service.start_worker()- # ADR 0011 / `#154`: construct + launch DBOS when DBOS_ENABLED=true; no-op- # passthrough otherwise. Fails loudly (raises) if the operator opted in- # and launch fails — see services/durable.py::init_dbos.- init_dbos()+ # ADR 0011 / `#154`: construct + launch DBOS when DBOS_ENABLED=true; no-op+ # passthrough otherwise. Fails loudly (raises) if the operator opted in+ # and launch fails — see services/durable.py::init_dbos. Run before+ # starting the events worker so a failed launch doesn't leak that thread.+ init_dbos()+ from services import events_service+ events_service.start_worker()
yield
🤖 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 `@backend/main.py` around lines 85 - 97, Reorder the startup sequence in the
application lifespan flow so init_dbos() completes before
events_service.start_worker() is called. Preserve the existing yield and
shutdown ordering, ensuring a DBOS initialization failure occurs before the
events worker starts and cannot leak that thread.
🤖 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 `@docs/decisions/0011-durable-execution-dbos.md`:
- Around line 44-50: Update docs/decisions/0011-durable-execution-dbos.md lines
44-50 to describe test_pipeline_identical_with_dbos_on as asserting parity for
category, abstract, and concept names, rather than byte-for-byte output
equality. Update lines 117-122 to state that classifier_agent.run is patched
with an AsyncMock and asserted not called, replacing “unmocked-but-spied”
wording.
- Around line 26-29: Update the `backend/requirements-durable.txt` description
so `dbos>=2.28,<3` is identified as a tested compatible range rather than
pinned; alternatively, change the requirement to an exact `dbos==2.28.0` pin and
retain the pinned wording.
---
Outside diff comments:
In `@backend/main.py`:
- Around line 85-97: Reorder the startup sequence in the application lifespan
flow so init_dbos() completes before events_service.start_worker() is called.
Preserve the existing yield and shutdown ordering, ensuring a DBOS
initialization failure occurs before the events worker starts and cannot leak
that thread.
🪄 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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 22eba8b8-500e-4aef-81fa-affa1d8cc3ac

📥 Commits

Reviewing files that changed from the base of the PR and between 83f5195 and e1f38bb.

📒 Files selected for processing (8)
  • backend/main.py
  • backend/requirements-durable.txt
  • backend/services/durable.py
  • backend/tests/test_dbos_resume.py
  • backend/tests/test_documents_routes.py
  • backend/tests/test_durable_shim.py
  • docs/architecture.md
  • docs/decisions/0011-durable-execution-dbos.md

Comment on lines +26 to +29
3. **`backend/requirements-durable.txt` now exists** (`dbos>=2.28,<3`,
pinned to the version this update's verification ran against). Never
added to `requirements.txt`/`requirements.lock` — durability stays
opt-in and the hermetic suite runs with `dbos` NOT installed.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Do not describe a compatible-version range as pinned.

dbos>=2.28,<3 permits later 2.x releases; it is not pinned to 2.28.0. Either use dbos==2.28.0 or describe this as a tested compatible range.

🤖 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 `@docs/decisions/0011-durable-execution-dbos.md` around lines 26 - 29, Update
the `backend/requirements-durable.txt` description so `dbos>=2.28,<3` is
identified as a tested compatible range rather than pinned; alternatively,
change the requirement to an exact `dbos==2.28.0` pin and retain the pinned
wording.

Comment on lines +44 to +50
`test_pipeline_identical_with_dbos_on` runs
`agents.document.process_document` for real under DBOS
(`SAPLING_MODEL_MODE=function`) and asserts its output matches the
function-mode constants byte-for-byte — proof that wrapping the
pipeline in `@durable_workflow`/`@durable_step` changes durability,
not behavior. Both tests were run against a real `dbos==2.28.0` +
a throwaway Postgres during this update and passed; see the file

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Align the ADR’s test claims with the actual assertions.

  • docs/decisions/0011-durable-execution-dbos.md#L44-L50: describe this as parity for category, abstract, and concept names, or expand the test to compare the complete result; it does not currently prove byte-for-byte output equality.
  • docs/decisions/0011-durable-execution-dbos.md#L117-L122: replace “unmocked-but-spied” with wording that classifier_agent.run is patched with an AsyncMock and asserted not called.
📍 Affects 1 file
  • docs/decisions/0011-durable-execution-dbos.md#L44-L50 (this comment)
  • docs/decisions/0011-durable-execution-dbos.md#L117-L122
🤖 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 `@docs/decisions/0011-durable-execution-dbos.md` around lines 44 - 50, Update
docs/decisions/0011-durable-execution-dbos.md lines 44-50 to describe
test_pipeline_identical_with_dbos_on as asserting parity for category, abstract,
and concept names, rather than byte-for-byte output equality. Update lines
117-122 to state that classifier_agent.run is patched with an AsyncMock and
asserted not called, replacing “unmocked-but-spied” wording.

…doc claims
- /upload/sync now pins the DBOS workflow id to doc:{user_id}:{request_id}
(user-scoped — X-Request-ID is client-supplied), so a client retry
attaches to the SAME workflow: completed -> recorded result, crashed ->
resume at last completed step. Graph merge wrapped as _step_apply_graph
so resume never re-runs the one real side effect.
- init_dbos now RAISES on flag-on-but-preconditions-unmet (missing URL /
failed import) instead of silently degrading an explicit opt-in; shim
tests pin both raise paths.
- Doc corrections: pre-#154 flag-on raised DBOSException per call (502s),
not 'silently nothing'; step-outside-workflow runs the plain function on
dbos 2.28 (not undefined); only database_url is deprecated.
- New opt-in proof test_pipeline_crash_resume_via_workflow_id: real
process_document crash mid-_run_workers -> same-id retry -> classify
step runs EXACTLY once across both phases. All 3 resume tests exercised
green against dbos 2.28 + a real Postgres.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

No issue scored above the posting threshold (five review passes: CLAUDE.md compliance, shallow bug scan, git history + code-comment compliance, prior-PR comments; scored 25–75). All six sub-threshold findings were fixed anyway in cd637c4:

  1. Durability was decorative for the product: production code never assigned a DBOS workflow id, so a client retry started a fresh workflow (re-running every agent step) and background recovery completed workflows nobody consumed — while the graph merge sat unwrapped in the workflow body, re-executed on every resume. Fixed: /upload/sync pins doc:{user_id}:{request_id} via the new services.durable.workflow_id helper (user-scoped — X-Request-ID is client-supplied, an unscoped id would allow cross-user workflow poisoning), and the merge is now a checkpointed _step_apply_graph. A new opt-in test proves the real pipeline resumes at the last completed step across a crash (classify runs exactly once over both phases); all three resume tests exercised green against dbos 2.28 + a real Postgres.

https://github.com/SaplingLearn/Sapling/blob/e1f38bb2c96de78372adba17e59b45048e60f22c/backend/agents/document.py#L150-L162

  1. init_dbos() silently degraded an explicit DBOS_ENABLED=true opt-in to passthrough when DBOS_DATABASE_URL was missing or the import failed, contradicting its own "fail-loud" contract — it now raises RuntimeError naming the failed precondition, with both paths pinned in tests.

3–4. Doc-accuracy: the ADR/docstring claimed the pre-#154 flag-on state "silently did nothing" (it actually raised DBOSException per call → deterministic 502s, per dbos/_core.py's registry guard), and agents/document.py called step-outside-workflow "undefined behavior" (dbos 2.28 deterministically runs the plain function). Both corrected, plus a deprecated-key wording fix.

The remaining pass observation — no pre-merge e2e-lane run reported yet — is the next pipeline stage; the full local cycle (Playwright + oracles under the stack lock) runs before merge and results will be posted here.

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Pre-merge local e2e cycle at cd637c4 under the stack lock (SAPLING_MODEL_MODE=function): make e2e-up clean, Playwright 29/29 passed (1.1m), e2e_oracles exit 0, clean teardown (RESULT up=0 pw=0 oracles=0 down=0). Flag-off no-op confirmed empirically: the lifespan's init_dbos() logged the passthrough line and every journey behaved identically.

@AndresL230
AndresL230 merged commit 9edfcf5 into mainJul 30, 2026
6 of 8 checks passed
@AndresL230
AndresL230 deleted the feat/b7-154-dbos-durability branch July 30, 2026 16:10
Sign up for freeto 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.

[P2] Agent platform: productionize DBOS durability + crash-safe streaming upload

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

feat(durability): productionize DBOS — entrypoint wiring, resume proof, validated procedure (#154) - #474

Merged
AndresL230 merged 2 commits into
mainfrom
feat/b7-154-dbos-durability
Jul 30, 2026
Merged

feat(durability): productionize DBOS — entrypoint wiring, resume proof, validated procedure (#154)#474
AndresL230 merged 2 commits into
mainfrom
feat/b7-154-dbos-durability

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Closes#154. Last item in the agent-migration epic #152.

ADR 0011 shipped the durable shim off + unvalidated: main.py never constructed or launched DBOS (the shim's import-time activation "trusted that DBOS() was called in main.py" — no such call existed), the DBOS_DATABASE_URL precondition was docstring-only, requirements-durable.txt was promised but never created, and neither mode had any test coverage.

What this does

  • services/durable.py: activation now requires DBOS_ENABLED=true + importable dbos + non-empty DBOS_DATABASE_URL (code matches its docstring). New init_dbos()/shutdown_dbos() called from main.py's _lifespan: construct the singleton (system_database_url config — the deprecated database_url key provisions a separate app DB used only by @DBOS.transaction, which we don't use) and DBOS.launch(). Fail-loud contract: any construct/launch failure under an explicit opt-in raises at startup (No startup env validation + silent HMAC-less SESSION_SECRET fallback #174 posture) — a silent passthrough would look durable while resuming nothing. Decorate-before-construct-before-launch verified against dbos==2.28.0 internals (registrations land on a lazily-created global registry the later DBOS.__init__ picks up).
  • requirements-durable.txt: the opt-in extra (dbos>=2.28,<3), deliberately outside requirements.txt/lock.
  • tests/test_durable_shim.py (11 hermetic tests, no dbos needed): passthrough parity + every precondition combination via env-patched reload of a fake dbos module, with pristine-state restore so the already-imported agents.document decorations are untouched.
  • tests/test_dbos_resume.py (opt-in, RUN_DBOS_RESUME=1): subprocess crash/resume proof — step 1 checkpoints, step 2 os._exit(42)s the process, relaunch auto-recovers the PENDING workflow, and step 1's side-effect counter shows exactly one invocation (resume at last completed step). Plus process_document parity under real DBOS in function mode. Exercised twice against dbos 2.28 + a real throwaway Postgres (implementer + an independent re-run): 2 passed in 3.56s.
  • Streaming crash-safety ([P2] Document pipeline robustness: post-result fallback double-fire, empty-OCR persistence, blocking IO, wrong size message #132 scenario): the streaming replay test now also pins zero re-inserts on an X-Request-ID retry after a crash-past-result (agents already pinned as not re-run; the fix(backend): close the #136 contract-audit tail + #340 + #72 #464 guard keeps exactly-one-result).
  • ADR 0011 → accepted (shipped + validated, default off): corrected activation procedure (DBOS.launch() runs system-DB migrations itself and auto-recovers PENDING workflows — the old dbos migrate step was stale), resume monitoring (startup INFO line captured by Logfire per [P2] Observability: activate Logfire for ops/error/LLM tracing #119 + dbos.workflow_status SQL), and the intentional streaming-route asymmetry reaffirmed. DBOS's system schema is DBOS-managed, deliberately outside db/migrations/.

Gates

  • Backend suite: 1480 passed, 31 skipped (was 1469/29; +11 shim tests, +2 opt-in skips), ruff clean.
  • Lock-universe check (pydantic-ai 1.107 scratch venv): all [P2] Agent platform: productionize DBOS durability + crash-safe streaming upload #154-touched areas green; the only failures are pre-existing scratch-venv dep gaps (docling/GOT-OCR/googleapiclient) that reproduce identically on unmodified main.
  • Default posture unchanged: flag off → byte-identical passthrough behavior, e2e lanes unaffected.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added opt-in durable execution for synchronous document uploads, enabling workflow recovery after crashes.
    • Added optional installation support for the durable execution feature.
    • Preserved existing behavior when durable execution is disabled or unavailable.
  • Bug Fixes

    • Improved upload replay handling to prevent duplicate processing, database records, and agent executions.
  • Documentation

    • Clarified durable and non-durable upload paths, recovery behavior, setup, and monitoring.

…f, validated procedure (#154)
ADR 0011 shipped the durable shim off + unvalidated: main.py never
constructed/launched DBOS (the shim 'trusted' an init that didn't exist),
the DBOS_DATABASE_URL precondition was docstring-only, and nothing tested
either mode.
- services/durable.py: enforce the DATABASE_URL precondition at activation;
new init_dbos()/shutdown_dbos() — construct + DBOS.launch() from the
lifespan, fail-loud when the operator explicitly opted in (#174 posture).
Decorate-before-construct-before-launch order verified against
dbos==2.28.0's registry internals.
- main.py: init_dbos() after validate_config(), shutdown_dbos() on teardown.
- requirements-durable.txt: the opt-in extra ADR 0011 promised (dbos>=2.28,<3),
never in requirements.txt/lock.
- tests/test_durable_shim.py: 11 hermetic tests over both modes and every
precondition combination (fake dbos module + reload, pristine restore).
- tests/test_dbos_resume.py: opt-in (RUN_DBOS_RESUME=1) subprocess crash/
resume proof — step1 runs EXACTLY once across an os._exit crash and the
workflow completes on relaunch recovery — plus process_document parity
under real DBOS. Exercised against dbos 2.28 + a real Postgres.
- test_documents_routes.py: streaming replay now also pins zero re-inserts
(the #132 crash-after-result scenario, with the #464 exactly-one-result
guard).
- ADR 0011 → accepted (shipped + validated, default off): corrected
activation procedure (launch() migrates + auto-recovers; 'dbos migrate'
was stale), resume monitoring (startup INFO line -> Logfire per #119,
workflow_status SQL), streaming-asymmetry reaffirmed.
Closes#154
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@supabase

supabaseBot commented Jul 30, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 30, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-stagingcd637c4Commit Preview URL

Branch Preview URL
Jul 30 2026, 04:09 PM

@coderabbitai

coderabbitaiBot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:21 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 3deaa77e-df7d-40e1-85f0-ac20afed8916

📥 Commits

Reviewing files that changed from the base of the PR and between e1f38bb and cd637c4.

📒 Files selected for processing (6)
  • backend/agents/document.py
  • backend/routes/documents.py
  • backend/services/durable.py
  • backend/tests/test_dbos_resume.py
  • backend/tests/test_durable_shim.py
  • docs/decisions/0011-durable-execution-dbos.md
📝 Walkthrough

Walkthrough

DBOS lifecycle handling is added to the FastAPI lifespan, with configuration-gated decorators and opt-in dependency installation. Hermetic tests cover shim modes and failures; subprocess tests cover crash recovery and output parity. Streaming replay tests verify no duplicate document writes, and architecture documentation is updated.

Changes

Durable execution

Layer / File(s)Summary
Durable shim contracts and lifecycle
backend/services/durable.py
DBOS activation now requires the enablement flag and a database URL, with passthrough fallback, explicit initialization, and non-raising shutdown.
FastAPI lifespan integration
backend/main.py, backend/requirements-durable.txt
Application startup launches DBOS before serving requests, shutdown destroys it after the events worker stops, and an opt-in DBOS dependency file is added.
Shim mode and failure validation
backend/tests/test_durable_shim.py
Hermetic tests cover passthrough decorators, degraded configuration, delegated decorators, lifecycle calls, and launch failures.
Resume and replay validation
backend/tests/test_dbos_resume.py, backend/tests/test_documents_routes.py
Opt-in subprocess tests cover checkpoint resume and DBOS output parity; streaming replay tests assert no duplicate document insert.
Architecture and ADR updates
docs/architecture.md, docs/decisions/0011-durable-execution-dbos.md
Documentation records DBOS activation, resume monitoring, streaming-route behavior, replay guarantees, and deferred durability areas.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

  • SaplingLearn/Sapling#67: Introduced the DBOS durable-execution scaffolding extended here with lifecycle initialization and shutdown wiring.

Sequence Diagram(s)

sequenceDiagram
participant FastAPI
participant services.durable
participant DBOS
participant Postgres
FastAPI->>services.durable: init_dbos()
services.durable->>DBOS: construct with system_database_url
DBOS->>Postgres: launch and access DBOS state
FastAPI-->>FastAPI: serve requests
FastAPI->>services.durable: shutdown_dbos()
services.durable->>DBOS: destroy()
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Linked Issues check⚠️ WarningMost #154 goals are covered, but the summary shows monitoring documentation only, not the required observability wiring.Add the DBOS resume monitoring integration to observability, or split that requirement into a separate follow-up PR.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe title clearly summarizes the DBOS durability productionization work and the resume/activation updates.
Out of Scope Changes check✅ PassedThe changes stay focused on DBOS durability, crash-resume tests, and documentation updates with no clear unrelated scope.
Docstring Coverage✅ PassedNo functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Fix failing CI checks
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/b7-154-dbos-durability

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.

@coderabbitaicoderabbitaiBot 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: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
backend/main.py (1)

85-97: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

init_dbos() failure leaks the events-service worker thread.

init_dbos() is documented to raise when DBOS_ENABLED=true and construction/launch fails. It's called at line 92, after events_service.start_worker() (line 88) but before yield (line 93). If it raises, the generator never reaches yield, so events_service.shutdown() (line 96) and shutdown_dbos() (line 97) never run — the drain thread and any queued rows are abandoned on the failed-startup path, contradicting the "last batch... isn't lost" intent of the shutdown comment.

Reorder so init_dbos() runs before starting the events worker, or wrap the sequence to flush/stop events_service on failure before re-raising.

🔧 Suggested reorder
- from services import events_service- events_service.start_worker()- # ADR 0011 / `#154`: construct + launch DBOS when DBOS_ENABLED=true; no-op- # passthrough otherwise. Fails loudly (raises) if the operator opted in- # and launch fails — see services/durable.py::init_dbos.- init_dbos()+ # ADR 0011 / `#154`: construct + launch DBOS when DBOS_ENABLED=true; no-op+ # passthrough otherwise. Fails loudly (raises) if the operator opted in+ # and launch fails — see services/durable.py::init_dbos. Run before+ # starting the events worker so a failed launch doesn't leak that thread.+ init_dbos()+ from services import events_service+ events_service.start_worker()
yield
🤖 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 `@backend/main.py` around lines 85 - 97, Reorder the startup sequence in the
application lifespan flow so init_dbos() completes before
events_service.start_worker() is called. Preserve the existing yield and
shutdown ordering, ensuring a DBOS initialization failure occurs before the
events worker starts and cannot leak that thread.
🤖 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 `@docs/decisions/0011-durable-execution-dbos.md`:
- Around line 44-50: Update docs/decisions/0011-durable-execution-dbos.md lines
44-50 to describe test_pipeline_identical_with_dbos_on as asserting parity for
category, abstract, and concept names, rather than byte-for-byte output
equality. Update lines 117-122 to state that classifier_agent.run is patched
with an AsyncMock and asserted not called, replacing “unmocked-but-spied”
wording.
- Around line 26-29: Update the `backend/requirements-durable.txt` description
so `dbos>=2.28,<3` is identified as a tested compatible range rather than
pinned; alternatively, change the requirement to an exact `dbos==2.28.0` pin and
retain the pinned wording.
---
Outside diff comments:
In `@backend/main.py`:
- Around line 85-97: Reorder the startup sequence in the application lifespan
flow so init_dbos() completes before events_service.start_worker() is called.
Preserve the existing yield and shutdown ordering, ensuring a DBOS
initialization failure occurs before the events worker starts and cannot leak
that thread.
🪄 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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 22eba8b8-500e-4aef-81fa-affa1d8cc3ac

📥 Commits

Reviewing files that changed from the base of the PR and between 83f5195 and e1f38bb.

📒 Files selected for processing (8)
  • backend/main.py
  • backend/requirements-durable.txt
  • backend/services/durable.py
  • backend/tests/test_dbos_resume.py
  • backend/tests/test_documents_routes.py
  • backend/tests/test_durable_shim.py
  • docs/architecture.md
  • docs/decisions/0011-durable-execution-dbos.md

Comment on lines +26 to +29
3. **`backend/requirements-durable.txt` now exists** (`dbos>=2.28,<3`,
pinned to the version this update's verification ran against). Never
added to `requirements.txt`/`requirements.lock` — durability stays
opt-in and the hermetic suite runs with `dbos` NOT installed.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Do not describe a compatible-version range as pinned.

dbos>=2.28,<3 permits later 2.x releases; it is not pinned to 2.28.0. Either use dbos==2.28.0 or describe this as a tested compatible range.

🤖 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 `@docs/decisions/0011-durable-execution-dbos.md` around lines 26 - 29, Update
the `backend/requirements-durable.txt` description so `dbos>=2.28,<3` is
identified as a tested compatible range rather than pinned; alternatively,
change the requirement to an exact `dbos==2.28.0` pin and retain the pinned
wording.

Comment on lines +44 to +50
`test_pipeline_identical_with_dbos_on` runs
`agents.document.process_document` for real under DBOS
(`SAPLING_MODEL_MODE=function`) and asserts its output matches the
function-mode constants byte-for-byte — proof that wrapping the
pipeline in `@durable_workflow`/`@durable_step` changes durability,
not behavior. Both tests were run against a real `dbos==2.28.0` +
a throwaway Postgres during this update and passed; see the file

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Align the ADR’s test claims with the actual assertions.

  • docs/decisions/0011-durable-execution-dbos.md#L44-L50: describe this as parity for category, abstract, and concept names, or expand the test to compare the complete result; it does not currently prove byte-for-byte output equality.
  • docs/decisions/0011-durable-execution-dbos.md#L117-L122: replace “unmocked-but-spied” with wording that classifier_agent.run is patched with an AsyncMock and asserted not called.
📍 Affects 1 file
  • docs/decisions/0011-durable-execution-dbos.md#L44-L50 (this comment)
  • docs/decisions/0011-durable-execution-dbos.md#L117-L122
🤖 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 `@docs/decisions/0011-durable-execution-dbos.md` around lines 44 - 50, Update
docs/decisions/0011-durable-execution-dbos.md lines 44-50 to describe
test_pipeline_identical_with_dbos_on as asserting parity for category, abstract,
and concept names, rather than byte-for-byte output equality. Update lines
117-122 to state that classifier_agent.run is patched with an AsyncMock and
asserted not called, replacing “unmocked-but-spied” wording.

…doc claims
- /upload/sync now pins the DBOS workflow id to doc:{user_id}:{request_id}
(user-scoped — X-Request-ID is client-supplied), so a client retry
attaches to the SAME workflow: completed -> recorded result, crashed ->
resume at last completed step. Graph merge wrapped as _step_apply_graph
so resume never re-runs the one real side effect.
- init_dbos now RAISES on flag-on-but-preconditions-unmet (missing URL /
failed import) instead of silently degrading an explicit opt-in; shim
tests pin both raise paths.
- Doc corrections: pre-#154 flag-on raised DBOSException per call (502s),
not 'silently nothing'; step-outside-workflow runs the plain function on
dbos 2.28 (not undefined); only database_url is deprecated.
- New opt-in proof test_pipeline_crash_resume_via_workflow_id: real
process_document crash mid-_run_workers -> same-id retry -> classify
step runs EXACTLY once across both phases. All 3 resume tests exercised
green against dbos 2.28 + a real Postgres.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

No issue scored above the posting threshold (five review passes: CLAUDE.md compliance, shallow bug scan, git history + code-comment compliance, prior-PR comments; scored 25–75). All six sub-threshold findings were fixed anyway in cd637c4:

  1. Durability was decorative for the product: production code never assigned a DBOS workflow id, so a client retry started a fresh workflow (re-running every agent step) and background recovery completed workflows nobody consumed — while the graph merge sat unwrapped in the workflow body, re-executed on every resume. Fixed: /upload/sync pins doc:{user_id}:{request_id} via the new services.durable.workflow_id helper (user-scoped — X-Request-ID is client-supplied, an unscoped id would allow cross-user workflow poisoning), and the merge is now a checkpointed _step_apply_graph. A new opt-in test proves the real pipeline resumes at the last completed step across a crash (classify runs exactly once over both phases); all three resume tests exercised green against dbos 2.28 + a real Postgres.

https://github.com/SaplingLearn/Sapling/blob/e1f38bb2c96de78372adba17e59b45048e60f22c/backend/agents/document.py#L150-L162

  1. init_dbos() silently degraded an explicit DBOS_ENABLED=true opt-in to passthrough when DBOS_DATABASE_URL was missing or the import failed, contradicting its own "fail-loud" contract — it now raises RuntimeError naming the failed precondition, with both paths pinned in tests.

3–4. Doc-accuracy: the ADR/docstring claimed the pre-#154 flag-on state "silently did nothing" (it actually raised DBOSException per call → deterministic 502s, per dbos/_core.py's registry guard), and agents/document.py called step-outside-workflow "undefined behavior" (dbos 2.28 deterministically runs the plain function). Both corrected, plus a deprecated-key wording fix.

The remaining pass observation — no pre-merge e2e-lane run reported yet — is the next pipeline stage; the full local cycle (Playwright + oracles under the stack lock) runs before merge and results will be posted here.

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Pre-merge local e2e cycle at cd637c4 under the stack lock (SAPLING_MODEL_MODE=function): make e2e-up clean, Playwright 29/29 passed (1.1m), e2e_oracles exit 0, clean teardown (RESULT up=0 pw=0 oracles=0 down=0). Flag-off no-op confirmed empirically: the lifespan's init_dbos() logged the passthrough line and every journey behaved identically.

@AndresL230
AndresL230 merged commit 9edfcf5 into mainJul 30, 2026
6 of 8 checks passed
@AndresL230
AndresL230 deleted the feat/b7-154-dbos-durability branch July 30, 2026 16:10
Sign up for freeto 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.

[P2] Agent platform: productionize DBOS durability + crash-safe streaming upload

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(durability): productionize DBOS — entrypoint wiring, resume proof, validated procedure (#154) - #474

Merged
AndresL230 merged 2 commits into
mainfrom
feat/b7-154-dbos-durability
Jul 30, 2026
Merged

feat(durability): productionize DBOS — entrypoint wiring, resume proof, validated procedure (#154)#474
AndresL230 merged 2 commits into
mainfrom
feat/b7-154-dbos-durability

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Closes#154. Last item in the agent-migration epic #152.

ADR 0011 shipped the durable shim off + unvalidated: main.py never constructed or launched DBOS (the shim's import-time activation "trusted that DBOS() was called in main.py" — no such call existed), the DBOS_DATABASE_URL precondition was docstring-only, requirements-durable.txt was promised but never created, and neither mode had any test coverage.

What this does

  • services/durable.py: activation now requires DBOS_ENABLED=true + importable dbos + non-empty DBOS_DATABASE_URL (code matches its docstring). New init_dbos()/shutdown_dbos() called from main.py's _lifespan: construct the singleton (system_database_url config — the deprecated database_url key provisions a separate app DB used only by @DBOS.transaction, which we don't use) and DBOS.launch(). Fail-loud contract: any construct/launch failure under an explicit opt-in raises at startup (No startup env validation + silent HMAC-less SESSION_SECRET fallback #174 posture) — a silent passthrough would look durable while resuming nothing. Decorate-before-construct-before-launch verified against dbos==2.28.0 internals (registrations land on a lazily-created global registry the later DBOS.__init__ picks up).
  • requirements-durable.txt: the opt-in extra (dbos>=2.28,<3), deliberately outside requirements.txt/lock.
  • tests/test_durable_shim.py (11 hermetic tests, no dbos needed): passthrough parity + every precondition combination via env-patched reload of a fake dbos module, with pristine-state restore so the already-imported agents.document decorations are untouched.
  • tests/test_dbos_resume.py (opt-in, RUN_DBOS_RESUME=1): subprocess crash/resume proof — step 1 checkpoints, step 2 os._exit(42)s the process, relaunch auto-recovers the PENDING workflow, and step 1's side-effect counter shows exactly one invocation (resume at last completed step). Plus process_document parity under real DBOS in function mode. Exercised twice against dbos 2.28 + a real throwaway Postgres (implementer + an independent re-run): 2 passed in 3.56s.
  • Streaming crash-safety ([P2] Document pipeline robustness: post-result fallback double-fire, empty-OCR persistence, blocking IO, wrong size message #132 scenario): the streaming replay test now also pins zero re-inserts on an X-Request-ID retry after a crash-past-result (agents already pinned as not re-run; the fix(backend): close the #136 contract-audit tail + #340 + #72 #464 guard keeps exactly-one-result).
  • ADR 0011 → accepted (shipped + validated, default off): corrected activation procedure (DBOS.launch() runs system-DB migrations itself and auto-recovers PENDING workflows — the old dbos migrate step was stale), resume monitoring (startup INFO line captured by Logfire per [P2] Observability: activate Logfire for ops/error/LLM tracing #119 + dbos.workflow_status SQL), and the intentional streaming-route asymmetry reaffirmed. DBOS's system schema is DBOS-managed, deliberately outside db/migrations/.

Gates

  • Backend suite: 1480 passed, 31 skipped (was 1469/29; +11 shim tests, +2 opt-in skips), ruff clean.
  • Lock-universe check (pydantic-ai 1.107 scratch venv): all [P2] Agent platform: productionize DBOS durability + crash-safe streaming upload #154-touched areas green; the only failures are pre-existing scratch-venv dep gaps (docling/GOT-OCR/googleapiclient) that reproduce identically on unmodified main.
  • Default posture unchanged: flag off → byte-identical passthrough behavior, e2e lanes unaffected.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added opt-in durable execution for synchronous document uploads, enabling workflow recovery after crashes.
    • Added optional installation support for the durable execution feature.
    • Preserved existing behavior when durable execution is disabled or unavailable.
  • Bug Fixes

    • Improved upload replay handling to prevent duplicate processing, database records, and agent executions.
  • Documentation

    • Clarified durable and non-durable upload paths, recovery behavior, setup, and monitoring.

…f, validated procedure (#154)
ADR 0011 shipped the durable shim off + unvalidated: main.py never
constructed/launched DBOS (the shim 'trusted' an init that didn't exist),
the DBOS_DATABASE_URL precondition was docstring-only, and nothing tested
either mode.
- services/durable.py: enforce the DATABASE_URL precondition at activation;
new init_dbos()/shutdown_dbos() — construct + DBOS.launch() from the
lifespan, fail-loud when the operator explicitly opted in (#174 posture).
Decorate-before-construct-before-launch order verified against
dbos==2.28.0's registry internals.
- main.py: init_dbos() after validate_config(), shutdown_dbos() on teardown.
- requirements-durable.txt: the opt-in extra ADR 0011 promised (dbos>=2.28,<3),
never in requirements.txt/lock.
- tests/test_durable_shim.py: 11 hermetic tests over both modes and every
precondition combination (fake dbos module + reload, pristine restore).
- tests/test_dbos_resume.py: opt-in (RUN_DBOS_RESUME=1) subprocess crash/
resume proof — step1 runs EXACTLY once across an os._exit crash and the
workflow completes on relaunch recovery — plus process_document parity
under real DBOS. Exercised against dbos 2.28 + a real Postgres.
- test_documents_routes.py: streaming replay now also pins zero re-inserts
(the #132 crash-after-result scenario, with the #464 exactly-one-result
guard).
- ADR 0011 → accepted (shipped + validated, default off): corrected
activation procedure (launch() migrates + auto-recovers; 'dbos migrate'
was stale), resume monitoring (startup INFO line -> Logfire per #119,
workflow_status SQL), streaming-asymmetry reaffirmed.
Closes#154
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@supabase

supabaseBot commented Jul 30, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 30, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-stagingcd637c4Commit Preview URL

Branch Preview URL
Jul 30 2026, 04:09 PM

@coderabbitai

coderabbitaiBot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:21 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 3deaa77e-df7d-40e1-85f0-ac20afed8916

📥 Commits

Reviewing files that changed from the base of the PR and between e1f38bb and cd637c4.

📒 Files selected for processing (6)
  • backend/agents/document.py
  • backend/routes/documents.py
  • backend/services/durable.py
  • backend/tests/test_dbos_resume.py
  • backend/tests/test_durable_shim.py
  • docs/decisions/0011-durable-execution-dbos.md
📝 Walkthrough

Walkthrough

DBOS lifecycle handling is added to the FastAPI lifespan, with configuration-gated decorators and opt-in dependency installation. Hermetic tests cover shim modes and failures; subprocess tests cover crash recovery and output parity. Streaming replay tests verify no duplicate document writes, and architecture documentation is updated.

Changes

Durable execution

Layer / File(s)Summary
Durable shim contracts and lifecycle
backend/services/durable.py
DBOS activation now requires the enablement flag and a database URL, with passthrough fallback, explicit initialization, and non-raising shutdown.
FastAPI lifespan integration
backend/main.py, backend/requirements-durable.txt
Application startup launches DBOS before serving requests, shutdown destroys it after the events worker stops, and an opt-in DBOS dependency file is added.
Shim mode and failure validation
backend/tests/test_durable_shim.py
Hermetic tests cover passthrough decorators, degraded configuration, delegated decorators, lifecycle calls, and launch failures.
Resume and replay validation
backend/tests/test_dbos_resume.py, backend/tests/test_documents_routes.py
Opt-in subprocess tests cover checkpoint resume and DBOS output parity; streaming replay tests assert no duplicate document insert.
Architecture and ADR updates
docs/architecture.md, docs/decisions/0011-durable-execution-dbos.md
Documentation records DBOS activation, resume monitoring, streaming-route behavior, replay guarantees, and deferred durability areas.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

  • SaplingLearn/Sapling#67: Introduced the DBOS durable-execution scaffolding extended here with lifecycle initialization and shutdown wiring.

Sequence Diagram(s)

sequenceDiagram
participant FastAPI
participant services.durable
participant DBOS
participant Postgres
FastAPI->>services.durable: init_dbos()
services.durable->>DBOS: construct with system_database_url
DBOS->>Postgres: launch and access DBOS state
FastAPI-->>FastAPI: serve requests
FastAPI->>services.durable: shutdown_dbos()
services.durable->>DBOS: destroy()
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Linked Issues check⚠️ WarningMost #154 goals are covered, but the summary shows monitoring documentation only, not the required observability wiring.Add the DBOS resume monitoring integration to observability, or split that requirement into a separate follow-up PR.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe title clearly summarizes the DBOS durability productionization work and the resume/activation updates.
Out of Scope Changes check✅ PassedThe changes stay focused on DBOS durability, crash-resume tests, and documentation updates with no clear unrelated scope.
Docstring Coverage✅ PassedNo functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Fix failing CI checks
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/b7-154-dbos-durability

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.

@coderabbitaicoderabbitaiBot 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: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
backend/main.py (1)

85-97: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

init_dbos() failure leaks the events-service worker thread.

init_dbos() is documented to raise when DBOS_ENABLED=true and construction/launch fails. It's called at line 92, after events_service.start_worker() (line 88) but before yield (line 93). If it raises, the generator never reaches yield, so events_service.shutdown() (line 96) and shutdown_dbos() (line 97) never run — the drain thread and any queued rows are abandoned on the failed-startup path, contradicting the "last batch... isn't lost" intent of the shutdown comment.

Reorder so init_dbos() runs before starting the events worker, or wrap the sequence to flush/stop events_service on failure before re-raising.

🔧 Suggested reorder
- from services import events_service- events_service.start_worker()- # ADR 0011 / `#154`: construct + launch DBOS when DBOS_ENABLED=true; no-op- # passthrough otherwise. Fails loudly (raises) if the operator opted in- # and launch fails — see services/durable.py::init_dbos.- init_dbos()+ # ADR 0011 / `#154`: construct + launch DBOS when DBOS_ENABLED=true; no-op+ # passthrough otherwise. Fails loudly (raises) if the operator opted in+ # and launch fails — see services/durable.py::init_dbos. Run before+ # starting the events worker so a failed launch doesn't leak that thread.+ init_dbos()+ from services import events_service+ events_service.start_worker()
yield
🤖 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 `@backend/main.py` around lines 85 - 97, Reorder the startup sequence in the
application lifespan flow so init_dbos() completes before
events_service.start_worker() is called. Preserve the existing yield and
shutdown ordering, ensuring a DBOS initialization failure occurs before the
events worker starts and cannot leak that thread.
🤖 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 `@docs/decisions/0011-durable-execution-dbos.md`:
- Around line 44-50: Update docs/decisions/0011-durable-execution-dbos.md lines
44-50 to describe test_pipeline_identical_with_dbos_on as asserting parity for
category, abstract, and concept names, rather than byte-for-byte output
equality. Update lines 117-122 to state that classifier_agent.run is patched
with an AsyncMock and asserted not called, replacing “unmocked-but-spied”
wording.
- Around line 26-29: Update the `backend/requirements-durable.txt` description
so `dbos>=2.28,<3` is identified as a tested compatible range rather than
pinned; alternatively, change the requirement to an exact `dbos==2.28.0` pin and
retain the pinned wording.
---
Outside diff comments:
In `@backend/main.py`:
- Around line 85-97: Reorder the startup sequence in the application lifespan
flow so init_dbos() completes before events_service.start_worker() is called.
Preserve the existing yield and shutdown ordering, ensuring a DBOS
initialization failure occurs before the events worker starts and cannot leak
that thread.
🪄 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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 22eba8b8-500e-4aef-81fa-affa1d8cc3ac

📥 Commits

Reviewing files that changed from the base of the PR and between 83f5195 and e1f38bb.

📒 Files selected for processing (8)
  • backend/main.py
  • backend/requirements-durable.txt
  • backend/services/durable.py
  • backend/tests/test_dbos_resume.py
  • backend/tests/test_documents_routes.py
  • backend/tests/test_durable_shim.py
  • docs/architecture.md
  • docs/decisions/0011-durable-execution-dbos.md

Comment on lines +26 to +29
3. **`backend/requirements-durable.txt` now exists** (`dbos>=2.28,<3`,
pinned to the version this update's verification ran against). Never
added to `requirements.txt`/`requirements.lock` — durability stays
opt-in and the hermetic suite runs with `dbos` NOT installed.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Do not describe a compatible-version range as pinned.

dbos>=2.28,<3 permits later 2.x releases; it is not pinned to 2.28.0. Either use dbos==2.28.0 or describe this as a tested compatible range.

🤖 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 `@docs/decisions/0011-durable-execution-dbos.md` around lines 26 - 29, Update
the `backend/requirements-durable.txt` description so `dbos>=2.28,<3` is
identified as a tested compatible range rather than pinned; alternatively,
change the requirement to an exact `dbos==2.28.0` pin and retain the pinned
wording.

Comment on lines +44 to +50
`test_pipeline_identical_with_dbos_on` runs
`agents.document.process_document` for real under DBOS
(`SAPLING_MODEL_MODE=function`) and asserts its output matches the
function-mode constants byte-for-byte — proof that wrapping the
pipeline in `@durable_workflow`/`@durable_step` changes durability,
not behavior. Both tests were run against a real `dbos==2.28.0` +
a throwaway Postgres during this update and passed; see the file

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Align the ADR’s test claims with the actual assertions.

  • docs/decisions/0011-durable-execution-dbos.md#L44-L50: describe this as parity for category, abstract, and concept names, or expand the test to compare the complete result; it does not currently prove byte-for-byte output equality.
  • docs/decisions/0011-durable-execution-dbos.md#L117-L122: replace “unmocked-but-spied” with wording that classifier_agent.run is patched with an AsyncMock and asserted not called.
📍 Affects 1 file
  • docs/decisions/0011-durable-execution-dbos.md#L44-L50 (this comment)
  • docs/decisions/0011-durable-execution-dbos.md#L117-L122
🤖 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 `@docs/decisions/0011-durable-execution-dbos.md` around lines 44 - 50, Update
docs/decisions/0011-durable-execution-dbos.md lines 44-50 to describe
test_pipeline_identical_with_dbos_on as asserting parity for category, abstract,
and concept names, rather than byte-for-byte output equality. Update lines
117-122 to state that classifier_agent.run is patched with an AsyncMock and
asserted not called, replacing “unmocked-but-spied” wording.

…doc claims
- /upload/sync now pins the DBOS workflow id to doc:{user_id}:{request_id}
(user-scoped — X-Request-ID is client-supplied), so a client retry
attaches to the SAME workflow: completed -> recorded result, crashed ->
resume at last completed step. Graph merge wrapped as _step_apply_graph
so resume never re-runs the one real side effect.
- init_dbos now RAISES on flag-on-but-preconditions-unmet (missing URL /
failed import) instead of silently degrading an explicit opt-in; shim
tests pin both raise paths.
- Doc corrections: pre-#154 flag-on raised DBOSException per call (502s),
not 'silently nothing'; step-outside-workflow runs the plain function on
dbos 2.28 (not undefined); only database_url is deprecated.
- New opt-in proof test_pipeline_crash_resume_via_workflow_id: real
process_document crash mid-_run_workers -> same-id retry -> classify
step runs EXACTLY once across both phases. All 3 resume tests exercised
green against dbos 2.28 + a real Postgres.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

No issue scored above the posting threshold (five review passes: CLAUDE.md compliance, shallow bug scan, git history + code-comment compliance, prior-PR comments; scored 25–75). All six sub-threshold findings were fixed anyway in cd637c4:

  1. Durability was decorative for the product: production code never assigned a DBOS workflow id, so a client retry started a fresh workflow (re-running every agent step) and background recovery completed workflows nobody consumed — while the graph merge sat unwrapped in the workflow body, re-executed on every resume. Fixed: /upload/sync pins doc:{user_id}:{request_id} via the new services.durable.workflow_id helper (user-scoped — X-Request-ID is client-supplied, an unscoped id would allow cross-user workflow poisoning), and the merge is now a checkpointed _step_apply_graph. A new opt-in test proves the real pipeline resumes at the last completed step across a crash (classify runs exactly once over both phases); all three resume tests exercised green against dbos 2.28 + a real Postgres.

https://github.com/SaplingLearn/Sapling/blob/e1f38bb2c96de78372adba17e59b45048e60f22c/backend/agents/document.py#L150-L162

  1. init_dbos() silently degraded an explicit DBOS_ENABLED=true opt-in to passthrough when DBOS_DATABASE_URL was missing or the import failed, contradicting its own "fail-loud" contract — it now raises RuntimeError naming the failed precondition, with both paths pinned in tests.

3–4. Doc-accuracy: the ADR/docstring claimed the pre-#154 flag-on state "silently did nothing" (it actually raised DBOSException per call → deterministic 502s, per dbos/_core.py's registry guard), and agents/document.py called step-outside-workflow "undefined behavior" (dbos 2.28 deterministically runs the plain function). Both corrected, plus a deprecated-key wording fix.

The remaining pass observation — no pre-merge e2e-lane run reported yet — is the next pipeline stage; the full local cycle (Playwright + oracles under the stack lock) runs before merge and results will be posted here.

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Pre-merge local e2e cycle at cd637c4 under the stack lock (SAPLING_MODEL_MODE=function): make e2e-up clean, Playwright 29/29 passed (1.1m), e2e_oracles exit 0, clean teardown (RESULT up=0 pw=0 oracles=0 down=0). Flag-off no-op confirmed empirically: the lifespan's init_dbos() logged the passthrough line and every journey behaved identically.

@AndresL230
AndresL230 merged commit 9edfcf5 into mainJul 30, 2026
6 of 8 checks passed
@AndresL230
AndresL230 deleted the feat/b7-154-dbos-durability branch July 30, 2026 16:10
Sign up for freeto 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.

[P2] Agent platform: productionize DBOS durability + crash-safe streaming upload

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(durability): productionize DBOS — entrypoint wiring, resume proof, validated procedure (#154) - #474

Merged
AndresL230 merged 2 commits into
mainfrom
feat/b7-154-dbos-durability
Jul 30, 2026
Merged

feat(durability): productionize DBOS — entrypoint wiring, resume proof, validated procedure (#154)#474
AndresL230 merged 2 commits into
mainfrom
feat/b7-154-dbos-durability

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Closes#154. Last item in the agent-migration epic #152.

ADR 0011 shipped the durable shim off + unvalidated: main.py never constructed or launched DBOS (the shim's import-time activation "trusted that DBOS() was called in main.py" — no such call existed), the DBOS_DATABASE_URL precondition was docstring-only, requirements-durable.txt was promised but never created, and neither mode had any test coverage.

What this does

  • services/durable.py: activation now requires DBOS_ENABLED=true + importable dbos + non-empty DBOS_DATABASE_URL (code matches its docstring). New init_dbos()/shutdown_dbos() called from main.py's _lifespan: construct the singleton (system_database_url config — the deprecated database_url key provisions a separate app DB used only by @DBOS.transaction, which we don't use) and DBOS.launch(). Fail-loud contract: any construct/launch failure under an explicit opt-in raises at startup (No startup env validation + silent HMAC-less SESSION_SECRET fallback #174 posture) — a silent passthrough would look durable while resuming nothing. Decorate-before-construct-before-launch verified against dbos==2.28.0 internals (registrations land on a lazily-created global registry the later DBOS.__init__ picks up).
  • requirements-durable.txt: the opt-in extra (dbos>=2.28,<3), deliberately outside requirements.txt/lock.
  • tests/test_durable_shim.py (11 hermetic tests, no dbos needed): passthrough parity + every precondition combination via env-patched reload of a fake dbos module, with pristine-state restore so the already-imported agents.document decorations are untouched.
  • tests/test_dbos_resume.py (opt-in, RUN_DBOS_RESUME=1): subprocess crash/resume proof — step 1 checkpoints, step 2 os._exit(42)s the process, relaunch auto-recovers the PENDING workflow, and step 1's side-effect counter shows exactly one invocation (resume at last completed step). Plus process_document parity under real DBOS in function mode. Exercised twice against dbos 2.28 + a real throwaway Postgres (implementer + an independent re-run): 2 passed in 3.56s.
  • Streaming crash-safety ([P2] Document pipeline robustness: post-result fallback double-fire, empty-OCR persistence, blocking IO, wrong size message #132 scenario): the streaming replay test now also pins zero re-inserts on an X-Request-ID retry after a crash-past-result (agents already pinned as not re-run; the fix(backend): close the #136 contract-audit tail + #340 + #72 #464 guard keeps exactly-one-result).
  • ADR 0011 → accepted (shipped + validated, default off): corrected activation procedure (DBOS.launch() runs system-DB migrations itself and auto-recovers PENDING workflows — the old dbos migrate step was stale), resume monitoring (startup INFO line captured by Logfire per [P2] Observability: activate Logfire for ops/error/LLM tracing #119 + dbos.workflow_status SQL), and the intentional streaming-route asymmetry reaffirmed. DBOS's system schema is DBOS-managed, deliberately outside db/migrations/.

Gates

  • Backend suite: 1480 passed, 31 skipped (was 1469/29; +11 shim tests, +2 opt-in skips), ruff clean.
  • Lock-universe check (pydantic-ai 1.107 scratch venv): all [P2] Agent platform: productionize DBOS durability + crash-safe streaming upload #154-touched areas green; the only failures are pre-existing scratch-venv dep gaps (docling/GOT-OCR/googleapiclient) that reproduce identically on unmodified main.
  • Default posture unchanged: flag off → byte-identical passthrough behavior, e2e lanes unaffected.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added opt-in durable execution for synchronous document uploads, enabling workflow recovery after crashes.
    • Added optional installation support for the durable execution feature.
    • Preserved existing behavior when durable execution is disabled or unavailable.
  • Bug Fixes

    • Improved upload replay handling to prevent duplicate processing, database records, and agent executions.
  • Documentation

    • Clarified durable and non-durable upload paths, recovery behavior, setup, and monitoring.

…f, validated procedure (#154)
ADR 0011 shipped the durable shim off + unvalidated: main.py never
constructed/launched DBOS (the shim 'trusted' an init that didn't exist),
the DBOS_DATABASE_URL precondition was docstring-only, and nothing tested
either mode.
- services/durable.py: enforce the DATABASE_URL precondition at activation;
new init_dbos()/shutdown_dbos() — construct + DBOS.launch() from the
lifespan, fail-loud when the operator explicitly opted in (#174 posture).
Decorate-before-construct-before-launch order verified against
dbos==2.28.0's registry internals.
- main.py: init_dbos() after validate_config(), shutdown_dbos() on teardown.
- requirements-durable.txt: the opt-in extra ADR 0011 promised (dbos>=2.28,<3),
never in requirements.txt/lock.
- tests/test_durable_shim.py: 11 hermetic tests over both modes and every
precondition combination (fake dbos module + reload, pristine restore).
- tests/test_dbos_resume.py: opt-in (RUN_DBOS_RESUME=1) subprocess crash/
resume proof — step1 runs EXACTLY once across an os._exit crash and the
workflow completes on relaunch recovery — plus process_document parity
under real DBOS. Exercised against dbos 2.28 + a real Postgres.
- test_documents_routes.py: streaming replay now also pins zero re-inserts
(the #132 crash-after-result scenario, with the #464 exactly-one-result
guard).
- ADR 0011 → accepted (shipped + validated, default off): corrected
activation procedure (launch() migrates + auto-recovers; 'dbos migrate'
was stale), resume monitoring (startup INFO line -> Logfire per #119,
workflow_status SQL), streaming-asymmetry reaffirmed.
Closes#154
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@supabase

supabaseBot commented Jul 30, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 30, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-stagingcd637c4Commit Preview URL

Branch Preview URL
Jul 30 2026, 04:09 PM

@coderabbitai

coderabbitaiBot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:21 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 3deaa77e-df7d-40e1-85f0-ac20afed8916

📥 Commits

Reviewing files that changed from the base of the PR and between e1f38bb and cd637c4.

📒 Files selected for processing (6)
  • backend/agents/document.py
  • backend/routes/documents.py
  • backend/services/durable.py
  • backend/tests/test_dbos_resume.py
  • backend/tests/test_durable_shim.py
  • docs/decisions/0011-durable-execution-dbos.md
📝 Walkthrough

Walkthrough

DBOS lifecycle handling is added to the FastAPI lifespan, with configuration-gated decorators and opt-in dependency installation. Hermetic tests cover shim modes and failures; subprocess tests cover crash recovery and output parity. Streaming replay tests verify no duplicate document writes, and architecture documentation is updated.

Changes

Durable execution

Layer / File(s)Summary
Durable shim contracts and lifecycle
backend/services/durable.py
DBOS activation now requires the enablement flag and a database URL, with passthrough fallback, explicit initialization, and non-raising shutdown.
FastAPI lifespan integration
backend/main.py, backend/requirements-durable.txt
Application startup launches DBOS before serving requests, shutdown destroys it after the events worker stops, and an opt-in DBOS dependency file is added.
Shim mode and failure validation
backend/tests/test_durable_shim.py
Hermetic tests cover passthrough decorators, degraded configuration, delegated decorators, lifecycle calls, and launch failures.
Resume and replay validation
backend/tests/test_dbos_resume.py, backend/tests/test_documents_routes.py
Opt-in subprocess tests cover checkpoint resume and DBOS output parity; streaming replay tests assert no duplicate document insert.
Architecture and ADR updates
docs/architecture.md, docs/decisions/0011-durable-execution-dbos.md
Documentation records DBOS activation, resume monitoring, streaming-route behavior, replay guarantees, and deferred durability areas.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

  • SaplingLearn/Sapling#67: Introduced the DBOS durable-execution scaffolding extended here with lifecycle initialization and shutdown wiring.

Sequence Diagram(s)

sequenceDiagram
participant FastAPI
participant services.durable
participant DBOS
participant Postgres
FastAPI->>services.durable: init_dbos()
services.durable->>DBOS: construct with system_database_url
DBOS->>Postgres: launch and access DBOS state
FastAPI-->>FastAPI: serve requests
FastAPI->>services.durable: shutdown_dbos()
services.durable->>DBOS: destroy()
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Linked Issues check⚠️ WarningMost #154 goals are covered, but the summary shows monitoring documentation only, not the required observability wiring.Add the DBOS resume monitoring integration to observability, or split that requirement into a separate follow-up PR.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe title clearly summarizes the DBOS durability productionization work and the resume/activation updates.
Out of Scope Changes check✅ PassedThe changes stay focused on DBOS durability, crash-resume tests, and documentation updates with no clear unrelated scope.
Docstring Coverage✅ PassedNo functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Fix failing CI checks
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/b7-154-dbos-durability

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.

@coderabbitaicoderabbitaiBot 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: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
backend/main.py (1)

85-97: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

init_dbos() failure leaks the events-service worker thread.

init_dbos() is documented to raise when DBOS_ENABLED=true and construction/launch fails. It's called at line 92, after events_service.start_worker() (line 88) but before yield (line 93). If it raises, the generator never reaches yield, so events_service.shutdown() (line 96) and shutdown_dbos() (line 97) never run — the drain thread and any queued rows are abandoned on the failed-startup path, contradicting the "last batch... isn't lost" intent of the shutdown comment.

Reorder so init_dbos() runs before starting the events worker, or wrap the sequence to flush/stop events_service on failure before re-raising.

🔧 Suggested reorder
- from services import events_service- events_service.start_worker()- # ADR 0011 / `#154`: construct + launch DBOS when DBOS_ENABLED=true; no-op- # passthrough otherwise. Fails loudly (raises) if the operator opted in- # and launch fails — see services/durable.py::init_dbos.- init_dbos()+ # ADR 0011 / `#154`: construct + launch DBOS when DBOS_ENABLED=true; no-op+ # passthrough otherwise. Fails loudly (raises) if the operator opted in+ # and launch fails — see services/durable.py::init_dbos. Run before+ # starting the events worker so a failed launch doesn't leak that thread.+ init_dbos()+ from services import events_service+ events_service.start_worker()
yield
🤖 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 `@backend/main.py` around lines 85 - 97, Reorder the startup sequence in the
application lifespan flow so init_dbos() completes before
events_service.start_worker() is called. Preserve the existing yield and
shutdown ordering, ensuring a DBOS initialization failure occurs before the
events worker starts and cannot leak that thread.
🤖 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 `@docs/decisions/0011-durable-execution-dbos.md`:
- Around line 44-50: Update docs/decisions/0011-durable-execution-dbos.md lines
44-50 to describe test_pipeline_identical_with_dbos_on as asserting parity for
category, abstract, and concept names, rather than byte-for-byte output
equality. Update lines 117-122 to state that classifier_agent.run is patched
with an AsyncMock and asserted not called, replacing “unmocked-but-spied”
wording.
- Around line 26-29: Update the `backend/requirements-durable.txt` description
so `dbos>=2.28,<3` is identified as a tested compatible range rather than
pinned; alternatively, change the requirement to an exact `dbos==2.28.0` pin and
retain the pinned wording.
---
Outside diff comments:
In `@backend/main.py`:
- Around line 85-97: Reorder the startup sequence in the application lifespan
flow so init_dbos() completes before events_service.start_worker() is called.
Preserve the existing yield and shutdown ordering, ensuring a DBOS
initialization failure occurs before the events worker starts and cannot leak
that thread.
🪄 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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 22eba8b8-500e-4aef-81fa-affa1d8cc3ac

📥 Commits

Reviewing files that changed from the base of the PR and between 83f5195 and e1f38bb.

📒 Files selected for processing (8)
  • backend/main.py
  • backend/requirements-durable.txt
  • backend/services/durable.py
  • backend/tests/test_dbos_resume.py
  • backend/tests/test_documents_routes.py
  • backend/tests/test_durable_shim.py
  • docs/architecture.md
  • docs/decisions/0011-durable-execution-dbos.md

Comment on lines +26 to +29
3. **`backend/requirements-durable.txt` now exists** (`dbos>=2.28,<3`,
pinned to the version this update's verification ran against). Never
added to `requirements.txt`/`requirements.lock` — durability stays
opt-in and the hermetic suite runs with `dbos` NOT installed.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Do not describe a compatible-version range as pinned.

dbos>=2.28,<3 permits later 2.x releases; it is not pinned to 2.28.0. Either use dbos==2.28.0 or describe this as a tested compatible range.

🤖 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 `@docs/decisions/0011-durable-execution-dbos.md` around lines 26 - 29, Update
the `backend/requirements-durable.txt` description so `dbos>=2.28,<3` is
identified as a tested compatible range rather than pinned; alternatively,
change the requirement to an exact `dbos==2.28.0` pin and retain the pinned
wording.

Comment on lines +44 to +50
`test_pipeline_identical_with_dbos_on` runs
`agents.document.process_document` for real under DBOS
(`SAPLING_MODEL_MODE=function`) and asserts its output matches the
function-mode constants byte-for-byte — proof that wrapping the
pipeline in `@durable_workflow`/`@durable_step` changes durability,
not behavior. Both tests were run against a real `dbos==2.28.0` +
a throwaway Postgres during this update and passed; see the file

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Align the ADR’s test claims with the actual assertions.

  • docs/decisions/0011-durable-execution-dbos.md#L44-L50: describe this as parity for category, abstract, and concept names, or expand the test to compare the complete result; it does not currently prove byte-for-byte output equality.
  • docs/decisions/0011-durable-execution-dbos.md#L117-L122: replace “unmocked-but-spied” with wording that classifier_agent.run is patched with an AsyncMock and asserted not called.
📍 Affects 1 file
  • docs/decisions/0011-durable-execution-dbos.md#L44-L50 (this comment)
  • docs/decisions/0011-durable-execution-dbos.md#L117-L122
🤖 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 `@docs/decisions/0011-durable-execution-dbos.md` around lines 44 - 50, Update
docs/decisions/0011-durable-execution-dbos.md lines 44-50 to describe
test_pipeline_identical_with_dbos_on as asserting parity for category, abstract,
and concept names, rather than byte-for-byte output equality. Update lines
117-122 to state that classifier_agent.run is patched with an AsyncMock and
asserted not called, replacing “unmocked-but-spied” wording.

…doc claims
- /upload/sync now pins the DBOS workflow id to doc:{user_id}:{request_id}
(user-scoped — X-Request-ID is client-supplied), so a client retry
attaches to the SAME workflow: completed -> recorded result, crashed ->
resume at last completed step. Graph merge wrapped as _step_apply_graph
so resume never re-runs the one real side effect.
- init_dbos now RAISES on flag-on-but-preconditions-unmet (missing URL /
failed import) instead of silently degrading an explicit opt-in; shim
tests pin both raise paths.
- Doc corrections: pre-#154 flag-on raised DBOSException per call (502s),
not 'silently nothing'; step-outside-workflow runs the plain function on
dbos 2.28 (not undefined); only database_url is deprecated.
- New opt-in proof test_pipeline_crash_resume_via_workflow_id: real
process_document crash mid-_run_workers -> same-id retry -> classify
step runs EXACTLY once across both phases. All 3 resume tests exercised
green against dbos 2.28 + a real Postgres.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

No issue scored above the posting threshold (five review passes: CLAUDE.md compliance, shallow bug scan, git history + code-comment compliance, prior-PR comments; scored 25–75). All six sub-threshold findings were fixed anyway in cd637c4:

  1. Durability was decorative for the product: production code never assigned a DBOS workflow id, so a client retry started a fresh workflow (re-running every agent step) and background recovery completed workflows nobody consumed — while the graph merge sat unwrapped in the workflow body, re-executed on every resume. Fixed: /upload/sync pins doc:{user_id}:{request_id} via the new services.durable.workflow_id helper (user-scoped — X-Request-ID is client-supplied, an unscoped id would allow cross-user workflow poisoning), and the merge is now a checkpointed _step_apply_graph. A new opt-in test proves the real pipeline resumes at the last completed step across a crash (classify runs exactly once over both phases); all three resume tests exercised green against dbos 2.28 + a real Postgres.

https://github.com/SaplingLearn/Sapling/blob/e1f38bb2c96de78372adba17e59b45048e60f22c/backend/agents/document.py#L150-L162

  1. init_dbos() silently degraded an explicit DBOS_ENABLED=true opt-in to passthrough when DBOS_DATABASE_URL was missing or the import failed, contradicting its own "fail-loud" contract — it now raises RuntimeError naming the failed precondition, with both paths pinned in tests.

3–4. Doc-accuracy: the ADR/docstring claimed the pre-#154 flag-on state "silently did nothing" (it actually raised DBOSException per call → deterministic 502s, per dbos/_core.py's registry guard), and agents/document.py called step-outside-workflow "undefined behavior" (dbos 2.28 deterministically runs the plain function). Both corrected, plus a deprecated-key wording fix.

The remaining pass observation — no pre-merge e2e-lane run reported yet — is the next pipeline stage; the full local cycle (Playwright + oracles under the stack lock) runs before merge and results will be posted here.

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Pre-merge local e2e cycle at cd637c4 under the stack lock (SAPLING_MODEL_MODE=function): make e2e-up clean, Playwright 29/29 passed (1.1m), e2e_oracles exit 0, clean teardown (RESULT up=0 pw=0 oracles=0 down=0). Flag-off no-op confirmed empirically: the lifespan's init_dbos() logged the passthrough line and every journey behaved identically.

@AndresL230
AndresL230 merged commit 9edfcf5 into mainJul 30, 2026
6 of 8 checks passed
@AndresL230
AndresL230 deleted the feat/b7-154-dbos-durability branch July 30, 2026 16:10
Sign up for freeto 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.

[P2] Agent platform: productionize DBOS durability + crash-safe streaming upload

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

feat(durability): productionize DBOS — entrypoint wiring, resume proof, validated procedure (#154) - #474

Merged
AndresL230 merged 2 commits into
mainfrom
feat/b7-154-dbos-durability
Jul 30, 2026
Merged

feat(durability): productionize DBOS — entrypoint wiring, resume proof, validated procedure (#154)#474
AndresL230 merged 2 commits into
mainfrom
feat/b7-154-dbos-durability

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Closes#154. Last item in the agent-migration epic #152.

ADR 0011 shipped the durable shim off + unvalidated: main.py never constructed or launched DBOS (the shim's import-time activation "trusted that DBOS() was called in main.py" — no such call existed), the DBOS_DATABASE_URL precondition was docstring-only, requirements-durable.txt was promised but never created, and neither mode had any test coverage.

What this does

  • services/durable.py: activation now requires DBOS_ENABLED=true + importable dbos + non-empty DBOS_DATABASE_URL (code matches its docstring). New init_dbos()/shutdown_dbos() called from main.py's _lifespan: construct the singleton (system_database_url config — the deprecated database_url key provisions a separate app DB used only by @DBOS.transaction, which we don't use) and DBOS.launch(). Fail-loud contract: any construct/launch failure under an explicit opt-in raises at startup (No startup env validation + silent HMAC-less SESSION_SECRET fallback #174 posture) — a silent passthrough would look durable while resuming nothing. Decorate-before-construct-before-launch verified against dbos==2.28.0 internals (registrations land on a lazily-created global registry the later DBOS.__init__ picks up).
  • requirements-durable.txt: the opt-in extra (dbos>=2.28,<3), deliberately outside requirements.txt/lock.
  • tests/test_durable_shim.py (11 hermetic tests, no dbos needed): passthrough parity + every precondition combination via env-patched reload of a fake dbos module, with pristine-state restore so the already-imported agents.document decorations are untouched.
  • tests/test_dbos_resume.py (opt-in, RUN_DBOS_RESUME=1): subprocess crash/resume proof — step 1 checkpoints, step 2 os._exit(42)s the process, relaunch auto-recovers the PENDING workflow, and step 1's side-effect counter shows exactly one invocation (resume at last completed step). Plus process_document parity under real DBOS in function mode. Exercised twice against dbos 2.28 + a real throwaway Postgres (implementer + an independent re-run): 2 passed in 3.56s.
  • Streaming crash-safety ([P2] Document pipeline robustness: post-result fallback double-fire, empty-OCR persistence, blocking IO, wrong size message #132 scenario): the streaming replay test now also pins zero re-inserts on an X-Request-ID retry after a crash-past-result (agents already pinned as not re-run; the fix(backend): close the #136 contract-audit tail + #340 + #72 #464 guard keeps exactly-one-result).
  • ADR 0011 → accepted (shipped + validated, default off): corrected activation procedure (DBOS.launch() runs system-DB migrations itself and auto-recovers PENDING workflows — the old dbos migrate step was stale), resume monitoring (startup INFO line captured by Logfire per [P2] Observability: activate Logfire for ops/error/LLM tracing #119 + dbos.workflow_status SQL), and the intentional streaming-route asymmetry reaffirmed. DBOS's system schema is DBOS-managed, deliberately outside db/migrations/.

Gates

  • Backend suite: 1480 passed, 31 skipped (was 1469/29; +11 shim tests, +2 opt-in skips), ruff clean.
  • Lock-universe check (pydantic-ai 1.107 scratch venv): all [P2] Agent platform: productionize DBOS durability + crash-safe streaming upload #154-touched areas green; the only failures are pre-existing scratch-venv dep gaps (docling/GOT-OCR/googleapiclient) that reproduce identically on unmodified main.
  • Default posture unchanged: flag off → byte-identical passthrough behavior, e2e lanes unaffected.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added opt-in durable execution for synchronous document uploads, enabling workflow recovery after crashes.
    • Added optional installation support for the durable execution feature.
    • Preserved existing behavior when durable execution is disabled or unavailable.
  • Bug Fixes

    • Improved upload replay handling to prevent duplicate processing, database records, and agent executions.
  • Documentation

    • Clarified durable and non-durable upload paths, recovery behavior, setup, and monitoring.

…f, validated procedure (#154)
ADR 0011 shipped the durable shim off + unvalidated: main.py never
constructed/launched DBOS (the shim 'trusted' an init that didn't exist),
the DBOS_DATABASE_URL precondition was docstring-only, and nothing tested
either mode.
- services/durable.py: enforce the DATABASE_URL precondition at activation;
new init_dbos()/shutdown_dbos() — construct + DBOS.launch() from the
lifespan, fail-loud when the operator explicitly opted in (#174 posture).
Decorate-before-construct-before-launch order verified against
dbos==2.28.0's registry internals.
- main.py: init_dbos() after validate_config(), shutdown_dbos() on teardown.
- requirements-durable.txt: the opt-in extra ADR 0011 promised (dbos>=2.28,<3),
never in requirements.txt/lock.
- tests/test_durable_shim.py: 11 hermetic tests over both modes and every
precondition combination (fake dbos module + reload, pristine restore).
- tests/test_dbos_resume.py: opt-in (RUN_DBOS_RESUME=1) subprocess crash/
resume proof — step1 runs EXACTLY once across an os._exit crash and the
workflow completes on relaunch recovery — plus process_document parity
under real DBOS. Exercised against dbos 2.28 + a real Postgres.
- test_documents_routes.py: streaming replay now also pins zero re-inserts
(the #132 crash-after-result scenario, with the #464 exactly-one-result
guard).
- ADR 0011 → accepted (shipped + validated, default off): corrected
activation procedure (launch() migrates + auto-recovers; 'dbos migrate'
was stale), resume monitoring (startup INFO line -> Logfire per #119,
workflow_status SQL), streaming-asymmetry reaffirmed.
Closes#154
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@supabase

supabaseBot commented Jul 30, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 30, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-stagingcd637c4Commit Preview URL

Branch Preview URL
Jul 30 2026, 04:09 PM

@coderabbitai

coderabbitaiBot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:21 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 3deaa77e-df7d-40e1-85f0-ac20afed8916

📥 Commits

Reviewing files that changed from the base of the PR and between e1f38bb and cd637c4.

📒 Files selected for processing (6)
  • backend/agents/document.py
  • backend/routes/documents.py
  • backend/services/durable.py
  • backend/tests/test_dbos_resume.py
  • backend/tests/test_durable_shim.py
  • docs/decisions/0011-durable-execution-dbos.md
📝 Walkthrough

Walkthrough

DBOS lifecycle handling is added to the FastAPI lifespan, with configuration-gated decorators and opt-in dependency installation. Hermetic tests cover shim modes and failures; subprocess tests cover crash recovery and output parity. Streaming replay tests verify no duplicate document writes, and architecture documentation is updated.

Changes

Durable execution

Layer / File(s)Summary
Durable shim contracts and lifecycle
backend/services/durable.py
DBOS activation now requires the enablement flag and a database URL, with passthrough fallback, explicit initialization, and non-raising shutdown.
FastAPI lifespan integration
backend/main.py, backend/requirements-durable.txt
Application startup launches DBOS before serving requests, shutdown destroys it after the events worker stops, and an opt-in DBOS dependency file is added.
Shim mode and failure validation
backend/tests/test_durable_shim.py
Hermetic tests cover passthrough decorators, degraded configuration, delegated decorators, lifecycle calls, and launch failures.
Resume and replay validation
backend/tests/test_dbos_resume.py, backend/tests/test_documents_routes.py
Opt-in subprocess tests cover checkpoint resume and DBOS output parity; streaming replay tests assert no duplicate document insert.
Architecture and ADR updates
docs/architecture.md, docs/decisions/0011-durable-execution-dbos.md
Documentation records DBOS activation, resume monitoring, streaming-route behavior, replay guarantees, and deferred durability areas.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

  • SaplingLearn/Sapling#67: Introduced the DBOS durable-execution scaffolding extended here with lifecycle initialization and shutdown wiring.

Sequence Diagram(s)

sequenceDiagram
participant FastAPI
participant services.durable
participant DBOS
participant Postgres
FastAPI->>services.durable: init_dbos()
services.durable->>DBOS: construct with system_database_url
DBOS->>Postgres: launch and access DBOS state
FastAPI-->>FastAPI: serve requests
FastAPI->>services.durable: shutdown_dbos()
services.durable->>DBOS: destroy()
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Linked Issues check⚠️ WarningMost #154 goals are covered, but the summary shows monitoring documentation only, not the required observability wiring.Add the DBOS resume monitoring integration to observability, or split that requirement into a separate follow-up PR.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe title clearly summarizes the DBOS durability productionization work and the resume/activation updates.
Out of Scope Changes check✅ PassedThe changes stay focused on DBOS durability, crash-resume tests, and documentation updates with no clear unrelated scope.
Docstring Coverage✅ PassedNo functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Fix failing CI checks
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/b7-154-dbos-durability

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.

@coderabbitaicoderabbitaiBot 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: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
backend/main.py (1)

85-97: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

init_dbos() failure leaks the events-service worker thread.

init_dbos() is documented to raise when DBOS_ENABLED=true and construction/launch fails. It's called at line 92, after events_service.start_worker() (line 88) but before yield (line 93). If it raises, the generator never reaches yield, so events_service.shutdown() (line 96) and shutdown_dbos() (line 97) never run — the drain thread and any queued rows are abandoned on the failed-startup path, contradicting the "last batch... isn't lost" intent of the shutdown comment.

Reorder so init_dbos() runs before starting the events worker, or wrap the sequence to flush/stop events_service on failure before re-raising.

🔧 Suggested reorder
- from services import events_service- events_service.start_worker()- # ADR 0011 / `#154`: construct + launch DBOS when DBOS_ENABLED=true; no-op- # passthrough otherwise. Fails loudly (raises) if the operator opted in- # and launch fails — see services/durable.py::init_dbos.- init_dbos()+ # ADR 0011 / `#154`: construct + launch DBOS when DBOS_ENABLED=true; no-op+ # passthrough otherwise. Fails loudly (raises) if the operator opted in+ # and launch fails — see services/durable.py::init_dbos. Run before+ # starting the events worker so a failed launch doesn't leak that thread.+ init_dbos()+ from services import events_service+ events_service.start_worker()
yield
🤖 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 `@backend/main.py` around lines 85 - 97, Reorder the startup sequence in the
application lifespan flow so init_dbos() completes before
events_service.start_worker() is called. Preserve the existing yield and
shutdown ordering, ensuring a DBOS initialization failure occurs before the
events worker starts and cannot leak that thread.
🤖 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 `@docs/decisions/0011-durable-execution-dbos.md`:
- Around line 44-50: Update docs/decisions/0011-durable-execution-dbos.md lines
44-50 to describe test_pipeline_identical_with_dbos_on as asserting parity for
category, abstract, and concept names, rather than byte-for-byte output
equality. Update lines 117-122 to state that classifier_agent.run is patched
with an AsyncMock and asserted not called, replacing “unmocked-but-spied”
wording.
- Around line 26-29: Update the `backend/requirements-durable.txt` description
so `dbos>=2.28,<3` is identified as a tested compatible range rather than
pinned; alternatively, change the requirement to an exact `dbos==2.28.0` pin and
retain the pinned wording.
---
Outside diff comments:
In `@backend/main.py`:
- Around line 85-97: Reorder the startup sequence in the application lifespan
flow so init_dbos() completes before events_service.start_worker() is called.
Preserve the existing yield and shutdown ordering, ensuring a DBOS
initialization failure occurs before the events worker starts and cannot leak
that thread.
🪄 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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 22eba8b8-500e-4aef-81fa-affa1d8cc3ac

📥 Commits

Reviewing files that changed from the base of the PR and between 83f5195 and e1f38bb.

📒 Files selected for processing (8)
  • backend/main.py
  • backend/requirements-durable.txt
  • backend/services/durable.py
  • backend/tests/test_dbos_resume.py
  • backend/tests/test_documents_routes.py
  • backend/tests/test_durable_shim.py
  • docs/architecture.md
  • docs/decisions/0011-durable-execution-dbos.md

Comment on lines +26 to +29
3. **`backend/requirements-durable.txt` now exists** (`dbos>=2.28,<3`,
pinned to the version this update's verification ran against). Never
added to `requirements.txt`/`requirements.lock` — durability stays
opt-in and the hermetic suite runs with `dbos` NOT installed.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Do not describe a compatible-version range as pinned.

dbos>=2.28,<3 permits later 2.x releases; it is not pinned to 2.28.0. Either use dbos==2.28.0 or describe this as a tested compatible range.

🤖 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 `@docs/decisions/0011-durable-execution-dbos.md` around lines 26 - 29, Update
the `backend/requirements-durable.txt` description so `dbos>=2.28,<3` is
identified as a tested compatible range rather than pinned; alternatively,
change the requirement to an exact `dbos==2.28.0` pin and retain the pinned
wording.

Comment on lines +44 to +50
`test_pipeline_identical_with_dbos_on` runs
`agents.document.process_document` for real under DBOS
(`SAPLING_MODEL_MODE=function`) and asserts its output matches the
function-mode constants byte-for-byte — proof that wrapping the
pipeline in `@durable_workflow`/`@durable_step` changes durability,
not behavior. Both tests were run against a real `dbos==2.28.0` +
a throwaway Postgres during this update and passed; see the file

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Align the ADR’s test claims with the actual assertions.

  • docs/decisions/0011-durable-execution-dbos.md#L44-L50: describe this as parity for category, abstract, and concept names, or expand the test to compare the complete result; it does not currently prove byte-for-byte output equality.
  • docs/decisions/0011-durable-execution-dbos.md#L117-L122: replace “unmocked-but-spied” with wording that classifier_agent.run is patched with an AsyncMock and asserted not called.
📍 Affects 1 file
  • docs/decisions/0011-durable-execution-dbos.md#L44-L50 (this comment)
  • docs/decisions/0011-durable-execution-dbos.md#L117-L122
🤖 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 `@docs/decisions/0011-durable-execution-dbos.md` around lines 44 - 50, Update
docs/decisions/0011-durable-execution-dbos.md lines 44-50 to describe
test_pipeline_identical_with_dbos_on as asserting parity for category, abstract,
and concept names, rather than byte-for-byte output equality. Update lines
117-122 to state that classifier_agent.run is patched with an AsyncMock and
asserted not called, replacing “unmocked-but-spied” wording.

…doc claims
- /upload/sync now pins the DBOS workflow id to doc:{user_id}:{request_id}
(user-scoped — X-Request-ID is client-supplied), so a client retry
attaches to the SAME workflow: completed -> recorded result, crashed ->
resume at last completed step. Graph merge wrapped as _step_apply_graph
so resume never re-runs the one real side effect.
- init_dbos now RAISES on flag-on-but-preconditions-unmet (missing URL /
failed import) instead of silently degrading an explicit opt-in; shim
tests pin both raise paths.
- Doc corrections: pre-#154 flag-on raised DBOSException per call (502s),
not 'silently nothing'; step-outside-workflow runs the plain function on
dbos 2.28 (not undefined); only database_url is deprecated.
- New opt-in proof test_pipeline_crash_resume_via_workflow_id: real
process_document crash mid-_run_workers -> same-id retry -> classify
step runs EXACTLY once across both phases. All 3 resume tests exercised
green against dbos 2.28 + a real Postgres.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

No issue scored above the posting threshold (five review passes: CLAUDE.md compliance, shallow bug scan, git history + code-comment compliance, prior-PR comments; scored 25–75). All six sub-threshold findings were fixed anyway in cd637c4:

  1. Durability was decorative for the product: production code never assigned a DBOS workflow id, so a client retry started a fresh workflow (re-running every agent step) and background recovery completed workflows nobody consumed — while the graph merge sat unwrapped in the workflow body, re-executed on every resume. Fixed: /upload/sync pins doc:{user_id}:{request_id} via the new services.durable.workflow_id helper (user-scoped — X-Request-ID is client-supplied, an unscoped id would allow cross-user workflow poisoning), and the merge is now a checkpointed _step_apply_graph. A new opt-in test proves the real pipeline resumes at the last completed step across a crash (classify runs exactly once over both phases); all three resume tests exercised green against dbos 2.28 + a real Postgres.

https://github.com/SaplingLearn/Sapling/blob/e1f38bb2c96de78372adba17e59b45048e60f22c/backend/agents/document.py#L150-L162

  1. init_dbos() silently degraded an explicit DBOS_ENABLED=true opt-in to passthrough when DBOS_DATABASE_URL was missing or the import failed, contradicting its own "fail-loud" contract — it now raises RuntimeError naming the failed precondition, with both paths pinned in tests.

3–4. Doc-accuracy: the ADR/docstring claimed the pre-#154 flag-on state "silently did nothing" (it actually raised DBOSException per call → deterministic 502s, per dbos/_core.py's registry guard), and agents/document.py called step-outside-workflow "undefined behavior" (dbos 2.28 deterministically runs the plain function). Both corrected, plus a deprecated-key wording fix.

The remaining pass observation — no pre-merge e2e-lane run reported yet — is the next pipeline stage; the full local cycle (Playwright + oracles under the stack lock) runs before merge and results will be posted here.

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Pre-merge local e2e cycle at cd637c4 under the stack lock (SAPLING_MODEL_MODE=function): make e2e-up clean, Playwright 29/29 passed (1.1m), e2e_oracles exit 0, clean teardown (RESULT up=0 pw=0 oracles=0 down=0). Flag-off no-op confirmed empirically: the lifespan's init_dbos() logged the passthrough line and every journey behaved identically.

@AndresL230
AndresL230 merged commit 9edfcf5 into mainJul 30, 2026
6 of 8 checks passed
@AndresL230
AndresL230 deleted the feat/b7-154-dbos-durability branch July 30, 2026 16:10
Sign up for freeto 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.

[P2] Agent platform: productionize DBOS durability + crash-safe streaming upload

1 participant

@AndresL230