fix(automations): atomic execution_count via SQL function (migration 007)

The engine did a client-side read-modify-write on
automations.execution_count, so two concurrent dispatches of the
same automation (e.g. fired for two different contacts within the
same second) could both read N and both write N+1, permanently
losing one count.

- Migration 007 adds increment_automation_execution_count(p_automation_id)
  as a SECURITY DEFINER sql function. Only service_role can EXECUTE
  it — an authenticated user can't juice someone else's counter by
  calling the RPC directly.
- Engine now calls this RPC instead of reading automation.execution_count
  and adding 1. Atomic in one statement; concurrent dispatches both
  land correctly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Arnas Donauskas 2026-04-17 21:45:27 +03:00
parent 59d0ea6e17
commit 635b5c1df6
No known key found for this signature in database
2 changed files with 45 additions and 7 deletions

View File

@ -159,13 +159,16 @@ async function executeAutomation(automation: Automation, input: DispatchInput) {
triggerEvent: input.triggerType,
})
await db
.from('automations')
.update({
execution_count: (automation.execution_count ?? 0) + 1,
last_executed_at: new Date().toISOString(),
// Atomic counter update via the SQL function from migration 007.
// Doing this with a client-side read-modify-write raced when the
// same automation fired for two contacts simultaneously — both
// would read N and both write N+1, losing one count permanently.
const { error: rpcErr } = await db.rpc('increment_automation_execution_count', {
p_automation_id: automation.id,
})
.eq('id', automation.id)
if (rpcErr) {
console.error('[automations] increment counter failed:', rpcErr)
}
}
interface ExecuteArgs {

View File

@ -0,0 +1,35 @@
-- ============================================================
-- 007_automations_increment_counter.sql
--
-- Atomic increment of automations.execution_count + refresh of
-- last_executed_at. Called via PostgREST RPC from the engine.
--
-- Before this, the engine did a read-modify-write:
-- UPDATE automations SET execution_count = <cached + 1> WHERE id = ...
-- so two concurrent dispatches (e.g. the same automation firing for
-- two different contacts in the same second) could both read N and
-- both write N+1, permanently losing one count.
--
-- Idempotent — safe to re-run.
-- ============================================================
CREATE OR REPLACE FUNCTION increment_automation_execution_count(p_automation_id UUID)
RETURNS VOID
LANGUAGE sql
SECURITY DEFINER
SET search_path = public
AS $$
UPDATE automations
SET
execution_count = execution_count + 1,
last_executed_at = NOW()
WHERE id = p_automation_id;
$$;
-- Only the service role needs to call this (engine uses the
-- service-role client). Explicitly lock anon / authenticated out so
-- an authenticated user can't juice someone else's counter via RPC.
REVOKE ALL ON FUNCTION increment_automation_execution_count(UUID) FROM PUBLIC;
REVOKE ALL ON FUNCTION increment_automation_execution_count(UUID) FROM anon;
REVOKE ALL ON FUNCTION increment_automation_execution_count(UUID) FROM authenticated;
GRANT EXECUTE ON FUNCTION increment_automation_execution_count(UUID) TO service_role;