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:
- Redis - przechowywał zbiór aktywnych call IDs w secie "active_calls", używany do sprawdzania dostępności kanałów przed inicjacją nowego połączenia.
- PostgreSQL - tabela "campaign_calls" z pełną historią prób połączeń, statusami, timestampami.
- Telnyx - operator telefoniczny, który faktycznie utrzymuje połączenia i zna ich rzeczywisty stan.
- In-memory state w Node.js - obiekty "CallStateMachine", które zarządzały lifecycle każdego połączenia, przechowywane w Map w procesie.
- BullMQ jobs - joby w Redis reprezentujące "call to be made" lub "call in progress".
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:
- 1. Webhook handler - odbiera "call.hangup", usuwa z "active_calls"
- 2. Timeout watcher - wykrywa stuck call, próbuje usunąć z "active_calls"
- 3. Manual cleanup (wywołany przez operatora via admin panel) - też próbuje usunąć
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ę:
- 1. Połączenie kończy się po 3 sekundach
- 2. Webhook "call.hangup" dociera, usuwamy z "active_calls"
- 3. Webhook "call.answered" (wysłany wcześniej, ale opóźniony w sieci) dociera, dodaje z powrotem do "active_calls"
- 4. Mamy teraz ghost call - system myśli że połączenie jest aktywne, ale operator już je zakończył
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:
- Połączenie inicjowane w T=0
- Webhook "call.answered" wysłany przez Telnyx w T=2s, ale opóźniony w sieci
- Timeout watcher w T=31s wykrywa "stuck call", wywołuje force cleanup
- Webhook dociera w T=32s, próbuje zaktualizować stan na "answered"
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:
- Widzimy KTÓRE konkretnie połączenia są aktywne, nie tylko ile
- Reconciliation może sprawdzić każdy call ID indywidualnie
- "srem" jest idempotentny - można wywołać wielokrotnie bez skutków ubocznych
- Łatwe debugging - "redis-cli SMEMBERS active_calls" pokazuje exact state
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ą:
- PostgreSQL mówi: call "xyz" jest "in_progress"
- Redis mówi: call "xyz" NIE jest w "active_calls"
- Telnyx mówi: call "xyz" nie istnieje (404)
Który wygrywa? Musieliśmy zdefiniować explicit priority hierarchy:
- 1. Telnyx API (realtime check) - jeśli Telnyx mówi że połączenie nie istnieje, to nie istnieje. To faktyczny stan u operatora.
- 2. PostgreSQL - jeśli PostgreSQL ma status "ended" z timestampem, to jest bardziej wiarygodny niż Redis (który mógł failować w trakcie update).
- 3. Redis - najmniej wiarygodny, bo volatile. Używany tylko jako performance cache.
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:
- Symptom 1: System przestaje inicjować nowe połączenia mimo że capacity jest dostępna. Logi pokazują "max channels reached", ale Telnyx dashboard pokazuje zero active calls.
- Symptom 2: Statystyki kampanii nie zgadzają się. PostgreSQL pokazuje 1000 attempted calls, ale suma połączeń answered + voicemail + no_answer = 980. Brakuje 20.
- Symptom 3: Redis memory usage rośnie bez ograniczeń. Set "active_calls" ma 200 elementów, mimo że kampania zakończyła się godzinę temu.
- Symptom 4: Webhooks przetwarzane wielokrotnie. Logi pokazują ten sam "event_id" obsłużony 3 razy w odstępach kilku sekund.
- Symptom 5: Timeout watchery eskalują do alertów. Połączenia w stanie "initiating" przez 5+ minut, mimo że faktycznie dawno się zakończyły.
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ł:
- Aktualny stan każdego połączenia (według każdego źródła)
- Historie reconciliation - ile rozbieżności naprawiono
- Histogram opóźnień webhooków (czas między event.occurred_at a timestamp przetworzenia)
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:
- 95% przypadków: consistency w <1 sekundę
- 4% przypadków: consistency w 1-10 sekund (opóźnione webhooks)
- 1% przypadków: consistency w 1-5 minut (wymagała reconciliation job)
- <0.1% przypadków: consistency dopiero po manual intervention (bugs w reconciliation logic)
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.