Foundationপ্রথম নীতি থেকে
LEVEL 9লেসন ১৩/১৯কঠিন৫০ মিনিট

Idempotency ও Exactly-once-এর মিথ

Idempotency and the Exactly-once Myth

Retry-যুক্ত যেকোনো channel-এ duplicate অনিবার্য — exactly-once বলতে আসলে at-least-once delivery-র সাথে idempotent effect; Idempotency-Key pattern-ই payment double-charge ঠেকানোর শিল্প-উত্তর।

এই লেসন শেষে আপনি পারবেন

  • Delivery-guarantee-ladder-এর তিন ধাপ — at-most-once, at-least-once, exactly-once — আলাদা করে ব্যাখ্যা করে বলতে পারবেন retry কেন duplicate-এর জন্ম দিতেই থাকে
  • Exactly-once-এর সৎ সংজ্ঞা — at-least-once delivery + idempotent effect — আর end-to-end argument দিয়ে 'exactly-once delivery'-দাবি খণ্ডন করতে পারবেন
  • Idempotency-Key header-pattern পুরো walkthrough করতে পারবেন — client-generated UUID, server-side key→result store, replay-এ cached response
  • Natural idempotency (`SET balance=X` বনাম `ADD delta`) আর CAS-style conditional write চিনে কোথায় কোনটা যথেষ্ট বলতে পারবেন
  • Kafka-র transactional producer + read_committed-এর scope-সততা রপ্ত করে বলতে পারবেন ওটা ঠিক কী guarantee দেয়, কী দেয় না

আগে এটা বুঝি

২০১৯-এ একটা পরিচিত fintech-startup-এর ঘটনা: gateway-র load-balancer flap করল, mobile app-এর HTTP client timeout দেখে স্বভাবতই retry করল — আর payment-service প্রতিটা request-কে নতুন চার্জ ভেবে কাটল। এক user, এক purchase, কার্ডে গেল তিনবার। Refund-run-এ রাত গেল, support-ticket উড়ল। Bug কোথায় ছিল? Retry-তে না — retry তো ঠিকই করেছিল। ভুল ছিল server-side: প্রতিটা request-কে first-time ভেবে effect চালানো।

Network-এ একটা কঠিন সত্য আছে: response হারালে client জানতেই পারে না request পৌঁছেছিল কি না। তাই সে retry করবেই — আর retry মানেই server হয়তো একই কাজ দ্বিতীয়বার করবে। এই লেসনের মূল প্রশ্ন দুটো: delivery-guarantee-র সিঁড়িতে ঠিক কী কী promise দেওয়া সম্ভব, আর ‘exactly-once’ নামের প্রচারিত জিনিসটা আসলে কী? উত্তর- দুটোই এক শব্দের চারপাশে ঘোরে — idempotency।

মূল ধারণা

Delivery-guarantee-র সিঁড়ি

Message/request পাঠানোর তিনটা সম্ভাব্য চুক্তি:

Guaranteeযন্ত্রবৈশিষ্ট্য
At-most-onceFire-and-forget, retry নেইDuplicate নেই, কিন্তু lossy — যা হারাল তা হারাই
At-least-onceRetry until ackLoss নেই, কিন্তু duplicate অনিবার্য
Exactly-once???নিচে দেখুন — সোজা পথে অসম্ভব

At-most-once sensor-heartbeat-এর মতো জায়গায় চলে (পরেরটা আসবেই)। কিন্তু payment, order, inventory — যেখানে প্রতিটা message-এর দাম — সেখানে at-least-once-ই একমাত্র বাস্তব ভিত্তি: হারানো যাবে না। আর তখনই duplicate ঢোকে, কারণ ack নিজেই একটা message, যা-ও হারাতে পারে।

Exactly-once-এর সৎ সংজ্ঞা

