State divergence i reconciliation w systemach real-time telefonii: kiedy Redis mówi jedno, a rzeczywistość drugie

Pracowaliśmy nad systemem voicemail drop, który musiał obsługiwać pięć równoległych kanałów telefonicznych z milisekundową precyzją. Po trzech miesiącach działania produkcyjnego zauważyliśmy coś dziwnego: system przestawał dzwonić, twierdząc że wszystkie kanały są zajęte, podczas gdy w rzeczywistości wszystkie były wolne. Problem pojawiał się sporadycznie, zazwyczaj po intensywnych kampaniach lub po nagłych rozłączeniach po stronie operatora.

To był klasyczny przypadek state divergence – sytuacji, w której różne części systemu mają różne wyobrażenia o tym samym stanie. W naszym przypadku: Redis twierdził, że pięć połączeń jest aktywnych, ale Telnyx (nasz operator) nie miał żadnych aktywnych połączeń dla naszego konta. System wszedł w deadlock – nie mógł nawiązać nowych połączeń, bo „wszystkie sloty były zajęte”, ale te sloty w rzeczywistości były puste.

Anatomia problemu: gdzie stan mieszka w systemie telefonicznym

W typowym systemie autodialera stan połączenia żyje w wielu miejscach jednocześnie. U nas to były:

Każdy z tych stanów był aktualizowany w różnych momentach, przez różne części systemu, w odpowiedzi na różne eventy. I tu zaczynały się schody.

Race conditions w trzy strony: webhook, timeout i manual cleanup

Klasyczny scenariusz który ujawniał problem wyglądał tak:

Inicjujemy połączenie przez Telnyx API. Telnyx odpowiada z „call_control_id”, zapisujemy go do Redisa w secie „active_calls”. Połączenie faktycznie się nawiązuje, ale po stronie odbiorcy następuje natychmiastowe rozłączenie (busy signal lub network issue po stronie operatora).

Telnyx wysyła webhook „call.hangup”, ale webhook trafia do nas z opóźnieniem 2-3 sekundy. W międzyczasie nasz timeout watcher (który miał wykrywać „stuck calls”) widzi, że połączenie jest w stanie „initiating” dłużej niż 10 sekund i próbuje wymusić cleanup.

Mamy więc trzy ścieżki kodu, które próbują zaktualizować stan tego samego połączenia:

Problem w tym, że te operacje nie były atomowe. Nasz kod wyglądał mniej więcej tak:

async function releaseChannel(callId: string) {
  const exists = await redis.sismember("active_calls", callId);
  if (exists) {
    await redis.srem("active_calls", callId);
    await db.campaignCalls.update(callId, { status: "ended", endedAt: new Date() });
  }
}

Wyglądało niewinnie, ale między „sismember” a „srem” mogło się zmieścić inne wywołanie. Co gorsza – czasami „srem” wykonywał się, ale update do PostgreSQL failował (connection timeout, deadlock). Zostawało nam połączenie usunięte z Redisa ale wiszące w PostgreSQL jako „in_progress”.

Albo odwrotnie – PostgreSQL zaktualizowany na „ended”, ale Redis nadal trzymał call ID w „active_calls”, bo exception zostało rzucone między operacjami.

Webhook ordering: nie ma gwarancji

Drugi fundamentalny problem to kolejność webhooków. Telnyx, jak większość dostawców telefonicznych, wysyła webhooks jako niezależne HTTP requesty. Nie ma gwarancji, że przychodzą w kolejności chronologicznej.

Obserwowaliśmy scenariusze gdzie webhook „call.answered” przychodził PO webhooku „call.hangup”. Jeśli przetwarzaliśmy je naiwnie w kolejności odbioru, mogliśmy mieć sytuację:

Próbowaliśmy rozwiązać to timestampami z webhooków, ale one też nie były wiarygodne – czasami miały opóźnienia rzędu kilku sekund względem rzeczywistych eventów. Telnyx generuje timestamp w momencie wysłania webhooka, nie w momencie faktycznego zdarzenia w ich systemie.

