Tichá ztráta záznamů při driftu sekvenčního čítače (unique kolize čísla)
import { Aside } from ‘@astrojs/starlight/components’;
Symptom
Sekce “Symptom”Příchozí záznamy z webhooku (lead-gen formuláře přes Zapier/Meta) přestaly tiše
vznikat. Navenek „přišlo míň poptávek, než by mělo”. V DB vznikl jen rodičovský
záznam (klient), ale navázaný záznam (lead) chyběl — webhook nebyl
transakční, takže klient se vytvořil a teprve lead.create spadl. V error logu:
Unique constraint failed on the fields: (`leadNumber`)Počet chyb přesně odpovídal počtu čísel mezi (zaostalým) čítačem a maximem v tabulce.
Root cause
Sekce “Root cause”Číslo záznamu se generovalo přes slepý čítač uložený v konfigurační tabulce —
atomický INSERT … ON CONFLICT DO UPDATE SET value = value + 1. Atomicita řešila
souběh, ale ne drift: když hodnota čítače z jakéhokoli důvodu klesla pod
skutečné maximum už použitých čísel (restore staré zálohy jen části tabulky, ruční
zásah, hromadný import, který čísla vložil přímo bez bumpnutí čítače), čítač začal
vydávat čísla, která už existují → unique constraint na sloupci čísla → výjimka.
Zrádné je, že to bylo částečně self-healing špatným směrem: každý spadlý pokus čítač posunul o +1, takže po N kolizích (N = velikost driftu) se sám „dohnal” nad maximum a začal fungovat — ztratilo se právě těch N záznamů, pak ticho. Diagnóza bez logů by ukázala „čítač je v pořádku” (už dohnaný).
Stejný projekt měl pro faktury/smlouvy jinou, korektní funkci (viz Fix) — leady omylem používaly tu nebezpečnou.
Fix
Sekce “Fix”Generování čísla podložit skutečným maximem sloupce, ne jen čítačem. Klíč je
GREATEST(counter, MAX+1) — čítač nikdy neklesne pod realitu, takže kolize je
nemožná:
-- 1) numerické maximum existující řady (ne textové: '9999' < '10000')SELECT COALESCE(MAX(CAST(substring("col" FROM '([0-9]+)$') AS INTEGER)), 0) AS maxseqFROM "Table";
-- 2) atomický inkrement s podlahou = maxseq+1 (self-healing)INSERT INTO "Setting" ("key","value") VALUES (:key, :maxseq_plus_2)ON CONFLICT ("key") DO UPDATE SET "value" = (GREATEST(CAST("Setting"."value" AS INTEGER), :maxseq_plus_1) + 1)::textRETURNING (CAST("value" AS INTEGER) - 1)::int AS seq;Před: value = value + 1 (slepé). Po: value = GREATEST(value, max+1) + 1.
Doplňky, které stojí za zvážení u stejného vzoru:
- Webhook udělej transakční (rodič + dítě v jedné transakci), aby kolize nevytvořila osiřelého rodiče.
- Idempotence: pokud webhook vrací 5xx, retry vrstva (Zapier) ho přehraje — deduplikuj podle externího ID, ať se po opravě nezdvojí.
- Test reprodukující drift: vlož záznam s vysokým číslem, čítač nastav nízko, ověř, že generátor vrátí max+1, ne kolizní číslo.