Skip to content

feat(harness): SQLite durability for session state and event replay - #6

Merged
ranvier2d2 merged 5 commits into
mainfrom
feat/harness-sqlite-durability
Mar 25, 2026
Merged

feat(harness): SQLite durability for session state and event replay#6
ranvier2d2 merged 5 commits into
mainfrom
feat/harness-sqlite-durability

Conversation

@ranvier2d2

@ranvier2d2 ranvier2d2 commented Mar 24, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Harness.Storage — new GenServer wrapping Exqlite with 2 SQLite tables (harness_events + harness_sessions), WAL mode, auto-migrations
  • SnapshotServer durability — recovers sessions + sequence from SQLite on init, persists on each apply_event, falls back to SQL for replay_since when WAL ring buffer is empty
  • Graceful degradation — always broadcasts even if SQL write fails (in-memory authoritative during runtime, SQL is recovery path)
  • 16 new tests — event CRUD, session upsert, JSON roundtrip, dedup, and 2 integration tests (recovery after restart + SQL replay fallback)

Verified manually

[info] Storage opened at priv/data/harness.db
[info] SnapshotServer recovered 17 sessions, seq=51

Session created → harness killed → harness restarted → session visible in snapshot with correct status.

Architecture

Provider GenServer
  → emit_event → SessionManager → SnapshotServer.apply_event
                                    ├── Projector.project (in-memory)
                                    ├── Storage.insert_event (SQLite)
                                    ├── Storage.upsert_session (SQLite)
                                    ├── WAL ring buffer (hot cache)
                                    └── PubSub broadcast (unchanged)

On restart:
  Storage.get_all_sessions() → rebuild Snapshot
  Storage.get_max_sequence() → restore sequence counter
  WAL starts empty → replay_since falls back to SQL

Known gap (Phase 1)

After recovery, sessions with status running in SQL have no live GenServer. The snapshot shows "running" but the process is dead. Phase 1 adds reconciliation on boot.

Test plan

  • mix compile --warnings-as-errors — clean
  • mix test test/harness/ — 50 tests, 0 failures (16 new + 34 existing)
  • mix credo — clean on new files
  • Manual: start harness → create session → kill BEAM → restart → snapshot shows recovered sessions

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • On-disk persistence of sessions and events with automatic recovery on restart; replay falls back to stored events when in-memory history is insufficient.
  • Tests

    • Added tests for storage, recovery, replay behavior, and duplicate-event handling.
  • Documentation

    • README tables reformatted for improved readability.
  • Chores

    • Local database files ignored; added SQLite-based storage dependency; CI workflow jobs trimmed.

Phase 0.5: minimal viable durability. BEAM restart no longer loses
session state or event history.

New: Harness.Storage GenServer wrapping Exqlite with 2 tables:
- harness_events: append-only event log with global_sequence
- harness_sessions: materialized session projection

Modified: SnapshotServer
- Recovers sessions + sequence from SQLite on init
- Persists events + session state to SQLite on each apply_event
- Falls back to SQLite for replay_since when WAL ring buffer
  cannot serve the request (e.g., after restart)
- Graceful degradation: broadcasts even if SQL write fails

Modified: Application supervision tree
- Storage starts before SnapshotServer (dependency order)

16 new tests (13 unit + 2 integration + 1 JSON roundtrip):
- Event insert/replay/dedup, session upsert/recovery
- SnapshotServer recovery: stop → restart → verify state preserved
- Replay fallback: restart (WAL empty) → replay_since(0) from SQL

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Mar 24, 2026

Copy link
Copy Markdown

Caution

Review failed

Pull request was closed or merged during review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 6b50a45b-9685-438e-a140-78ad0234b612

📥 Commits

Reviewing files that changed from the base of the PR and between 471cb47 and 7fcc24a.

📒 Files selected for processing (1)
  • .github/workflows/pr-size.yml
💤 Files with no reviewable changes (1)
  • .github/workflows/pr-size.yml

📝 Walkthrough

Walkthrough

