Skip to content

feat(sequences): optimistic concurrency for step branch writes - #248

Draft
Ahmustufa wants to merge 6 commits into
mainfrom
feature/branch-write-precondition
Draft

Ahmustufa wants to merge 6 commits into
mainfrom
feature/branch-write-precondition

Conversation

@Ahmustufa

Copy link
Copy Markdown
Contributor

Stacked on #242 (branching). Merge that first; this PR's base then moves to main.

Closes the cross-tab last-writer-wins gap that the #245 review found.

Contract

  • PUT /campaigns/{id}/steps/{stepId}/branch, optional expected_updated_at:
    • absent: last writer wins, as today (API clients and agents are unaffected);
    • null: create-only;
    • a timestamp: replace only if unchanged.
  • DELETE: optional expected_updated_at query param.
  • 409 { error, code: "branch_changed", current: StepBranch | null }.
  • 400 for a malformed token, including sub-microsecond digits, which are refused rather than rounded.
  • StepBranch.updated_at now carries microseconds. Clients must echo it verbatim, because a JS Date round trip truncates it to milliseconds.

Latent bugs fixed

  • updated_at was serialized to whole seconds, so it could never match exactly.

  • updated_at didn't always advance:

    • now() is the transaction start time, taken before the lock;
    • two writes in one transaction got the same value;
    • ON DELETE SET NULL left it untouched.

    A trigger now makes it strictly increase.

Verification

  • Go: build, vet (plus -tags=integration), golangci-lint v2.12.2, and unit and integration tests. The integration tests cover:
    • matching, stale, create-if-absent races and stale delete;
    • cross-workspace 404 in every mode;
    • updated_at advancing across 20 writes, within one transaction, and on SET NULL.
  • Mutation checks: making the store ignore the precondition, swapping the trigger for now(), and reverting the handler to whole seconds each make a test fail.
  • lint:api is valid.

The frontend side (sending the token, handling 409) lands in #245.

🤖 Generated with Claude Code

Ahmustufa and others added 5 commits September 24, 2026 00:59
A step can now carry one branch that routes each enrollment after the step
is sent: always / opened / clicked / replied / not_opened / not_replied,
within N days (1-90), optionally narrowed to a reply label, with a yes exit
and a no exit (a null exit ends the path). Steps without a branch keep the
original linear code path unchanged, and a test pins that.

- New table sequence_step_branches, one row per source step. Composite FKs
  keep both exits in the same campaign and tenant. Deleting a target step
  nulls only that exit.
- The route is recomputed from events inside a fixed window measured from
  the step's own send, so a decided answer can't flip. Opens and clicks
  count human events only. Replies look at inbox_messages (the other leg)
  and exclude auto-replies unless the branch names that label.
- Cycles are refused at save time under a per-campaign lock, including for
  step delete and reorder. A runtime backstop ends the path if a loop ever
  gets in.
- Mid-flight edits use the graph as it is when the decision is made. The
  rule is documented in branchroute.go.
- GET /campaigns/{id}/graph, PUT/DELETE /campaigns/{id}/steps/{stepId}/branch.
- Threading replies to the most recently sent email, not the
  highest-numbered step, since a path can go 1 -> 3 -> 2.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Reply branches (product decision: stop-on-reply stays, so labels win):
- A reply branch on a label that stops the sequence is refused at save
  (400 reply_label_stops_sequence). The API says every default human label
  stops, so a default-label "replied" branch never fires. The replacement
  tests go through the real poll -> classify -> dispatch path.
- A reply nudge only pulls forward a due time that a condition wait set,
  never an out-of-office deferral.

Routing:
- The loop backstop compares against the current step's own send row, not
  enrollment.last_sent_at, which a recover-forward re-stamps.
- Paused and done campaigns neither finish nor park enrollments; the
  status gate now runs before routing.
- opened/clicked/not_opened are refused without tracking or an HTML body
  (400 tracking_required).
- Condition waits no longer count as "deferred" sends.
- Only a real miss is a 404.
- LatestSentForContact is workspace-pinned.
- New test: a routed send still honours suppression.

Migration: the inbox_threads index is its own single-statement
CONCURRENTLY migration (asserted indisvalid on a scratch DB), so the
branching migration no longer blocks the send path.

Docs: Postgres 15+ minimum; mid-flight edit rules corrected; invariant 82
notes that reply evidence is thread-scoped.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
main now has the audit log at 82 and inbox search at 83 (#252).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…lds can't deadlock

golang-migrate's pgx5 driver waits for its advisory lock inside a
running statement, which holds a snapshot. CREATE INDEX CONCURRENTLY
waits for every snapshot, so a second migrator waiting on the lock
deadlocks the build, and the migration is left dirty. This reproduced 3
of 3 times with 6 concurrent migrators on a fresh DB, and it hits both
CI (-p 4) and multi-replica deploys.

Migrate, MigrateDown, MigrateTo and Version now take a separate
session-level lock by polling pg_try_advisory_lock on a dedicated
connection. A waiter is idle between tries and holds no snapshot. The
wait is bounded (15 min, ErrMigrationLockTimeout), and unlock/close run
on a detached context.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@Ahmustufa
Ahmustufa force-pushed the feature/sequence-branching branch from aca19dd to 940b1af Compare September 23, 2026 20:21
PUT /branch takes an optional expected_updated_at: absent means last
writer wins (as before), null means create-only, and a timestamp means
replace only if unchanged. DELETE takes it as a query param. A mismatch
returns 409 branch_changed with the current branch (or null). It is
enforced atomically in SQL under the existing per-campaign graph lock.

Two latent bugs made the token unusable, and both are fixed:
- updated_at was serialized to whole seconds (RFC3339), so it could
  never match exactly. It is now RFC3339Nano (microseconds).
- updated_at did not always advance. now() is the transaction start
  (taken before the lock), two writes in one tx collided, and ON DELETE
  SET NULL left it untouched. A trigger now sets
  greatest(clock_timestamp(), old + 1us).

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

Copy link
Copy Markdown
Contributor Author

Retargeted to main, so it can't be stranded on a merged parent the way #241, #243 and #246 were. It's now a draft: merge after #242. Once #242 lands, I'll rebase this so its diff is only the precondition commit.

@Ahmustufa
Ahmustufa marked this pull request as draft September 23, 2026 20:27
@Ahmustufa

Copy link
Copy Markdown
Contributor Author

Retargeted to main, so it can't be stranded on a merged parent the way #241, #243 and #246 were. It's now a draft: merge after #242. Once #242 lands, I'll rebase this so its diff is only the precondition commit.

@Ahmustufa
Ahmustufa changed the base branch from feature/sequence-branching to main September 23, 2026 20:27
Sign up for free to 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.

1 participant