Skip to main content

Transactional Ledger With Holds

Initial Problem Statement​

You’re building an in-memory ledger for account balances with support for:

  • deposits / withdrawals
  • transactional semantics (begin/commit/rollback)
  • “holds” (pending debits) that expire
  • and available_balance vs posted_balance

Design a class:

class Ledger:
def deposit(self, accountId: str, amount: int) -> None
def withdraw(self, accountId: str, amount: int) -> bool

def place_hold(self, accountId: str, amount: int, holdId: str, timestampMs: int, ttlMs: int) -> bool
def release_hold(self, accountId: str, holdId: str) -> bool
def capture_hold(self, accountId: str, holdId: str) -> bool

def posted_balance(self, accountId: str, timestampMs: int) -> int
def available_balance(self, accountId: str, timestampMs: int) -> int

def begin(self) -> None
def commit(self) -> bool
def rollback(self) -> bool

Semantics​

Balances​

  • posted_balance: sum of completed deposits/withdrawals
  • available_balance: posted_balance minus any active holds (pending debits)

Deposits / withdrawals​

  • deposit: always succeeds; increases posted
  • withdraw: succeeds only if available_balance >= amount at time of call; decreases posted

Holds​

A hold represents a pending debit (like a card authorization):

  • place_hold succeeds only if available_balance >= amount at that time
  • A hold has (holdId, amount, expiresAt) where expiresAt = timestampMs + ttlMs
  • release_hold removes an active hold (no posted change)
  • capture_hold converts an active hold into a posted withdrawal:
    • removes the hold
    • subtracts hold amount from posted

Expiration​

When calling posted_balance or available_balance (and also when placing/capturing holds), the system should treat expired holds as inactive

You may assume timestamps for a given account are non-decreasing across calls

Transactions​

begin/commit/rollback apply to all operations above

  • begin() starts a transaction (can be nested)
  • During a transaction, all operations should be “tentative”
  • commit() persists the latest transaction layer into the next outer layer
  • rollback() discards changes in the latest layer

If commit() or rollback() is called with no open transaction, return false

Complexity requirements​

Aim for near O(1)O(1) amortized per operation Holds expiration should not require scanning all holds every time

Example​

L.deposit("A", 100)
L.place_hold("A", 30, "h1", t=0, ttl=10000) # true
L.available_balance("A", 0) -> 70
L.posted_balance("A", 0) -> 100

L.capture_hold("A", "h1") # true
L.posted_balance("A", 0) -> 70
L.available_balance("A", 0) -> 70

Constraints​

Initial Thoughts, Questions​

  • Explain expiresAt = timestampMs + ttlMs
    • timestampMs = the time when the hold is created, in milliseconds since some reference (epoch or monotonic clock; doesn’t matter for the problem)
    • ttlMs = time-to-live, in milliseconds — how long the hold should remain active
    • expiresAt = the exact timestamp (ms) when the hold becomes inactive
    • So if you place a hold at time 1000 with ttlMs=5000, then expiresAt=6000
    • At timestamps >= 6000, that hold is expired and should no longer reduce available balance
  • Are any API calls outside of a transaction immediately committed? If there was an API call without a transaction and a rollback() is called, do we remove the last API call? A better question is API + transaction1 + direct API call...if a rollback() is called do we remove direct API call or transaction1?
    • Operations outside any transaction are immediately committed
      • rollback() only affects the most recent open transaction layer
      • If there is no open transaction, rollback() returns false and does nothing
      • A direct API call after a committed block is not undone by rollback unless it was executed inside an open transaction
deposit(100)          # committed
begin()
withdraw(10) # tentative
deposit(5) # tentative
commit() # now both tentative ops become committed
deposit(20) # committed
rollback() # false (no open tx), does not undo deposit(20)
  • Are transactions considered strictly ordered, or are there ID's associated with them?
    • Transactions are strictly ordered by call sequence
    • There are no transaction IDs

Implementation​

  • Storing monetary amounts via tuples - Dollars would be BIG_INT outside of python, cents just INT
  • Transactions can be held on a stack where a new transaction gets push onto stack, and a commit pop one off
  • Holds themselves should be stored in a priority queue based on expiresAt
    • This priority queue will get drained during any action that checks available_balance, as holds directly affect only this part
    • Holds should also be stored in a dictionary by their holdId for O(1)O(1) access
    • Any capture_hold call will focus on expiresAt and time management
  • Always handle time based on parameters passed into API