Adds a new SQLite-backed persistence GenServer (Harness.Storage), wires it into the supervision tree, updates Harness.SnapshotServer to persist events and recover/replay from storage, adds tests for storage and recovery, and updates .gitignore and mix deps for the DB driver.

Changes

Cohort / File(s) Summary
Config & deps
apps/harness/.gitignore, apps/harness/mix.exs
Added priv/data/*.db* to .gitignore and added {:exqlite, "~> 0.27"} dependency.
Storage implementation
apps/harness/lib/harness/storage.ex
New Harness.Storage GenServer: opens Exqlite DB with WAL/PRAGMAs, runs migrations, serializes access, provides API to insert events (duplicate detection), upsert sessions, read sessions, replay events, get max sequence/count, and test-only reset; closes DB on terminate.
Supervision wiring
apps/harness/lib/harness/application.ex
Added Harness.Storage to application children so storage starts under the supervisor.
Snapshot server persistence & recovery
apps/harness/lib/harness/snapshot_server.ex
Startup recovery from storage to rebuild snapshot and initialize sequence; changed start state to nil; apply-event now persists events and conditionally upserts sessions; replay_since falls back to storage when WAL cannot satisfy requests; added helpers and logging.
Tests
apps/harness/test/harness/storage_test.exs
New test module covering in-memory and file-backed storage: insert/replay/duplicate detection, counts/max sequence, session upsert/JSON roundtrips, and SnapshotServer recovery/integration tests.
Docs formatting
README.md
Table formatting and alignment changes only (presentation-only edits).
CI workflows
.github/workflows/ci.yml, .github/workflows/pr-size.yml
Removed explicit display name for quality job; removed sync-label-definitions job from PR-size workflow (no runtime code changes).

Sequence Diagram(s)

sequenceDiagram
    participant SnapshotServer
    participant StorageGenServer as Storage GenServer
    participant SQLiteDB as SQLite DB
    participant WALBuffer as WAL Buffer

    rect rgba(100,200,150,0.5)
    Note over SnapshotServer,SQLiteDB: Startup Recovery
    SnapshotServer->>StorageGenServer: recover_from_storage()
    StorageGenServer->>SQLiteDB: SELECT sessions
    SQLiteDB-->>StorageGenServer: session rows
    StorageGenServer->>SQLiteDB: SELECT MAX(global_sequence)
    SQLiteDB-->>StorageGenServer: max_seq
    StorageGenServer-->>SnapshotServer: sessions + max_seq
    SnapshotServer->>WALBuffer: initialize with max_seq
    SnapshotServer->>SnapshotServer: rebuild Snapshot state
    end

    rect rgba(100,150,200,0.5)
    Note over SnapshotServer,SQLiteDB: Event Apply & Persistence
    SnapshotServer->>SnapshotServer: apply_event()
    SnapshotServer->>StorageGenServer: insert_event(event_map)
    StorageGenServer->>SQLiteDB: INSERT event row
    SQLiteDB-->>StorageGenServer: :ok
    SnapshotServer->>StorageGenServer: upsert_session(session_map)
    StorageGenServer->>SQLiteDB: INSERT ... ON CONFLICT session row
    SQLiteDB-->>StorageGenServer: :ok
    StorageGenServer-->>SnapshotServer: :ok
    end

    rect rgba(200,150,100,0.5)
    Note over SnapshotServer,SQLiteDB: Replay on Cache Miss
    SnapshotServer->>WALBuffer: replay_since(after_seq)
    alt WAL hit
        WALBuffer-->>SnapshotServer: events
    else WAL miss/empty/evicted
        SnapshotServer->>StorageGenServer: replay_since(after_seq)
        StorageGenServer->>SQLiteDB: SELECT events WHERE global_sequence > after_seq
        SQLiteDB-->>StorageGenServer: event rows
        StorageGenServer-->>SnapshotServer: events
    end
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐰✨ I dug a tiny SQLite bed,

where sequences sleep and rows are fed.
On boot I peek, stitch sessions anew,
persist each hop, replay what is due.
A rabbit claps: your history grew.

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately and clearly summarizes the main change: adding SQLite-backed durability for session state and event replay, which is the primary objective across all modified files.
Description check ✅ Passed The description comprehensively explains what changed (Storage GenServer, SnapshotServer durability, graceful degradation, tests), why it matters (recovery, durability), includes architecture diagrams, test results, and manual verification—meeting all template requirements.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/harness-sqlite-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 and usage tips.

@github-actions github-actions Bot added size:XL vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. labels Mar 24, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

Caution

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

⚠️ Outside diff range comments (2)
apps/harness/lib/harness/snapshot_server.ex (2)

98-121: ⚠️ Potential issue | 🔴 Critical

Fallback replay must not report success unless it returned the full gap.

The SQL path calls Harness.Storage.replay_since(after_seq) with its default 500-row limit and also turns storage failures into {:ok, seq, []}. Once a client is more than 500 events behind—or SQLite is unavailable—you still reply with currentSeq == seq even though the client is missing events. That also bypasses the full-sync path in apps/harness/lib/harness_web/harness_channel.ex that currently depends on {:gap, ...}.

Also applies to: 248-260

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/harness/lib/harness/snapshot_server.ex` around lines 98 - 121, The
fallback SQL path in handle_call ({:replay_since, after_seq}) and the helper
replay_from_sql_or_empty must not return {:ok, seq, []} when the storage call is
limited (default 500 rows) or fails; instead detect when
Harness.Storage.replay_since(after_seq) returns an error or returns fewer events
than needed to fill the gap (i.e., rows < seq - after_seq) and reply with a gap
tuple (for example {:gap, seq, after_seq}) so the caller will trigger a full
sync; update replay_from_sql_or_empty to call
Harness.Storage.replay_since(after_seq), check for {:ok, events} length vs
expected gap and for {:error, _} and return {:gap, seq, after_seq} in those
cases, otherwise return {:ok, seq, events} as before.

127-135: ⚠️ Potential issue | 🔴 Critical

A failed SQLite insert can cause sequence reuse after the next restart.

new_seq is assigned and broadcast before the durable insert is known to have succeeded, but init/1 later restores seq from Harness.Storage.get_max_sequence/0. If one insert fails and a later insert succeeds, SQLite lags the public sequence, and the next boot can hand out sequence numbers that clients already saw for different events.

Also applies to: 170-183, 201-239

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/harness/lib/harness/snapshot_server.ex` around lines 127 - 135, The code
currently increments and broadcasts new_seq in handle_cast ({:apply_event,
event}) before the SQLite insert, which can cause sequence reuse because init/1
restores seq from Harness.Storage.get_max_sequence/0; change the flow so the
durable persist to SQLite is completed and confirmed before exposing or
advancing the in-memory sequence: have persist (or a new function) accept the
event and desired seq, perform the insert and return {:ok, seq} or {:error,
reason}, only then set new_seq/seq, update snapshot (Projector.project), build
event_map (event_to_map), and broadcast; on persist failure do not advance seq
or broadcast and handle retry/logging. Ensure related code paths referenced at
lines noted (other handle_cast blocks and init/1 using
Harness.Storage.get_max_sequence/0) follow the same pattern so durable state is
the source of truth.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/harness/lib/harness/storage.ex`:
- Around line 14-15: The file currently sets a cwd-relative module attribute
`@default_db_path` = "priv/data/harness.db"; change this to compute the priv path
for the :harness app at runtime so the DB always lives under the app's priv
directory (e.g. use :code.priv_dir(:harness) or Application.app_dir/2 and
Path.join to build "priv/data/harness.db"). Update the single `@default_db_path`
definition and any other places (the occurrences around the block referenced at
117-127) to use the same app-resolved path so the DB location is consistent and
matches the harness/.gitignore pattern.

In `@apps/harness/test/harness/storage_test.exs`:
- Around line 256-257: Replace the timing-based Process.sleep(50) after casting
apply_event/1 by using a deterministic sync barrier: after calling the cast
(apply_event/1) call _ = :sys.get_state(SnapshotServer) to ensure the
SnapshotServer has processed prior messages before continuing; likewise replace
the other sleep at the later location with the same pattern, and where waiting
for process termination was done via Process.alive?/1 switch to using
Process.monitor/1 and assert_receive the :DOWN message to wait for shutdown.
- Around line 37-63: The test setup currently starts replacement processes with
Storage.start_link/1 and SnapshotServer.start_link/1, which makes the test
responsible for cleanup; replace those calls with start_supervised!/1 so ExUnit
takes ownership and automatically cleans them up (e.g., call
start_supervised!({Storage, db_path: ":memory:"}) and similarly for
SnapshotServer) after you stop the application-managed children via
Supervisor.terminate_child(Harness.Supervisor, Storage) and SnapshotServer; keep
the on_exit logic that restarts the application children but remove manual
GenServer.stop of the test-owned pids since start_supervised!/1 handles
shutdown. Also apply the same replacement for the second setup block around
lines 196-229.

---

Outside diff comments:
In `@apps/harness/lib/harness/snapshot_server.ex`:
- Around line 98-121: The fallback SQL path in handle_call ({:replay_since,
after_seq}) and the helper replay_from_sql_or_empty must not return {:ok, seq,
[]} when the storage call is limited (default 500 rows) or fails; instead detect
when Harness.Storage.replay_since(after_seq) returns an error or returns fewer
events than needed to fill the gap (i.e., rows < seq - after_seq) and reply with
a gap tuple (for example {:gap, seq, after_seq}) so the caller will trigger a
full sync; update replay_from_sql_or_empty to call
Harness.Storage.replay_since(after_seq), check for {:ok, events} length vs
expected gap and for {:error, _} and return {:gap, seq, after_seq} in those
cases, otherwise return {:ok, seq, events} as before.
- Around line 127-135: The code currently increments and broadcasts new_seq in
handle_cast ({:apply_event, event}) before the SQLite insert, which can cause
sequence reuse because init/1 restores seq from
Harness.Storage.get_max_sequence/0; change the flow so the durable persist to
SQLite is completed and confirmed before exposing or advancing the in-memory
sequence: have persist (or a new function) accept the event and desired seq,
perform the insert and return {:ok, seq} or {:error, reason}, only then set
new_seq/seq, update snapshot (Projector.project), build event_map
(event_to_map), and broadcast; on persist failure do not advance seq or
broadcast and handle retry/logging. Ensure related code paths referenced at
lines noted (other handle_cast blocks and init/1 using
Harness.Storage.get_max_sequence/0) follow the same pattern so durable state is
the source of truth.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 9b22e2ec-582f-405a-8956-55b53e75e25a

📥 Commits

Reviewing files that changed from the base of the PR and between f17f336 and f32e74a.

⛔ Files ignored due to path filters (1)
  • apps/harness/mix.lock is excluded by !**/*.lock
