Skip to content

feat(jobs): per-campaign and per-remote-operation locks - #177

Merged
TheAggressive merged 2 commits into
masterfrom
feat/locks
Oct 11, 2026
Merged

TheAggressive merged 2 commits into
masterfrom
feat/locks

Conversation

@TheAggressive

@TheAggressive TheAggressive commented Oct 10, 2026 •

Copy link
Copy Markdown
Owner

Closes #169. Part of #66 (M5). Rebased onto master after #176 merged. It also carries #178 (the documentation pass), which was merged into this branch while it was #178's base, so landing this PR brings both to master.

What this adds

  • Schema 8: {prefix}campaignbridge_locks, behind a Lock_Source port.
    • The primary key admits one holder.
    • Taking over an expired lock is a conditional update with one winner.
    • Only the holder can release, so a process that ran past its expiry can't free a lock someone else now holds.
  • Workflow/Lock/Lock_Manager. Holds the lock for the whole operation, provider calls included, and releases it in finally.
    • When the lock is held elsewhere, the work doesn't run. The caller gets the new locked workflow error: REST 409 campaignbridge_campaign_locked with retry_after, the seconds until the lock expires.
    • Abandoned locks expire after 5 minutes. The takeover is audited as campaign_lock_takeover or provider_lock_takeover.
  • Campaign lock around draft handoff, test send, schedule, unschedule, send, and reconcile. It covers the draft re-assertion before delivery, which also writes to Mailchimp.
  • Remote-operation lock around discovery refreshes, per account and list. An overlapping refresh returns the cached list with a retryable mailchimp_refresh_in_progress instead of a second provider call.
  • Timeline: a takeover reads "Recovered an interrupted operation (Interrupted: send)".
  • The workflows take the lock manager as an optional last constructor dependency, and the production factories always pass it. Existing tests that build workflows directly are unchanged.

Evidence (#169)

  • Campaign_Schedule_Route_Test: while a background worker holds the campaign, send, schedule, test send, and reconcile each return 409 locked with retry_after of 300 seconds or less, and no request reaches Mailchimp. After release, the send goes through with exactly one action and releases its own lock.
  • Lock_Manager_Test:
    • the second holder is refused, with the remaining wait;
    • different campaigns don't block each other;
    • the lock is released even when the work throws;
    • only the owner releases;
    • an abandoned lock is taken over and audited with the interrupted operation;
    • racing takeovers have one winner.
  • Provider_Discovery_Routes_Test: a refresh run while another holds the lock makes no Mailchimp call and returns the cached list with a retryable refusal.
  • Migration: schema 7 → 8 keeps queued jobs, and re-runs are idempotent. The index check covers locks.
  • Jest: the timeline label and the interrupted-operation detail.
  • SQLite (Studio): a probe ran insert collision, held_until, takeover with the interrupted purpose, one winner of a racing takeover, and owner-only release. Studio was re-stamped to schema 6 afterwards.

Verification

  • pnpm qa:fast passes.
  • Unit (1126), integration (458), security (117), accessibility (25), and performance (9) suites: all pass.

🤖 Generated with Claude Code

Base automatically changed from feat/durable-jobs to master October 11, 2026 10:32
@github-actions github-actions Bot added area:frontend Frontend source or Node tooling area:php PHP or Composer code area:tests Automated tests or test configuration risk:high Security or release sensitive; never auto-merged type:feature New capability needs-attention Automation stopped; maintainer action required labels Oct 11, 2026
Two workers, or a worker and an operator, can no longer act on the same
campaign or the same provider list at once.

- Schema 8 adds {prefix}campaignbridge_locks. Acquiring inserts the name,
  so the primary key admits one holder; taking over an expired lock is a
  conditional update with one winner; only the holder can release.
- Lock_Manager holds the lock for a whole operation, provider calls
  included, and releases it in finally. A caller that finds it held does
  nothing and gets the new `locked` workflow error (REST 409) with
  retry_after, the seconds until the lock expires. A lock left by a process
  that stopped expires after five minutes and the takeover is audited.
- Draft handoff, test send, schedule, unschedule, send, and reconcile hold
  the campaign lock; a discovery refresh holds a lock per account and list
  and, when one is already running, returns the cached list with a
  retryable refusal instead of calling the provider again.
- The campaign timeline labels a takeover "Recovered an interrupted
  operation" with the operation that stopped.

Verified on MySQL and on the Studio SQLite site.

Closes #169

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions github-actions Bot added area:email Email compiler or campaign workflow area:providers Provider integrations or delivery boundaries and removed area:tests Automated tests or test configuration area:frontend Frontend source or Node tooling labels Oct 11, 2026
… suppressions (#178)

116 files under includes/ disabled Squiz.Commenting.FunctionComment,
Generic.Commenting.DocComment.MissingShort, and FunctionCommentThrowTag at
the top of the file, which hid 1,378 findings: 401 functions with no doc
comment, 567 missing @PARAM tags, and tags that no longer matched their
signatures. Every one of those disables is removed and the code is now
documented instead:

- Each function has a summary describing what it does, and a complete,
  ordered @PARAM list with the signature's types (keeping the existing
  PHPStan generic types) and a description.
- Interface implementations use {@inheritdoc}, so each port documents its
  contract once; phpcs.xml.dist enables Squiz's skipIfInheritdoc for them.
- Every exception a function throws has a @throws tag.
- Typed callable and Closure signatures move to @phpstan-param, which
  Squiz's type-hint check cannot parse.

Database_Values::decode_json() now reports malformed JSON as the
InvalidArgumentException it already documented, with the JsonException as
the previous exception, instead of leaking an undeclared exception type;
the three repository hydrators that caught both now catch the one.
Database-boundary disables are unchanged.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
@TheAggressive
TheAggressive enabled auto-merge (squash) October 11, 2026 11:06
@TheAggressive
TheAggressive merged commit 113cbc3 into master Oct 11, 2026
23 checks passed
@TheAggressive
TheAggressive deleted the feat/locks branch October 11, 2026 11:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:email Email compiler or campaign workflow area:php PHP or Composer code area:providers Provider integrations or delivery boundaries needs-attention Automation stopped; maintainer action required risk:high Security or release sensitive; never auto-merged type:feature New capability

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(jobs): per-campaign and per-remote-operation locks

1 participant