Duplikacja webhooków i idempotency która nie chroni przed wszystkim

Telnyx retry”uje webhooki jeśli nie dostanie 200 OK w ciągu 5 sekund. Problem w tym, że nasze webhooks czasami przetwarzały się dłużej (transkrypcja, klasyfikacja przez AI), więc Telnyx timeout”ował i retry”ował podczas gdy pierwsze request nadal się wykonywało.

Standardowe rozwiązanie to idempotency – zapisz event ID do Redisa, jeśli już jest, zignoruj duplikat:

const eventId = req.body.data.id;
const processed = await redis.setnx("webhook:${eventId}", "1", "EX", 3600);
if (!processed) return res.status(200).send("duplicate");

To działa dla kompletnych duplikatów, ale nie chroni przed race condition gdzie oba requesty przejdą przez „setnx” niemal równocześnie. Redis „setnx” jest atomowy, więc to by miało działać – ale co jeśli Redis sam miał problemy?

Mieliśmy incident gdzie Redis cluster przeszedł przez failover. W tym czasie część webhooków została przyjęta, część nie. Po failoverze Telnyx retry”ował wszystkie, które nie dostały 200 OK, ale nasz system stracił informację o tym, które już przetworzyliśmy (były w Redis, który właśnie przełączył się na nowy master bez pełnej replikacji).

Rezultat: masowe duplikaty, setki połączeń policzonych podwójnie w statystykach kampanii.

PostgreSQL jako source of truth i reconciliation pattern

Po tych problemach zrozumieliśmy, że Redis nie może być źródłem prawdy. Redis jest świetny jako cache i working state, ale jest volatile. Musieliśmy zbudować architekturę, gdzie PostgreSQL jest zawsze authoritative, a Redis jest synchronizowany z nim.

Wprowadziliśmy dual-write pattern – każda zmiana stanu połączenia idzie najpierw do PostgreSQL, potem do Redisa:

async function markCallEnded(callId: string, reason: string) {
  // 1. PostgreSQL first - durable write
  await db.campaignCalls.update(callId, {
    status: "ended",
    endReason: reason,
    endedAt: new Date()
  });
  // 2. Redis second - best effort
  try {
    await redis.srem("active_calls", callId);
  } catch (err) {
    // Redis failure doesn"t fail the operation
    // Reconciliation will fix it later
    logger.warn("Redis update failed for ${callId}, will reconcile", err);
  }
}

Kluczowe jest, że failure w Redis nie powoduje rollback PostgreSQL. Redis jest traktowany jako cache, który może być nieaktualny.

Żeby zsynchronizować rozbieżności, wprowadziliśmy reconciliation job działający co 60 sekund:

async function reconcileActiveCalls() {
  // 1. Get our view of active calls from Redis
  const redisCallIds = await redis.smembers("active_calls");
  // 2. Get authoritative view from PostgreSQL
  const dbActiveCalls = await db.campaignCalls.findMany({
    status: "in_progress",
    updatedAt: { gte: DateTime.now().minus({ minutes: 10 }) }
  });
  const dbCallIds = new Set(dbActiveCalls.map(c => c.callId));
  // 3. Get actual state from Telnyx
  const telnyxCalls = await telnyx.calls.list({ 
    status: "active",
    connectionId: config.telnyxConnectionId 
  });
  const telnyxCallIds = new Set(telnyxCalls.map(c => c.call_control_id));
  // 4. Find divergences
  const ghostCallsInRedis = redisCallIds.filter(id => 
    !telnyxCallIds.has(id) && !dbCallIds.has(id)
  );
  const missingInRedis = [...dbCallIds].filter(id => 
    !redisCallIds.includes(id)
  );
  const stalledInDB = dbActiveCalls.filter(call =>
    !telnyxCallIds.has(call.callId) &&
    DateTime.fromJSDate(call.updatedAt) < DateTime.now().minus({ minutes: 5 })
  );
  // 5. Fix divergences
  for (const callId of ghostCallsInRedis) {
    await redis.srem("active_calls", callId);
    metrics.increment("reconciliation.ghost_call_removed");
    logger.warn("Reconciliation: removed ghost call ${callId} from Redis");
  }
  for (const callId of missingInRedis) {
    await redis.sadd("active_calls", callId);
    metrics.increment("reconciliation.call_restored_to_redis");
  }
  for (const call of stalledInDB) {
    await markCallEnded(call.callId, "stalled_detected_in_reconciliation");
    metrics.increment("reconciliation.stalled_call_cleaned");
    logger.error("Reconciliation: force-ended stalled call ${call.callId}");
  }
}