📒 Files selected for processing (6)
  • apps/harness/.gitignore
  • apps/harness/lib/harness/application.ex
  • apps/harness/lib/harness/snapshot_server.ex
  • apps/harness/lib/harness/storage.ex
  • apps/harness/mix.exs
  • apps/harness/test/harness/storage_test.exs

Comment on lines +14 to +15
@default_db_path "priv/data/harness.db"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Resolve the default DB path via the harness app, not the process working directory.

"priv/data/harness.db" is cwd-relative, so this DB lands in different places depending on where the BEAM is started. In this umbrella layout that also misses the new ignore rule in apps/harness/.gitignore, which only covers apps/harness/priv/data/*.db*.

Suggested fix
-  `@default_db_path` "priv/data/harness.db"
+  `@default_db_path` nil
...
   defp resolve_db_path(opts) do
     cond do
       opts[:db_path] ->
         opts[:db_path]

       config_path = get_in(Application.get_env(:harness, __MODULE__, []), [:db_path]) ->
         config_path

       true ->
-        `@default_db_path`
+        Application.app_dir(:harness, "priv/data/harness.db")
     end
   end

Also applies to: 117-127

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/harness/lib/harness/storage.ex` around lines 14 - 15, The file currently
sets a cwd-relative module attribute `@default_db_path` = "priv/data/harness.db";
change this to compute the priv path for the :harness app at runtime so the DB
always lives under the app's priv directory (e.g. use :code.priv_dir(:harness)
or Application.app_dir/2 and Path.join to build "priv/data/harness.db"). Update
the single `@default_db_path` definition and any other places (the occurrences
around the block referenced at 117-127) to use the same app-resolved path so the
DB location is consistent and matches the harness/.gitignore pattern.

Comment on lines +37 to +63
setup do
# The Application supervisor starts Storage. We need to stop it
# and restart with :memory: for isolated tests.
case Process.whereis(Storage) do
nil -> :ok
_pid -> Supervisor.terminate_child(Harness.Supervisor, Storage)
end

# Also stop SnapshotServer since it depends on Storage
case Process.whereis(SnapshotServer) do
nil -> :ok
_pid -> Supervisor.terminate_child(Harness.Supervisor, SnapshotServer)
end

# Start fresh in-memory Storage
{:ok, _} = Storage.start_link(db_path: ":memory:")

on_exit(fn ->
# Restart the Application-managed versions
case Process.whereis(Storage) do
nil -> :ok
pid -> GenServer.stop(pid)
end

Supervisor.restart_child(Harness.Supervisor, Storage)
Supervisor.restart_child(Harness.Supervisor, SnapshotServer)
end)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Start the test-owned processes with start_supervised!/1.

These setups replace app-global Harness.Storage and Harness.SnapshotServer instances with bare start_link/1, which pushes process ownership/cleanup into the test logic. Please switch the replacement processes to start_supervised!/1 once the application-managed children have been stopped.

As per coding guidelines, apps/harness/**/*_test.exs: Always use start_supervised!/1 to start processes in tests as it guarantees cleanup between tests.