সোজা প্রশ্ন: receiver-এর কাছে পৌঁছানো-প্রক্রিয়াকে exactly-once করা যায়? End-to-end argument বলে — না। কারণ:

  • Sender দুইবার পাঠাতেই পারে (ack হারালে);
  • Receiver-এর মৃত্যু-পুনর্জন্মের মাঝে ‘আমি এটা করে ফেলেছি’ স্মৃতি না থাকলে আবার করবে;
  • ‘ঠিক একবার পাঠানো’-র কোনো physical mechanism নেই — wire-এ যা যায় সেটা packet, packet হারে, packet duplicate হয়।

তাহলে Kafka-র মতো system-র ‘exactly-once’ দাবি কী? সত্যটা হলো:

Exactly-once EFFECT = at-least-once DELIVERY + idempotent PROCESSING।

Duplicate আসবেই — কিন্তু প্রতিটা duplicate প্রক্রিয়া করার সময় আগে-করা-কাজের স্মৃতি দেখে নিজেকে বিরত রাখবে। Delivery-layer নয়, effect-layer-এ exactly-once কেনা যায়। এটাই end-to-end argument- এর practical রূপ: সমস্যার স্থায়ী সমাধান বসে knowledge-সর্বশেষ- প্রান্তে (application), transport-এর মাঝে নয়।

Idempotency-Key pattern — শিল্প-standard walkthrough

Stripe থেকে Shopify — প্রায় সব payment API একই ছাঁচে চলে:

১. Client প্রতিটা logical operation-এর জন্য নতুন UUID বানায়
      (retry-তে একই UUID, নতুন operation-এ নতুন — নিয়মটা কঠোর!)
২. Request-header-এ যায়:   Idempotency-Key: 7f3c...
৩. Server দেখে — key store-এ আছে?
      না  → effect চালাও, response store-এ জমাও, উত্তর দাও
      হ্যাঁ → stored response হুবহু ফেরত দাও, effect ছোঁয় না
৪. Timeout-ambiguous retry-ও একই উত্তর পায় — double-charge অসম্ভব

Design-এর তিনটা সূক্ষ্ম জায়গা:

  • Key বানায় client — server নয়। Server-generated key হলে প্রতিটা retry-ই নতুন key পেয়ে যায়, dedup অর্থহীন।
  • Store করা হয় pura response, শুধু success/fail flag নয় — যাতে replay-এ client হুবহু সেই-এক উত্তর পায় (idempotent-response contract)।
  • Key-র সাথে scope জড়ে: একই key ভিন্ন endpoint/ভিন্ন account- এ collision হলে ভুল response ফেরত যেতে পারে — তাই store-এর primary key = (scope, key)।

Natural idempotency আর CAS

Key-store সব জায়গা লাগে না — মাঝে মাঝে operation নিজেই idempotent:

-- NOT idempotent: retry-তে দ্বিগুণ কাটবে
UPDATE accounts SET balance = balance - 100 WHERE id = 7;

-- idempotent by construction: কতবার চালালেও state এক
UPDATE accounts SET balance = 400 WHERE id = 7;

-- conditional (CAS-style): version মিললেই একবারই apply হবে
UPDATE accounts SET status='PAID', version = version + 1
WHERE id = 7 AND version = 3;

SET absolute-value আর conditional-write দুটোই natural idempotency — duplicate apply-এর ফল শূন্য। কিন্তু absolute-set নিজেই lost-update- ঝুঁকি বহন করে (আগের লেসন দেখুন) — তাই বাস্তবে CAS-version ছাড়া natural path নিরাপদ নয়।

ভেতরে কী ঘটছে

Key-store ভেতরে — race window-ই আসল কাজ

CREATE TABLE idempotency_keys (
  scope       text NOT NULL,          -- endpoint + account
  key         uuid NOT NULL,
  response    jsonb NOT NULL,
  created_at  timestamptz DEFAULT now(),
  PRIMARY KEY (scope, key)
);

Naive handler-এ ফাঁক থেকে যায়: check (SELECT) আর insert দুই statement-এর মাঝে দুইটা concurrent duplicate ঢুকে পড়লে দুটোই ‘নেই’ দেখে effect চালায়। সমাধান check-then-act নয় — atomic claim:

