Přeskočit na obsah

Tichá ztráta záznamů při driftu sekvenčního čítače (unique kolize čísla)

import { Aside } from ‘@astrojs/starlight/components’;

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.

Čí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.

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 maxseq
FROM "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)::text
RETURNING (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.
Přidal aiarchitekt.cz · 12. 6. 2026 2:00
Provozuje aiarchitekt.cz