Ten pattern uratował nas wiele razy. Ale ma koszt – zapytanie do Telnyx API co 60 sekund nie jest darmowe (rate limits, latency). Dla 5 kanałów to nie problem, ale przy skalowaniu do 50+ trzeba to przemyśleć.

„System miał dokładnie pięć aktywnych połączeń. Szkoda tylko, że operator nie miał żadnego. Po kilku godzinach debugowania człowiek zaczyna rozumieć, że distributed systems to głównie synchronizowanie różnych wersji tej samej katastrofy.”

Lua scripting dla atomic operations w Redis

Częściowym rozwiązaniem problemu race conditions była atomizacja operacji przez Lua scripty w Redis. Zamiast read-check-write w trzech krokach, robimy to atomowo po stronie Redisa:

async function acquireChannel(callId: string): Promise {
  const script = "
    local max_channels = tonumber(ARGV[1])
    local call_id = ARGV[2]
    local active_count = redis.call("SCARD", KEYS[1])
    if active_count < max_channels then
      redis.call("SADD", KEYS[1], call_id)
      return 1
    else
      return 0
    end
  ";
  const acquired = await redis.eval(
    script,
    ["active_calls"],
    [MAX_CHANNELS, callId]
  );
  return acquired === 1;
}

To rozwiązało większość problemów z równoczesnym dodawaniem połączeń przez różne worker procesy. Ale nie rozwiązało problemu gdzie „releaseChannel” nie zostaje wywołane (crash procesu, unhandled exception).

Timeout watchers i ich własne race conditions

Wprowadziliśmy timeout watchery, żeby wykrywać połączenia, które „utkneły” w stanie przejściowym. Połączenie w stanie „initiating” dłużej niż 30 sekund to anomalia – albo Telnyx nie odpowiedział, albo webhook się zgubił.

Problem w tym, że timeout watcher sam wprowadzał race conditions. Wyobraź sobie:

Rezultat: połączenie faktycznie aktywne, ale w naszym systemie oznaczone jako „force_ended”.

Musieliśmy wprowadzić dodatkową logikę – przed force cleanup, timeout watcher odpytuje Telnyx API o aktualny stan połączenia:

async function checkForStalledCalls() {
  const potentiallyStalled = await db.campaignCalls.findMany({
    status: "initiating",
    createdAt: { lt: DateTime.now().minus({ seconds: 30 }) }
  });
  for (const call of potentiallyStalled) {
    try {
      // Don"t trust our state - verify with Telnyx
      const actualState = await telnyx.calls.retrieve(call.callControlId);
      if (actualState.state === "active") {
        // False positive - webhook probably delayed, update our state
        await markCallAnswered(call.callId, actualState);
        logger.warn("Timeout watcher: call ${call.callId} was actually active, webhook delayed");
      } else if (actualState.state === "ended") {
        // True positive - webhook lost, cleanup
        await markCallEnded(call.callId, "webhook_lost");
        logger.error("Timeout watcher: call ${call.callId} ended but webhook lost");
      }
    } catch (err) {
      if (err.status === 404) {
        // Telnyx doesn"t know about this call - definitely stale
        await markCallEnded(call.callId, "call_not_found_at_provider");
        logger.error("Timeout watcher: call ${call.callId} not found at Telnyx, force cleanup");
      }
    }
  }
}