INSERT INTO idempotency_keys(scope, key, response)
VALUES ($1, $2, '{"state": "processing"}')
ON CONFLICT DO NOTHING;
-- inserted row-count == 0 ?  আগের কেউ দাবি করেছে → wait/replay

Unique-constraint-ই distributed lock — database এক হলে। Database ভাঙা থাকলে Redis SET NX PX-জাতীয় put-if-absent বা আগের লেসনের semantic-lock discipline। আর effect চলাকালীন দ্বিতীয় duplicate এলে? In-flight lock: দাবি-করা row এখনো processing — দ্বিতীয় request হয় অপেক্ষা করবে, নয়তো 409 Conflict/425 Too Early ফেরত দেবে; কখনোই parallel-এ effect চালাবে না।

TTL — স্মৃতির মেয়াদ

Key forever রাখা যায় না — store ফুলবে। Standard: ২৪ ঘণ্টার TTL। দামটা জেনে নিন: TTL-expire-এর পরে একই key-তে retry এলে সেটা নতুন operation — double-effect আবার সম্ভব। তাই client-নীতি: expire-এর বেশি পুরনো retry retry-ই না, নতুন decision।

Kafka EOS-এর scope-সততা

Kafka-র ‘exactly-once’ দুই স্তরে, দুই সীমায়:

  • Idempotent producer: enable.idempotence=true — broker producer-id + sequence-number দেখে দুইবার-লেখা আটকায়। Scope: এক producer, এক session, এক partition।
  • Transactional producer + read_committed: ক’টা partition- এ ক’টা write এক atomically; consumer transaction-marker না-দেখা record পড়ে না।

যেখানে এসব শেষ হয় না: consumer-এর effect বাইরে — DB-write, email, card-charge। Kafka সেই external effect-এর duplicate আটকায় না, আটকাতে পারে না। ওখানে ফের আপনার হাতেই idempotent consumer / dedup-store। প্রচারের ‘exactly-once’ শুনে transport-magic ভেবো না — pipeline-এর ভেতরের segment-scope, বাইরে নয়।

উদাহরণ

এক payment-এর পূর্ণ trace

# প্রথম চেষ্টা
→ POST /v1/charges
→ Idempotency-Key: 7f3c9a2e-4b1d-4c8f-9e2a-51f0b3d7a811
→ {"order": 88231, "amount_cents": 450000}

✕ network glitch — response হারাল, কিন্তু server কাজ করেছে:
  effect: charge#1041 created, key stored (scope=charges/acc_42)

# client timeout → retry, একই key!
→ POST /v1/charges
→ Idempotency-Key: 7f3c9a2e-4b1d-4c8f-9e2a-51f0b3d7a811
→ {"order": 88231, "amount_cents": 450000}

← server: key found → NO new effect
← 200 OK  {"id": "ch_1041", "status": "succeeded",
            "amount_cents": 450000}          ← হুবহু cached response
← Idempotency-Replayed: true                  ← কিছু API এই header দেয়

# ledger: charge#1041 — ঠিক একবার। user: happy। auditor: happy।

উল্টোদিকটাও দেখুন — একই scenario, key ছাড়া: প্রতিটা retry নতুন POST — charge#1041, #1042, #1043… তিনটাই real। Gateway আর client দুজনেই ‘স্বাভাবিকভাবে’ ব্যর্থ হয়েছিল; double-charge- এর দোষী শুধু missing-dedup-design।

ছবিতে

Retry-এর দুই মুখ

CLIENT                    NETWORK               SERVER (no dedup)
  │── POST charge ──────►│                       │
  │                      │─────────────────────► │ effect! charge#A
  │      ✕ response lost ◄─────────────✕         │
  │── POST charge ──────►│ (client ভাবলেই পৌঁছায়নি)│
  │                      │─────────────────────► │ effect! charge#B
  │◄── 200 ──────────────┼───────────────────────│
  ▼
