From 635b5c1df6e52bc9259cbf8dfbb680249a3588b6 Mon Sep 17 00:00:00 2001 From: Arnas Donauskas Date: Fri, 17 Apr 2026 21:45:27 +0300 Subject: [PATCH] fix(automations): atomic execution_count via SQL function (migration 007) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/lib/automations/engine.ts | 17 +++++---- .../007_automations_increment_counter.sql | 35 +++++++++++++++++++ 2 files changed, 45 insertions(+), 7 deletions(-) create mode 100644 supabase/migrations/007_automations_increment_counter.sql diff --git a/src/lib/automations/engine.ts b/src/lib/automations/engine.ts index 1a131fa..2007eb8 100644 --- a/src/lib/automations/engine.ts +++ b/src/lib/automations/engine.ts @@ -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(), - }) - .eq('id', automation.id) + // 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, + }) + if (rpcErr) { + console.error('[automations] increment counter failed:', rpcErr) + } } interface ExecuteArgs { diff --git a/supabase/migrations/007_automations_increment_counter.sql b/supabase/migrations/007_automations_increment_counter.sql new file mode 100644 index 0000000..9126cb0 --- /dev/null +++ b/supabase/migrations/007_automations_increment_counter.sql @@ -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 = 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;