To działa, ale ma poważne ograniczenie – Telnyx API ma rate limit 60 requests/minute. Przy większej liczbie kanałów moglibyśmy przekroczyć ten limit tylko na reconciliation.

„Redis jako source of truth działa świetnie. Przynajmniej do momentu, w którym dwa webhooki, timeout watcher i failover klastra postanowią jednocześnie sprawdzić, czy na pewno jeszcze kontrolujesz własny system.”

Set-based tracking zamiast counterów

Wcześniej trackowaliśmy liczbę aktywnych połączeń jako prosty integer counter:

await redis.incr("active_calls_count"); // przy starcie
await redis.decr("active_calls_count"); // przy końcu

Problem z tym podejściem: jeśli „decr” nie zostaje wywołany (crash, exception), counter nigdy nie wraca do zera. Po kilku takich incidentach mieliśmy „active_calls_count” = 37, podczas gdy faktycznie zero połączeń było aktywnych.

Przejście na set-based tracking było kluczową zmianą:

// Zamiast countera - explicit set of call IDs
await redis.sadd("active_calls", callId);  // przy starcie
await redis.srem("active_calls", callId);  // przy końcu
// Ilość aktywnych = wielkość seta
const activeCount = await redis.scard("active_calls");

Korzyści z tego podejścia:

Ale set zajmuje więcej pamięci niż integer – dla 1000 call IDs (UUID4, 36 znaków) to ~36KB vs 8 bajtów dla countera. Przy naszej skali (max 5 równoczesnych połączeń) to nieistotne, ale przy setach tysięcy połączeń trzeba to uwzględnić.

Distributed locking i problem TTL

Próbowaliśmy użyć distributed lockingu (przez Redlock pattern) żeby zapobiec równoczesnemu przetwarzaniu tego samego webhooka przez różne worker instancje:

const lock = await redlock.acquire(["lock:webhook:${eventId}"], 5000);
try {
  await processWebhook(event);
} finally {
  await lock.release();
}

Problem pojawił się gdy przetwarzanie webhooka zajmowało dłużej niż TTL locka (5 sekund). Lock expirował, drugi worker zaczynał przetwarzać ten sam webhook, a pierwszy worker nadal działał. Mieliśmy duplicate processing MIMO locka.

Zwiększenie TTL do 30 sekund pomogło, ale wprowadziło nowy problem – jeśli worker crashował z lockiem, inne workery czekały 30 sekund przed możliwością przetworzenia tego webhooka.

Ostatecznie zrezygnowaliśmy z distributed lockingu na rzecz idempotency keys w PostgreSQL. Każdy webhook zapisuje swój event ID do tabeli:

CREATE TABLE processed_webhooks (
  event_id VARCHAR(255) PRIMARY KEY,
  received_at TIMESTAMP NOT NULL DEFAULT NOW(),
  processed_at TIMESTAMP
);
CREATE INDEX idx_received_at ON processed_webhooks(received_at);
async function processWebhook(event: TelnyxWebhook) {
  try {
    await db.processedWebhooks.insert({
      eventId: event.data.id,
      receivedAt: new Date()
    });
  } catch (err) {
    if (err.code === "23505") { // unique constraint violation
      // Duplicate webhook, ignore
      return;
    }
    throw err;
  }
  // Process webhook...
  await db.processedWebhooks.update(event.data.id, {
    processedAt: new Date()
  });
}

PostgreSQL unique constraint gwarantuje atomowość lepiej niż distributed lock. Koszt to dodatkowy write do bazy przy każdym webhooku, ale to akceptowalne (setki webhooków/minute, nie tysiące).

Multi-source state reconciliation

Najbardziej złożony przypadek to gdy mamy trzy źródła prawdy, które się nie zgadzają:

Który wygrywa? Musieliśmy zdefiniować explicit priority hierarchy:

Reconciliation implementuje tę hierarchię:

async function resolveConflict(callId: string) {
  // 1. Check Telnyx first (source of truth)
  let telnyxState;
  try {
    telnyxState = await telnyx.calls.retrieve(callId);
  } catch (err) {
    if (err.status === 404) {
      telnyxState = { state: "not_found" };
    } else {
      throw err; // Network error, can"t resolve
    }
  }
  // 2. Get our states
  const dbCall = await db.campaignCalls.findUnique({ callId });
  const inRedis = await redis.sismember("active_calls", callId);
  // 3. Apply hierarchy
  if (telnyxState.state === "not_found") {
    // Telnyx doesn"t know about it - definitely should be ended
    if (dbCall.status !== "ended") {
      await markCallEnded(callId, "not_found_at_telnyx");
    }
    if (inRedis) {
      await redis.srem("active_calls", callId);
    }
    return "resolved_to_ended";
  }
  if (telnyxState.state === "active") {
    // Telnyx says active - trust it
    if (dbCall.status !== "in_progress") {
      await db.campaignCalls.update(callId, { status: "in_progress" });
    }
    if (!inRedis) {
      await redis.sadd("active_calls", callId);
    }
    return "resolved_to_active";
  }
  if (telnyxState.state === "ended") {
    // Telnyx ended it - sync our state
    await markCallEnded(callId, "synced_from_telnyx");
    return "resolved_to_ended";
  }
}

Symptomy state divergence w produkcji

Jak rozpoznać że masz problem z state divergence? Obserwowaliśmy kilka charakterystycznych symptomów:

Każdy z tych symptomów wskazywał na inny aspekt state divergence. Debugging wymagał skorelowania logów z trzech źródeł – nasze logi, Redis MONITOR, Telnyx webhook logs (dostępne przez ich dashboard).

Monitoring i alerting dla reconciliation

Wprowadziliśmy metryki które sygnalizowały problemy zanim wpłynęły na użytkowników:

// Metrics tracked per reconciliation run
metrics.gauge("reconciliation.ghost_calls_found", ghostCallsInRedis.length);
metrics.gauge("reconciliation.missing_in_redis", missingInRedis.length);
metrics.gauge("reconciliation.stalled_in_db", stalledInDB.length);
metrics.gauge("reconciliation.state_matches", matchingStates);
// Alert thresholds
if (ghostCallsInRedis.length > 2) {
  alerts.trigger("high_ghost_call_rate", { count: ghostCallsInRedis.length });
}
if (stalledInDB.length > 0) {
  alerts.trigger("stalled_calls_detected", { calls: stalledInDB });
}

Dodaliśmy również dashboard który pokazywał:

To pozwoliło nam zauważyć pattern: większość problemów występowała w godzinach szczytu (3-5 PM EST), kiedy Telnyx miał największe obciążenie i webhooki były najbardziej opóźnione.

„Distributed locking brzmi bardzo profesjonalnie do momentu, w którym lock wygasa szybciej niż webhook kończy własny processing. Wtedy odkrywasz, że większość synchronizacji w realtime systems to po prostu kontrolowane zarządzanie tym, kto aktualnie psuje stan.”

Trade-offy reconciliation frequency

Reconciliation co 60 sekund dawało dobry balans, ale rozważaliśmy różne częstotliwości:

Co 10 sekund: Szybkie wykrywanie problemów, ale wysokie obciążenie API (6 requests/minute do Telnyx tylko na reconciliation). Przy więcej niż 10 kanałach przekraczaliśmy rate limity.

Co 5 minut: Niskie obciążenie, ale problemy mogły się akumulować. Ghost calls w Redis blokowały sloty przez 5 minut zamiast 1 minuty.

Event-driven reconciliation: Uruchamiać tylko gdy wykryto anomalię (np. webhook timeout). Eleganckie, ale nie wyłapuje „cichych” problemów gdzie nic nie timeout”uje, po prostu stan jest błędny.

Skończyliśmy na hybrydowym podejściu: reconciliation co 60s jako baseline, plus on-demand reconciliation triggered przez anomaly detection (np. nagły spike w failed webhook processing).

Eventual consistency i jak długo jest "eventual"

System operuje w modelu eventual consistency – Redis i PostgreSQL mogą być chwilowo niespójne, ale reconciliation doprowadza je do zgodności. Pytanie: jak długa może być ta chwila?

W praktyce obserwowaliśmy:

Dla naszego use case”u 1-5 minut było akceptowalne – system dzwonił z taką częstotliwością, że chwilowa rozbieżność nie blokowała kampanii. Ale dla systemu z real-time bidding w połączeniach (gdzie każda sekunda ma wartość) byłoby to nie do przyjęcia.

To pokazuje kluczowy punkt: eventual consistency nie jest uniwersalnie dobra czy zła – zależy od wymagań biznesowych. W naszym przypadku reliability > speed była świadomym trade-offem.

Wnioski praktyczne

State divergence nie jest problemem który można „rozwiązać” raz na zawsze. To inherentny problem systemów rozproszonych z wieloma źródłami prawdy. Można go tylko mitigować przez:

Explicitne definiowanie source of truth dla każdego typu stanu. PostgreSQL dla durable state, Redis dla working state, Telnyx API dla realtime verification.

Reconciliation jako first-class citizen w architekturze, nie afterthought. Musi być monitorowany, alertowany, i regularnie review”owany.

Atomowe operacje przez Lua scripting w Redis, transakcje w PostgreSQL, idempotency keys dla webhooków.

Monitoring rozbieżności jako główna metryka zdrowia systemu, nie tylko throughput czy error rate.

Acceptance że system będzie czasowo niespójny i projektowanie workflow które to tolerują.

Po wdrożeniu tych mechanizmów, incydenty ghost calls spadły z 2-3 tygodniowo do jednego na miesiąc. Nie jest idealnie, ale jest production-ready.

Podsumowanie

Największym błędem w systemach realtime jest moment, w którym człowiek zaczyna wierzyć, że „stan” faktycznie istnieje. Przez kilka tygodni byliśmy absolutnie przekonani, że mamy pięć aktywnych połączeń, mimo że operator nie miał już żadnego, PostgreSQL powoli tracił kontakt z rzeczywistością, a Redis dzielnie przechowywał ghost calle jakby były częścią feature’u. System oczywiście raportował wszystko jako healthy. Dashboard świecił się na zielono. Monitoring mówił, że jest stabilnie. Produkcja miała po prostu inne zdanie.

W pewnym momencie reconciliation przestał być „mechanizmem naprawczym”, a stał się czymś w rodzaju cyfrowego terapeuty dla infrastruktury. Co minutę chodził po systemie i pytał każdy komponent osobno, co mu się wydaje, że właśnie się wydarzyło. Redis twierdził jedno. Webhooki drugie. Timeout watcher trzecie. Telnyx czasem odpowiadał 404, co w praktyce oznaczało „powodzenia w interpretacji tego biznesowo”. Eventual consistency brzmi bardzo mądrze do momentu, aż odkrywasz, że oznacza głównie „może za chwilę przestaniemy sobie przeczyć”.

Najlepsze jest to, że większość tych problemów wyglądała absurdalnie mało prawdopodobnie na etapie developmentu. Distributed lock wygasający pół sekundy za wcześnie. Webhook „call.answered” przychodzący po „call.hangup”. Redis failover dokładnie między srem a update’em PostgreSQL. Każdy pojedynczy przypadek wydawał się praktycznie niemożliwy. Produkcja potraktowała to bardziej jak checklistę.

Zobacz powiązane case studies i analizy

Procesy, architektura i workflow powiązane z tematami poruszanymi w tym materiale – od integracji i realtime systems po automatyzacje operacyjne.

Inne

Pozostałe artykuły

Webhook Reliability Patterns: czego nauczyłem się debugując phantom calls w systemie VoIP
Jak dostarczyć MVP w 14 dni bez tworzenia architektonicznej katastrofy, która zablokuje rozwój produktu po pierwszym sukcesie?
System offline-first brzmi świetnie do momentu, aż magazyn straci sieć w środku operacji, a aplikacja musi zdecydować, które dane są jeszcze prawdą.