ledger: দুটো real charge — এক purchase
Key ছাড়া retry — প্রতিটা চেষ্টা নতুন effect; ambiguous timeout-ই duplicate-এর দরজা।
CLIENT                    NETWORK               SERVER (dedup store)
  │── POST + Key:k ─────►│                       │
  │                      │─────────────────────► │ claim(k)=miss
  │                      │                       │ effect! charge#A
  │      ✕ lost ◄────────────────────✕          │ store(k)=resp(A)
  │── POST + Key:k ─────►│                       │
  │                      │─────────────────────► │ claim(k)=HIT
  │◄── 200 resp(A) ──────┼───────────────────────│ (effect ছোঁয়নি)
  ▼
ledger: charge#A — ঠিক একবার
Key-সহ retry — প্রথম effect-ই শেষ কথা; বাকি সব চেষ্টা cached-response-এ মেটে।

নিজে চালিয়ে দেখুন

EXPERIMENT

Unreliable-channel lab — retry-storm বনাম idempotency-key, আর dedup-lookup-এর দাম

macOS / Linux (Python 3.10+)· ১৮ মিনিট
"""flaky_charge.py — response-drop + retry বনাম Idempotency-Key"""
import random, time


class Server:
    def __init__(self, use_keys):
        self.use_keys = use_keys
        self.charges, self.store = [], {}

    def handle(self, req):
        k = req.get('key')
        if self.use_keys and k in self.store:         # dedup-hit!
            return {'replayed': True, 'charge_id': self.store[k]}
        self.charges.append(req['amount'])            # ★ real-money effect ★
        cid = len(self.charges)
        if self.use_keys:
            self.store[k] = cid                       # key → result store
        return {'replayed': False, 'charge_id': cid}


class Channel:
    """Server প্রতিবারই কাজ শেষ করে — কিন্তু response মাঝে মাঝে wire-এ হারায়"""
    def __init__(self, server, drop_rate, seed):
        self.server, self.drop_rate = server, drop_rate
        self.rng = random.Random(seed)

    def send(self, req):
        resp = self.server.handle(req)
        if self.rng.random() > self.drop_rate:
            return resp
        return None                                   # timeout-ambiguity


TRIALS, DROP_RATE = 200, 0.35
for mode in (False, True):
    srv = Server(use_keys=mode)
    ch = Channel(srv, drop_rate=DROP_RATE, seed=42)
    for i in range(TRIALS):
        req = {'amount': 4500}
        if mode:
            req['key'] = f'pay-{i}'                   # client-generated UUID
        for _ in range(6):                            # retry until answered
            if ch.send(req):
                break
    print(f'use_keys={mode}: {TRIALS} logical payments -> '
          f'{len(srv.charges)} real charges '
          f'(duplicates={len(srv.charges) - TRIALS})')

store = {f'k{i}': i for i in range(100000)}
start = time.perf_counter()
for i in range(1000000):
    store.get(f'k{i % 100000}')                       # hash lookup probe
elapsed = time.perf_counter() - start
print(f'1M dedup lookups: {elapsed:.3f}s (~{elapsed * 1000:.0f}ns each)')

নমুনা output (seed-fixed):

use_keys=False: 200 logical payments -> 321 real charges (duplicates=121)
use_keys=True : 200 logical payments -> 200 real charges (duplicates=0)
1M dedup lookups: 0.488s (~488ns each)

তিনটা পাঠ:

  • Key-ছাড়া পথে ২০০ payment-এ ১২১টা ghost-charge — retry-policy যত generous, ghost তত বেশি।
  • Key-সহ পথে real charges == logical payments, চিরকাল — channel যত খারাপই হোক।
  • Dedup-lookup sub-microsecond; payment-এর ms-scale latency-তে rounding-error — ‘performance-excuse’ dedup-বাদ দেওয়ার কারণ নয়।
এটা কী প্রমাণ করে

Response-loss + retry-তে key-ছাড়া channel বারবার double-charge করে, key-সহ channel প্রতিটা logical payment-এ ঠিক একবার কাটে; আর dedup-store lookup-এর latency দাম নগণ্য — nanosecond-scale।

নিজে বানান

BUILD IT

IdempotentEndpoint — key→response cache, in-flight lock, TTL, fuzz-test

Python · ●●●○○
  1. Dedup store — key → completed-response mapping
  2. Atomic claim — একই key-র দুই concurrent caller একজনের হাতে
  3. In-flight lock — effect চলাকালীন দ্বিতীয়জন অপেক্ষায়
  4. TTL eviction — expire হলে স্মৃতি মুছে যায়
  5. Fuzz test — random retry/timeout-এ effect-count==1 প্রমাণ
"""idempotent_endpoint.py — exactly-once EFFECT wrapper (~80 line)"""
import random, threading, time
from collections import defaultdict


class IdempotentEndpoint:
    def __init__(self, ttl_s=0.05):
        self.ttl = ttl_s
        self.mu = threading.Lock()
        self.results = {}      # key -> (expires_at, value)
        self.claims = set()    # claimed-but-running keys
        self.waiters = defaultdict(list)

    def _expired(self, key, now):
        rec = self.results.get(key)
        return rec is not None and now >= rec[0]

    def handle(self, key, effect):
        """returns (verdict, value) — effect ঠিক একবারই চলে"""
        while True:
            now = time.monotonic()
            with self.mu:
                if self._expired(key, now):
                    del self.results[key]           # TTL eviction
                if key in self.results:
                    return ('replayed', self.results[key][1])
                if key in self.claims:
                    evt = threading.Event()
                    self.waiters[key].append(evt)   # in-flight lock
                else:
                    self.claims.add(key)            # atomic claim!
                    break                            # we execute
            evt.wait(2)                              # outside lock
        try:
            value = effect()                         # ★ single effect ★
            now = time.monotonic()
            with self.mu:
                self.results[key] = (now + self.ttl, value)
                self.claims.discard(key)
                for w in self.waiters.pop(key, []):
                    w.set()
            return ('executed', value)
        except Exception:
            with self.mu:
                self.claims.discard(key)             # retry allowed
                for w in self.waiters.pop(key, []):
                    w.set()
            raise


# ── fuzz test: random clients, flaky effect, duplicate storms ──
def fuzz(trials=300, seed=99):
    import concurrent.futures as cf
    rng = random.Random(seed)
    ep = IdempotentEndpoint(ttl_s=60)                 # TTL > test-window
    committed = defaultdict(int)                      # key -> real effects

    def effect(key):
        if rng.random() > 0.8:                        # 20% provider-fail:
            raise RuntimeError('payment hiccup')      # কিছুই commit হয়নি
        committed[key] += 1                           # ★ committed effect ★
        return f'result-{key}'

    for i in range(trials):                           # retry-storm phase
        key = f'op-{rng.randint(0, 24)}'
        deadline = time.monotonic() + 0.5
        while True:
            try:
                ep.handle(key, lambda k=key: effect(k))
                break                                 # success/replay
            except RuntimeError:
                if time.monotonic() >= deadline:
                    break                             # gave up

    with cf.ThreadPoolExecutor(8) as ex:              # duplicate storm!
        futs = [ex.submit(ep.handle, 'op-storm',
                          lambda: effect('op-storm'))
                for _ in range(16)]
        verdicts = [f.result()[0] for f in futs]

    bad = {k: c for k, c in committed.items() if c != 1}
    assert not bad, f'exactly-once ভাঙল: {bad}'
    assert committed['op-storm'] == 1
    executed = sum(1 for v in verdicts if v == 'executed')
    print(f'{trials} trials + 16-thread storm on 1 key')
    print(f'storm verdicts: {executed} executed, '
          f'{16 - executed} replayed/in-flight-wait')
    print('PASS — প্রতিটা key-র effect ঠিক একবার')


fuzz()

নমুনা output:

300 trials + 16-thread storm on 1 key
storm verdicts: 1 executed, 15 replayed/in-flight-wait
PASS — প্রতিটা key-র effect ঠিক একবার

তিনটা পাঠ:

  • claims.add আর results-check একই lock-এর ভেতরে — atomicity- টা এখানেই; আলাদা হলে race window ফিরে আসে।
  • Effect-fail হলে claim ছেড়ে দেওয়া হয় — নইলে poison-request key-টা চিরতরে বন্দি করে ফেলত।
  • Fuzz-এ effects[key] != 1 কখনো ধরা না পড়লেও confidence আসে thread-interleaving থেকে — production-এ এই assertion-টাই dedup-monitoring-এর ভিত্তি।

নিজে বাড়ান:

  1. handle-কে multi-node বানান — claims-কে Redis SET NX PX-এ সরিয়ে নিয়ে দুই process-এর duplicate-execution আটকান
  2. Response-body বড় হলে store-এ pointer (S3-key) রাখার pattern যোগ করুন — Stripe-এর কাছাকাছি
  3. TTL-expire-পরবর্তী retry-এ ‘stale-key’ warning metric বাড়ান

বাস্তব সিস্টেমে

বাস্তবে idempotency

Payment API-রা। Stripe Idempotency-Key header (২৪ ঘণ্টা TTL, cached full-response replay), PayPal, Adyen — সবাই এই pattern-ই; Stripe docs-এর retry-guide প্রায় এই লেসনের summary।

Mobile-first traffic। Cellular network-এ ১-২% request-ই timeout-ambiguous — elevator-এ subway-তে tap করা প্রতিটা user আসলে retry-generator; client-SDK-রা এখন built-in retry দেয়, তাই key ছাড়া POST-API মানেই double-effect।

Kafka ecosystem। enable.idempotence=true producer, EOS transactions, read_committed consumer — সব transport-segment-scope; downstream DB/email-effect-এ আবার আপনার dedup। CDC pipeline-ও at-least-once — consumer-table-এ (source, offset) unique-index-ই সেখানকার idempotency-key।

Banking ledger। Transfer-instruction-এ client-supplied reference-number

  • unique-constraint — SWIFT-যুগের idempotency-key; duplicate-file-এর বিরুদ্ধে শিল্প-পুরনো defense।

যে ভুলগুলো সবাই করে

“Kafka transactions মানে end-to-end exactly-once — আর duplicate নিয়ে ভাবতে হবে না।”

Scope-টা পড়ুন: transactional producer + read_committed শুধু Kafka-pipeline-এর ভেতরের read-process-write লুপ atomic করে। Consumer-এর বাইরের effect — DB-row, email, refund — Kafka দেখেই না। সেখানে dedup/idempotent-consumer আপনারই দায়; প্রচার-শব্দ contract নয়।

“GET/PUT/DELETE idempotent, তাই ওদিকে dedup লাগে না — শুধু POST-এ লাগবে।”

Method-idempotency concurrency-safe নয়: দুই parallel PUT একই resource-এ ভিন্ন body নিয়ে last-write-wins করে; DELETE-retry নিরীহ কিন্তু DELETE-এর response হারালে client জানে না resource আগেই ছিল কি না। Method-classification retry-নিরাপত্তা বোঝায়, business-level dedup বোঝায় না — দুই স্তর আলাদা প্রশ্ন।

“Idempotency-key একবার বানালেই চিরকাল নিরাপদ।”

Store-এর TTL-ই চুক্তির সীমা — ২৪ ঘণ্টা পরে একই key-তে নতুন request নতুন operation, double-effect আবার সম্ভব। আর key নতুন logical operation-এ reuse করলে (client-bug) উল্টো problem: সত্যিকারের দ্বিতীয় payment সাইলেন্টলি replayed-response খেয়ে হারিয়ে যাবে। Key-discipline দুই দিক থেকেই কঠোর।

