Design transactional in-memory key-value store
Company: Snowflake
Role: Software Engineer
Category: Coding & Algorithms
Difficulty: hard
Interview Round: Technical Screen
Quick Answer: This question evaluates understanding of in-memory key–value store semantics, transactional and nested transaction behavior, and practical competencies in hash-based data structures and thread-safety for concurrent access.
Part 1: Basic in-memory key-value store
Constraints
- 0 <= len(operations) <= 200000
- Keys are strings of length 1 to 100
- Values are integers and are never None
- All operations are valid
Examples
Input: [('put', 'a', 1), ('get', 'a'), ('put', 'a', 2), ('get', 'a'), ('delete', 'a'), ('get', 'a')]
Expected Output: [1, 2, None]
Explanation: The value for 'a' is updated from 1 to 2, then deleted.
Input: [('get', 'missing'), ('delete', 'missing'), ('get', 'missing')]
Expected Output: [None, None]
Explanation: Getting a missing key returns None, and deleting a missing key is a no-op.
Input: []
Expected Output: []
Explanation: Edge case: no operations means no outputs.
Input: [('put', 'x', 10), ('put', 'y', 20), ('delete', 'x'), ('get', 'x'), ('get', 'y')]
Expected Output: [None, 20]
Explanation: After deleting 'x', only 'y' remains.
Solution
def solution(operations):
store = {}
result = []
for op in operations:
cmd = op[0]
if cmd == 'put':
_, key, value = op
store[key] = value
elif cmd == 'get':
_, key = op
result.append(store.get(key, None))
elif cmd == 'delete':
_, key = op
store.pop(key, None)
return resultTime complexity: O(n) total for n operations. Space complexity: O(u), where u is the number of keys currently stored.
Hints
- A Python dict supports average O(1) get, set, and delete.
- Only 'get' operations add something to the output list.
Part 2: Transactional key-value store with nested transactions
Constraints
- 0 <= len(operations) <= 200000
- Maximum nested transaction depth is at most len(operations)
- Keys are strings of length 1 to 100
- Values are integers and are never None
- All operations are valid
Examples
Input: [('put', 'a', 1), ('begin',), ('put', 'a', 2), ('get', 'a'), ('begin',), ('delete', 'a'), ('get', 'a'), ('rollback',), ('get', 'a'), ('commit',), ('get', 'a')]
Expected Output: [2, None, True, 2, True, 2]
Explanation: The inner delete is rolled back, so 'a' becomes 2 again. The outer commit keeps that value.
Input: [('commit',), ('rollback',), ('get', 'x')]
Expected Output: [False, False, None]
Explanation: Edge case: there is no active transaction to commit or roll back.
Input: [('put', 'k', 5), ('begin',), ('delete', 'k'), ('get', 'k'), ('rollback',), ('get', 'k')]
Expected Output: [None, True, 5]
Explanation: Deleting inside a transaction is reversible after rollback.
Input: [('put', 'x', 1), ('begin',), ('put', 'x', 2), ('begin',), ('put', 'x', 3), ('commit',), ('get', 'x'), ('rollback',), ('get', 'x')]
Expected Output: [True, 3, True, 1]
Explanation: The inner commit keeps x=3 inside the parent transaction, but rolling back the parent restores x=1.
Input: []
Expected Output: []
Explanation: Edge case: no operations.
Solution
def solution(operations):
store = {}
tx_stack = []
result = []
for op in operations:
cmd = op[0]
if cmd == 'begin':
tx_stack.append({})
elif cmd == 'put':
_, key, value = op
if tx_stack:
log = tx_stack[-1]
if key not in log:
log[key] = (key in store, store.get(key))
store[key] = value
else:
store[key] = value
elif cmd == 'delete':
_, key = op
if tx_stack:
log = tx_stack[-1]
if key not in log:
log[key] = (key in store, store.get(key))
store.pop(key, None)
else:
store.pop(key, None)
elif cmd == 'get':
_, key = op
result.append(store.get(key, None))
elif cmd == 'commit':
if not tx_stack:
result.append(False)
else:
log = tx_stack.pop()
if tx_stack:
parent = tx_stack[-1]
for key, old_state in log.items():
if key not in parent:
parent[key] = old_state
result.append(True)
elif cmd == 'rollback':
if not tx_stack:
result.append(False)
else:
log = tx_stack.pop()
for key, (had_key, old_value) in log.items():
if had_key:
store[key] = old_value
else:
store.pop(key, None)
result.append(True)
return resultTime complexity: get/put/delete/begin are O(1) average; commit and rollback are O(k), where k is the number of distinct keys changed in the current transaction. Space complexity: O(u + p), where u is the number of keys in the current visible store and p is the total number of keys recorded in active transaction logs.
Hints
- When a transaction first changes a key, record what that key looked like before the change.
- On nested commit, the current visible state should stay as-is; only the undo information needs to be merged upward.
Part 3: Concurrent transactional key-value store
Constraints
- 0 <= len(operations) <= 200000
- 1 <= number of distinct thread IDs <= 100000
- Maximum nested transaction depth per thread is at most len(operations)
- Keys are strings of length 1 to 100
- Values are integers and are never None
- All operations are valid and should be processed exactly in the given order
Examples
Input: [('put', 1, 'x', 1), ('begin', 2), ('get', 2, 'x'), ('put', 2, 'x', 5), ('get', 2, 'x'), ('get', 1, 'x'), ('commit', 2), ('get', 1, 'x')]
Expected Output: [1, 5, 1, True, 5]
Explanation: Thread 2 sees its own uncommitted write, while thread 1 does not see it until thread 2 commits.
Input: [('begin', 1), ('put', 1, 'a', 10), ('begin', 1), ('delete', 1, 'a'), ('get', 1, 'a'), ('get', 2, 'a'), ('rollback', 1), ('get', 1, 'a'), ('commit', 1), ('get', 2, 'a')]
Expected Output: [None, None, True, 10, True, 10]
Explanation: The inner delete is visible only to thread 1 and is undone by rollback. After the outer commit, thread 2 can see the value.
Input: [('commit', 3), ('rollback', 3), ('put', 1, 'k', 7), ('delete', 2, 'k'), ('get', 1, 'k')]
Expected Output: [False, False, None]
Explanation: Edge case: thread 3 has no active transaction. Thread 2 deletes 'k' globally because it is not inside a transaction.
Input: [('begin', 1), ('put', 1, 'x', 2), ('begin', 1), ('put', 1, 'x', 3), ('commit', 1), ('get', 1, 'x'), ('get', 2, 'x'), ('rollback', 1), ('get', 1, 'x')]
Expected Output: [True, 3, None, True, None]
Explanation: The child commit merges into thread 1's parent transaction, but thread 2 still cannot see the value until the outermost commit.
Input: [('put', 1, 'v', 1), ('begin', 2), ('get', 2, 'v'), ('delete', 1, 'v'), ('get', 2, 'v')]
Expected Output: [1, None]
Explanation: This shows the model is not snapshot isolation: thread 2 begins before the delete, but a later read still sees the latest global committed state.
Solution
def solution(operations):
TOMBSTONE = object()
global_store = {}
stacks = {}
result = []
def read_value(tid, key):
layers = stacks.get(tid, [])
for layer in reversed(layers):
if key in layer:
return None if layer[key] is TOMBSTONE else layer[key]
return global_store.get(key, None)
for op in operations:
cmd = op[0]
if cmd == 'begin':
_, tid = op
stacks.setdefault(tid, []).append({})
elif cmd == 'put':
_, tid, key, value = op
layers = stacks.get(tid, [])
if layers:
layers[-1][key] = value
else:
global_store[key] = value
elif cmd == 'delete':
_, tid, key = op
layers = stacks.get(tid, [])
if layers:
layers[-1][key] = TOMBSTONE
else:
global_store.pop(key, None)
elif cmd == 'get':
_, tid, key = op
result.append(read_value(tid, key))
elif cmd == 'commit':
_, tid = op
layers = stacks.get(tid, [])
if not layers:
result.append(False)
else:
current = layers.pop()
if layers:
layers[-1].update(current)
else:
for key, value in current.items():
if value is TOMBSTONE:
global_store.pop(key, None)
else:
global_store[key] = value
if not layers:
stacks.pop(tid, None)
result.append(True)
elif cmd == 'rollback':
_, tid = op
layers = stacks.get(tid, [])
if not layers:
result.append(False)
else:
layers.pop()
if not layers:
stacks.pop(tid, None)
result.append(True)
return resultTime complexity: A get is O(d) for transaction depth d of that thread; a commit is O(k) for the number of keys in the committed layer; all other operations are O(1) average. Space complexity: O(u + p), where u is the number of committed keys in the global store and p is the total number of pending key updates across all active thread-local transaction layers.
Hints
- Keep one transaction stack per thread ID, plus one shared committed store.
- For a read, only the calling thread's own transaction layers matter before you fall back to the shared committed store.