Also applies to: 196-229

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/harness/test/harness/storage_test.exs` around lines 37 - 63, The test
setup currently starts replacement processes with Storage.start_link/1 and
SnapshotServer.start_link/1, which makes the test responsible for cleanup;
replace those calls with start_supervised!/1 so ExUnit takes ownership and
automatically cleans them up (e.g., call start_supervised!({Storage, db_path:
":memory:"}) and similarly for SnapshotServer) after you stop the
application-managed children via Supervisor.terminate_child(Harness.Supervisor,
Storage) and SnapshotServer; keep the on_exit logic that restarts the
application children but remove manual GenServer.stop of the test-owned pids
since start_supervised!/1 handles shutdown. Also apply the same replacement for
the second setup block around lines 196-229.

Comment on lines +256 to +257
# Give cast time to process
Process.sleep(50)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Replace the fixed sleeps with a GenServer sync barrier.

Both waits are timing-based. Since apply_event/1 is a cast, a busy CI runner can still be behind after 50ms. _ = :sys.get_state(SnapshotServer) right after the casts gives you a deterministic “all prior messages handled” barrier before reading the snapshot or restarting the server.

As per coding guidelines, apps/harness/**/*_test.exs: Avoid Process.sleep/1 and Process.alive?/1 in tests; use Process.monitor/1 and assert on the DOWN message to wait for process completion, and In tests, use _ = :sys.get_state/1 to ensure the process has handled prior messages instead of sleeping to synchronize before the next call.

Also applies to: 290-290

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/harness/test/harness/storage_test.exs` around lines 256 - 257, Replace
the timing-based Process.sleep(50) after casting apply_event/1 by using a
deterministic sync barrier: after calling the cast (apply_event/1) call _ =
:sys.get_state(SnapshotServer) to ensure the SnapshotServer has processed prior
messages before continuing; likewise replace the other sleep at the later
location with the same pattern, and where waiting for process termination was
done via Process.alive?/1 switch to using Process.monitor/1 and assert_receive
the :DOWN message to wait for shutdown.

@ranvier2d2
ranvier2d2 merged commit 99c3734 into main Mar 25, 2026
7 of 8 checks passed
@ranvier2d2
ranvier2d2 deleted the feat/harness-sqlite-durability branch March 25, 2026 16:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XL vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant