<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Pessimistic-Locking on Corebaseit — POS · EMV · Payments · AI · Telecommunications</title><link>https://corebaseit.com/tags/pessimistic-locking/</link><description>Recent content in Pessimistic-Locking on Corebaseit — POS · EMV · Payments · AI · Telecommunications</description><generator>Hugo -- gohugo.io</generator><language>en-us</language><managingEditor>contact@corebaseit.com (Vincent Bevia)</managingEditor><webMaster>contact@corebaseit.com (Vincent Bevia)</webMaster><lastBuildDate>Tue, 21 Jul 2026 10:00:00 +0200</lastBuildDate><atom:link href="https://corebaseit.com/tags/pessimistic-locking/index.xml" rel="self" type="application/rss+xml"/><item><title>The €400 Wallet Problem: Concurrency, Lost Updates, and Where to Serialize</title><link>https://corebaseit.com/corebaseit_posts/wallet-concurrency-serialization/</link><pubDate>Tue, 21 Jul 2026 10:00:00 +0200</pubDate><author>contact@corebaseit.com (Vincent Bevia)</author><guid>https://corebaseit.com/corebaseit_posts/wallet-concurrency-serialization/</guid><description>&lt;img src="https://corebaseit.com/diagrams/wallet-concurrency.png" alt="Featured image of post The €400 Wallet Problem: Concurrency, Lost Updates, and Where to Serialize" />&lt;p>Benefit wallets, stored-value accounts, and employer-funded allowances share a constraint that card authorization alone does not solve: a &lt;strong>finite spendable balance&lt;/strong> that multiple terminals can draw against at the same time. Two POS devices can send two valid authorization requests — different transaction IDs, different STANs, both well-formed — and still overspend the underlying wallet if the backend reads the balance before either write completes.&lt;/p>
&lt;p>This is the classic &lt;strong>lost update&lt;/strong> problem applied to payment ledgers. It is distinct from duplicate charging on retry, which is an idempotency problem when the same payment intent is submitted twice. Here the intents are different; the race is on shared mutable state. This post is really about one architectural question: where should serialization happen? Should concurrent access to the same wallet be serialized in the database, in the application, in the messaging layer, or in the domain model itself? The answer determines the trade-offs between throughput, latency, complexity, and correctness.&lt;/p>
&lt;h2 id="the-scenario">The scenario
&lt;/h2>&lt;p>An employee has a wellness allowance:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>Wallet balance: €400
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>At the same instant, two POS terminals each submit a €300 charge against the same employee wallet. The requests are independent: different transaction IDs, different terminals, and both arriving concurrently. The requests are independent: different transaction IDs, different terminals, both arriving concurrently.&lt;/p>
&lt;p>Without coordination, both transactions can execute:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>SELECT balance → 400
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>400 &amp;gt;= 300 → YES (both)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>UPDATE balance → 100 (both write 100)
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Both approvals succeed. Total spend: &lt;strong>€600&lt;/strong> from a &lt;strong>€400&lt;/strong> wallet.&lt;/p>
&lt;p>The failure mode is not fraud at the terminal layer. It is &lt;strong>read-modify-write without serialization&lt;/strong> on shared wallet state.&lt;/p>
&lt;h2 id="why-this-is-not-solved-by-idempotency-alone">Why this is not solved by idempotency alone
&lt;/h2>&lt;p>Idempotency keys collapse duplicate submissions of the &lt;strong>same&lt;/strong> payment intent. They do not protect against two &lt;strong>different&lt;/strong> intents racing on the same balance.&lt;/p>
&lt;p>In production, both mechanisms are required:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Problem&lt;/th>
&lt;th>Same intent submitted twice&lt;/th>
&lt;th>Two different intents, one balance&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Typical cause&lt;/td>
&lt;td>Network timeout, blind retry&lt;/td>
&lt;td>Concurrent terminals, parallel API calls&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Primary defense&lt;/td>
&lt;td>Idempotency key / STAN deduplication&lt;/td>
&lt;td>Serialization on wallet or reservation row&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Related post&lt;/td>
&lt;td>&lt;a class="link" href="https://corebaseit.com/corebaseit_posts/double-charging/" >Double charges&lt;/a>&lt;/td>
&lt;td>This post&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>&lt;em>Point-of-Sale Systems Architecture&lt;/em>, Ch. 15 (Backend Architecture for POS), treats idempotency at the API gateway and STAN/RRN deduplication at the ISO boundary as hard invariants. Wallet-backed products add a second invariant: &lt;strong>no two concurrent debits may pass a balance check against the same stale read&lt;/strong>.&lt;/p>
&lt;h2 id="option-1--pessimistic-locking">Option 1 — Pessimistic locking
&lt;/h2>&lt;p>The direct fix: lock the wallet row before reading and updating it.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-sql" data-lang="sql">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#66d9ef">BEGIN&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#66d9ef">SELECT&lt;/span> &lt;span style="color:#f92672">*&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#66d9ef">FROM&lt;/span> wallet
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#66d9ef">WHERE&lt;/span> wallet_id &lt;span style="color:#f92672">=&lt;/span> &lt;span style="color:#ae81ff">123&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#66d9ef">FOR&lt;/span> &lt;span style="color:#66d9ef">UPDATE&lt;/span>;
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Transaction A acquires the row lock. Transaction B blocks until A commits.&lt;/p>
&lt;p>Transaction A reads €400, debits €300, writes €100, commits. The lock releases. Transaction B wakes up, reads €100, checks &lt;code>100 &amp;gt;= 300&lt;/code>, declines.&lt;/p>
&lt;p>&lt;strong>Advantages&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>Simple to reason about inside a single database&lt;/li>
&lt;li>ACID guarantees on the balance check and write&lt;/li>
&lt;li>Overspend is impossible while the lock is held&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Disadvantages&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>Throughput drops under contention — concurrent requests on the same wallet queue behind the lock&lt;/li>
&lt;li>Lock duration must stay short; holding a lock across an external PSP call is dangerous&lt;/li>
&lt;li>Hot wallets can become serialization bottlenecks because every update to the same wallet row must wait, even when the application is horizontally scaled&lt;/li>
&lt;/ul>
&lt;p>For a single PostgreSQL backend with moderate contention — one employee wallet receiving relatively infrequent concurrent requests — pessimistic locking is a defensible default. I would start here unless measurement proves contention is the bottleneck.&lt;/p>
&lt;h2 id="option-2--optimistic-locking">Option 2 — Optimistic locking
&lt;/h2>&lt;p>Add a version column to the wallet row:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>balance = 400
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>version = 5
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Transaction A:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-sql" data-lang="sql">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#66d9ef">UPDATE&lt;/span> wallet
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#66d9ef">SET&lt;/span> balance &lt;span style="color:#f92672">=&lt;/span> &lt;span style="color:#ae81ff">100&lt;/span>, &lt;span style="color:#66d9ef">version&lt;/span> &lt;span style="color:#f92672">=&lt;/span> &lt;span style="color:#ae81ff">6&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#66d9ef">WHERE&lt;/span> wallet_id &lt;span style="color:#f92672">=&lt;/span> &lt;span style="color:#ae81ff">123&lt;/span> &lt;span style="color:#66d9ef">AND&lt;/span> &lt;span style="color:#66d9ef">version&lt;/span> &lt;span style="color:#f92672">=&lt;/span> &lt;span style="color:#ae81ff">5&lt;/span>;
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>One row updated. Success.&lt;/p>
&lt;p>Transaction B attempts the same &lt;code>WHERE version = 5&lt;/code> update. Zero rows affected. The application retries: re-reads balance (€100), re-evaluates, declines.&lt;/p>
&lt;p>&lt;strong>Advantages&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>No held locks during the read phase&lt;/li>
&lt;li>Strong under &lt;strong>low contention&lt;/strong> — most updates succeed on the first attempt&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Disadvantages&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>Under heavy contention on the same wallet, retry storms increase latency and load&lt;/li>
&lt;li>Application must implement retry logic with bounds (max attempts, backoff, decline on exhaustion)&lt;/li>
&lt;li>Still requires a single authoritative store for the versioned row&lt;/li>
&lt;/ul>
&lt;p>Optimistic locking fits when wallet collisions are rare — many wallets, few concurrent writes per wallet. It is a weaker default when two terminals charging the same employee allowance is a normal Tuesday.&lt;/p>
&lt;h2 id="option-3--serialize-by-partition-key-event-stream">Option 3 — Serialize by partition key (event stream)
&lt;/h2>&lt;p>Move balance mutations behind ordered consumption by partitioning events on walletId. All commands for the same wallet are routed to the same partition, and that partition is owned by only one consumer in the consumer group at a time.&lt;/p>
&lt;p>Instead of updating the wallet directly from two concurrent HTTP handlers, publish:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>ReserveWallet { walletId: 123, amount: 300, paymentId: A }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>ReserveWallet { walletId: 123, amount: 300, paymentId: B }
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Partition the topic by &lt;code>walletId&lt;/code>. Kafka (or any log with keyed partitioning) guarantees that all events for wallet 123 land on the same partition and are consumed &lt;strong>in order&lt;/strong> by one consumer in the group.&lt;/p>
&lt;p>The consumer processes sequentially:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>400 → reserve 300 → 100 → reserve 300 → decline
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>No database row lock during the HTTP request path. The messaging layer establishes an ordered processing path for each wallet, enabling the consumer to serialize its balance mutations.&lt;/p>
&lt;p>&lt;strong>Advantages&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>Decouples the authorization API from the ledger write rate&lt;/li>
&lt;li>Scales horizontally across wallets — wallet 456 and wallet 123 do not block each other&lt;/li>
&lt;li>Natural fit for event-sourced or CQRS ledger designs&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Disadvantages&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>End-to-end latency increases — the API must wait for the consumer result or accept async confirmation&lt;/li>
&lt;li>Consumer failure, replay, and at-least-once delivery require idempotent handlers&lt;/li>
&lt;li>Partition key choice is permanent for ordering; a wrong key shards related events incorrectly&lt;/li>
&lt;/ul>
&lt;p>This is the pattern I reach for when wallet volume grows beyond what a single database writer can sustain, or when the ledger is already event-driven. &lt;em>Point-of-Sale Systems Architecture&lt;/em>, Ch. 15, notes synchronous replication and careful idempotency as prerequisites for zero-loss backends; partitioned consumption extends that model by making &lt;strong>per-wallet ordering&lt;/strong> an explicit architectural choice rather than an accident of database locking.&lt;/p>
&lt;p>Kafka does not eliminate the need for transactional correctness. It guarantees that all events for the same wallet are delivered to the same partition and processed by a single consumer within the consumer group. That ordering enables serialization, but the consumer must still perform the balance check and ledger update atomically within its own database transaction. Kafka solves ordering; PostgreSQL (or another transactional store) still protects correctness.&lt;/p>
&lt;h2 id="option-4--reservation-model">Option 4 — Reservation model
&lt;/h2>&lt;p>Many wallet and payment systems do not debit immediately. They &lt;strong>reserve&lt;/strong> first, then &lt;strong>capture&lt;/strong> or &lt;strong>release&lt;/strong>.&lt;/p>
&lt;p>Initial state:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>Initial
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Available: 400
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Reserved: 0
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Captured: 0
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>↓
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Reserve €300
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Available: 100
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Reserved: 300
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Captured: 0
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>↓
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Capture
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Available: 100
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Reserved: 0
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Captured: 300
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>↓
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>(or)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Release
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Available: 400
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Reserved: 0
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Captured: 0
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Transaction A reserves €300:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>Available: 100
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Reserved: 300
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Transaction B attempts to reserve €300 against available €100. Fails — regardless of whether the check uses a lock, a version column, or a serialized consumer.&lt;/p>
&lt;p>Later, on payment success, reserved funds move to captured/settled. On failure or timeout, the reservation is released back to available.&lt;/p>
&lt;p>This is the same structural pattern as &lt;strong>pre-authorization and capture&lt;/strong> in card processing — an authorization hold reserves issuer open-to-buy without clearing funds. I cover the card-side hold lifecycle in &lt;em>Point-of-Sale Systems Architecture&lt;/em>, Ch. 13 (Pre-Authorization and Completion Flows). Benefit wallets apply the idea on the merchant or platform ledger instead of the issuer account.&lt;/p>
&lt;p>&lt;strong>Advantages&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>Separates &amp;ldquo;funds committed for this attempt&amp;rdquo; from &amp;ldquo;funds permanently spent&amp;rdquo;&lt;/li>
&lt;li>Timeouts and abandoned checkouts release reserved balance cleanly&lt;/li>
&lt;li>Aligns with two-phase payment flows: reserve locally, call external PSP, capture or release&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Disadvantages&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>More states to manage: pending, captured, released, expired&lt;/li>
&lt;li>Requires a sweeper for stale reservations&lt;/li>
&lt;li>Reservation and capture must both be idempotent under retry&lt;/li>
&lt;/ul>
&lt;p>Notice that these approaches are not mutually exclusive. A production payment platform may reserve funds using pessimistic locking, publish state changes through an outbox, serialize downstream processing by Kafka partition, and still use optimistic concurrency inside independent read models. Real systems rarely choose a single technique—they combine them at different architectural boundaries.&lt;/p>
&lt;p>A reservation row with a unique &lt;code>(wallet_id, idempotency_key)&lt;/code> constraint and a lifecycle of &lt;code>PENDING → CAPTURED | RELEASED | EXPIRED&lt;/code> is the ledger pattern I would use for any wallet product that also calls an external payment provider. The wallet reservation is the correctness boundary; the PSP call cannot share that database transaction.&lt;/p>
&lt;p>Why introduce reservations instead of debiting immediately? Because external payment providers sit outside your database transaction. The wallet cannot remain locked while waiting for a network call that might take seconds or fail entirely. Reserving funds establishes a local correctness boundary before calling the external provider. Once the provider responds, the reservation is either captured or released.&lt;/p>
&lt;h2 id="which-approach-fits-where">Which approach fits where?
&lt;/h2>&lt;p>There is no universal winner. The choice is where you want &lt;strong>serialization&lt;/strong> to happen:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Layer&lt;/th>
&lt;th>Mechanism&lt;/th>
&lt;th>Best when&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Database&lt;/td>
&lt;td>&lt;code>SELECT … FOR UPDATE&lt;/code>&lt;/td>
&lt;td>Single Postgres, moderate contention, team wants simplicity&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Application&lt;/td>
&lt;td>Optimistic version retries&lt;/td>
&lt;td>Many wallets, low per-wallet collision rate&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Messaging&lt;/td>
&lt;td>Partition by &lt;code>walletId&lt;/code>&lt;/td>
&lt;td>High scale, event-driven ledger, async acceptance OK&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Domain model&lt;/td>
&lt;td>Available / reserved split&lt;/td>
&lt;td>External PSP in the path, holds and timeouts matter&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>A reasonable evolution:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>Correctness baseline:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>PostgreSQL transaction
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>+ atomic balance check
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>+ wallet reservation
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Scaling option:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Partition commands by walletId
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>+ ordered consumer processing
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Reliable event propagation:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Transactional outbox
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>+ idempotent consumers
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>These are not necessarily sequential maturity stages. Reservations solve the business problem of committing funds across an external payment flow, while locking, versioning, and partitioned consumption determine how concurrent reservation attempts are serialized. An implementation may use reservations and PostgreSQL locking from day one, then introduce partitioned processing only when throughput requires it.&lt;/p>
&lt;h2 id="connection-to-pos-and-backend-design">Connection to POS and backend design
&lt;/h2>&lt;p>Terminal-side correctness — EMV cryptograms, idempotent STAN on retry, reversal on timeout — does not protect the wallet ledger if the backend approves two concurrent debits. The POS did its job. The failure is backend concurrency.&lt;/p>
&lt;p>Backend architects should verify:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Balance checks and writes are atomic&lt;/strong> — same transaction, or serialized queue, or reservation with row lock.&lt;/li>
&lt;li>&lt;strong>Idempotency keys are scoped per payment intent&lt;/strong> — &lt;code>(wallet_id, idempotency_key)&lt;/code> unique constraint.&lt;/li>
&lt;li>&lt;strong>External PSP calls sit outside the lock&lt;/strong> — reserve locally, call provider, capture or release; never hold a wallet lock across network I/O.&lt;/li>
&lt;li>&lt;strong>Cached balances are derived&lt;/strong> — the ledger or reservation table is source of truth; balance views are rebuildable.&lt;/li>
&lt;li>&lt;strong>Reconciliation catches drift&lt;/strong> — compare ledger totals, reservation ages, and provider settlement files.&lt;/li>
&lt;/ol>
&lt;p>Ch. 6 (POS Application Architecture) emphasizes deterministic state machines and idempotency at the authorization client. Ch. 14 (Offline and Store-and-Forward) adds atomic queue operations and duplicate suppression on sync. Those terminal-side controls are necessary but not sufficient when the spend limit lives in a shared wallet.&lt;/p>
&lt;h2 id="confirmed-facts-vs-interpretation">Confirmed facts vs interpretation
&lt;/h2>&lt;p>&lt;strong>Confirmed&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>Concurrent read-modify-write without serialization produces lost updates (standard database concurrency theory).&lt;/li>
&lt;li>PostgreSQL &lt;code>SELECT … FOR UPDATE&lt;/code> acquires a row-level lock until transaction end.&lt;/li>
&lt;li>Kafka keyed partitioning delivers ordered consumption per partition within a consumer group.&lt;/li>
&lt;li>Optimistic concurrency control uses a version column; conflicting updates affect zero rows and require application retry.&lt;/li>
&lt;li>Pre-authorization in card processing reserves funds without immediate capture (scheme and issuer behavior; see Ch. 13).&lt;/li>
&lt;li>Idempotency keys prevent duplicate processing of the same request; they do not serialize different concurrent requests against shared balance.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Interpretation&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>Pessimistic locking on PostgreSQL is an acceptable default for wallet backends at moderate scale before introducing streaming infrastructure.&lt;/li>
&lt;li>Partition-by-&lt;code>walletId&lt;/code> is the natural Staff-level evolution when a single writer becomes the bottleneck — not because Kafka is fashionable, but because per-wallet ordering is an explicit product requirement.&lt;/li>
&lt;li>The reservation model is the correct domain shape when an external PSP sits in the path and &amp;ldquo;approved at the terminal&amp;rdquo; is not the same as &amp;ldquo;captured in the ledger.&amp;rdquo;&lt;/li>
&lt;li>The interesting design question is not which technology wins, but &lt;strong>where serialization belongs&lt;/strong> for your contention profile, latency budget, and failure modes.&lt;/li>
&lt;/ul>
&lt;h2 id="takeaway">Takeaway
&lt;/h2>&lt;p>Two terminals. One wallet. €400 available. Two €300 payment requests arriving at the same instant.&lt;/p>
&lt;p>That scenario looks simple, yet it exposes one of the oldest problems in distributed systems: &lt;strong>concurrent access to shared state&lt;/strong>.&lt;/p>
&lt;p>The solution is rarely just “add a lock” or “use Kafka.” The real architectural decision is &lt;strong>where serialization belongs&lt;/strong>:&lt;/p>
&lt;ul>
&lt;li>A database lock serializes access at the storage layer.&lt;/li>
&lt;li>Optimistic versioning detects conflicts and resolves them through bounded retries.&lt;/li>
&lt;li>A partitioned event stream creates an ordered processing path for each wallet.&lt;/li>
&lt;li>A reservation ledger commits business intent before external payment execution.&lt;/li>
&lt;/ul>
&lt;p>Most production payment platforms combine several of these mechanisms rather than relying on only one.&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>Good payment systems do not prevent concurrent requests. They make concurrent requests deterministic.&lt;/strong>&lt;/p>&lt;/blockquote>
&lt;p>Idempotency stops the same payment intent from being processed twice. Serialization stops two different payment intents from spending the same balance concurrently. Payment backends need both.&lt;/p>
&lt;h2 id="references">References
&lt;/h2>&lt;p>Primary reference:&lt;/p>
&lt;ul>
&lt;li>Bevia, V. &lt;em>Point-of-Sale Systems Architecture: A Practical Guide to Secure, Certifiable POS Systems&lt;/em>. &lt;a class="link" href="https://corebaseit.com/books/point-of-sale-systems-architecture/" >Book page&lt;/a>. Chapters most relevant here:
&lt;ul>
&lt;li>Ch. 4 — Wallets and Alternative Payment Methods&lt;/li>
&lt;li>Ch. 6 — POS Application Architecture (state machines, idempotency)&lt;/li>
&lt;li>Ch. 13 — Pre-Authorization and Completion Flows (hold/reserve/capture pattern)&lt;/li>
&lt;li>Ch. 14 — Offline and Store-and-Forward (atomic operations, duplicate suppression)&lt;/li>
&lt;li>Ch. 15 — Backend Architecture for POS (idempotency invariants, transaction processor)&lt;/li>
&lt;li>Ch. 19 — Case Studies (idempotency as non-negotiable)&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;p>Related Corebaseit posts:&lt;/p>
&lt;ul>
&lt;li>&lt;a class="link" href="https://corebaseit.com/corebaseit_posts/double-charging/" >Double Charges: The Hardest Problem in Payments&lt;/a> — idempotency and retry safety (complementary problem)&lt;/li>
&lt;li>&lt;a class="link" href="https://corebaseit.com/corebaseit_posts/partial_approvals_and_how_terminals/" >Partial Approvals: What Terminals and Hosts Must Do&lt;/a> — when approved amount differs from requested&lt;/li>
&lt;li>&lt;a class="link" href="https://corebaseit.com/corebaseit_posts/manual-capture/" >Manual Capture: When Authorization Is Not Settlement&lt;/a> — capture idempotency and hold lifecycle&lt;/li>
&lt;li>&lt;a class="link" href="https://corebaseit.com/corebaseit_posts/reversals-refunds-chargebacks-payment-lifecycle/" >Reversals, Refunds, and Chargebacks&lt;/a> — releasing reserved funds&lt;/li>
&lt;/ul>
&lt;p>External references:&lt;/p>
&lt;ul>
&lt;li>PostgreSQL Documentation, &lt;a class="link" href="https://www.postgresql.org/docs/current/explicit-locking.html" target="_blank" rel="noopener"
>Explicit Locking&lt;/a> — &lt;code>FOR UPDATE&lt;/code> row-level locks&lt;/li>
&lt;li>Apache Kafka Documentation, &lt;a class="link" href="https://kafka.apache.org/documentation/#semantics" target="_blank" rel="noopener"
>Partitioning and ordering guarantees&lt;/a>&lt;/li>
&lt;li>Kleppmann, M. &lt;em>Designing Data-Intensive Applications&lt;/em> (O&amp;rsquo;Reilly) — Ch. 7 on transactions and Ch. 11 on stream processing (lost update, optimistic locking, partitioned logs)&lt;/li>
&lt;/ul></description></item></channel></rss>