“Retry বিরল ব্যর্থতা-হ্যান্ডলিং — normal path-এ ভাবার দরকার নেই।”

Production-measurement বলে উল্টো: p99-latency spike, deploy-restart, LB-drain — প্রতিটাতেই in-flight request retry হয়। বড় scale-এ ‘duplicate-per-million’ মানেই প্রতিদিন ক’টা double-charge। তাই design-principle: retry নিশ্চিত, duplicate প্রশ্নই জন্মায় না — এমন structure বানাও।

বুঝেছেন কি না দেখুন

1

Delivery-ladder-এর তিন ধাপ আর প্রত্যেকটার মূল trade-off এক line-এ।

স্মরণ

At-most-once — lossy, duplicate নেই; at-least-once — loss নেই, duplicate অনিবার্য; exactly-once — সোজা পথে অসম্ভব, বাস্তবে at-least-once delivery + idempotent effect।

2

Ack/response হারার জন্যই duplicate ঢোকে — কেন sender শুধু ‘অন্যরকম ack’ পাঠিয়ে সমস্যাটা মেটাতে পারে না?

যুক্তি

Ack নিজেও message — সেটাও হারায়, duplicate হয়; infinite regress। Wire-এ যা-ই থাকুক, lossy-channel-এ ‘ঠিক একবার’ signal করার physical উপায় নেই। তাই সমাধান receiver-side স্মৃতিতে — effect-layer, transport-layer নয় (end-to-end argument)।

3

UPDATE stock = stock − 1 আর SET status='PAID' WHERE version=3 — retry-তে কোনটা বিপজ্জনক, কেন, আর safe alternative?

প্রয়োগ

Delta-update non-idempotent — প্রতি retry-তে বাড়তি decrement। CAS-style conditional natural-idempotent — version mismatch হলে zero rows, duplicate effect অসম্ভব। Alternative: delta-ই রাখতে হলে request-scoped idempotency-key + applied-keys table, অথবা absolute-value set (কিন্তু lost-update-ঝুঁকি আলাদাভাবে cover করে)।

4

Check-then-insert (SELECT তারপর INSERT) কেন ভাঙে, আর ON CONFLICT DO NOTHING + rowcount কেন ভাঙে না?

যুক্তি

Check-then-insert দুই statement — মাঝে অন্য duplicate ঢুকে দুটোই ‘miss’ দেখে effect চালায় (race window)। ON CONFLICT এক statement- এ atomic claim: unique/PK constraint-ই lock; rowcount 0 মানে অন্যজন আগেই দাবি করেছে → wait/replay-path। Claim আর effect মাঝে in-flight-lock-ও জরুরি — নইলে running-effect-এর সমান্তরাল duplicate।

5

Order-service প্রতি night bulk-refund job চালায়; queue at-least-once। Refund-effect-এ কোন dedup-key, store-এ কী রাখবেন, TTL কত?

ডিজাইন

Key = (refund-batch-id, order-id) — batch-retry আর order-retry দুটোই cover। Store: refund-ledger-এ unique-constraint — effect আর dedup-record একই local txn-এ (outbox-ধাঁচ), নইলে একটা হারাবে। TTL চিরস্থায়ী-প্রায় (ledger-ই তো) — refund এমন domain যেখানে ‘s্মৃতি মোছা’ option-ই নেই; মানে exactly-once effect, সস্তায়।

এরপর কী

এরপর কী

Effect-layer-এ exactly-once কেনা গেল — এবার সেই effect-গুলো service-এর মাঝে কীভাবে বহন হয় সেটা দেখা যাক। পরের লেসনে message queues: RabbitMQ-র exchange-routing-push-ack জগৎ বনাম Kafka-র append-only partitioned log-offset-replay জগৎ — smart-broker বনাম smart-consumer, retention বনাম ack-delete, আর consumer-group- এর rebalance-storm; দুই দর্শনই আগের দুই লেসনের guarantee-র উপর দাঁড়িয়ে।

আরও পড়ুন