Prev Next

Database / Mnesia intermediate to advanced Interview questions

1. What is the difference between mnesia:transaction/1 and mnesia:sync_transaction/1? 2. What is the difference between async_dirty and sync_dirty activity contexts? 3. How does mnesia:activity/4 let you customize the access context of an operation? 4. What is table fragmentation in Mnesia and why would you use it? 5. How do you configure a fragmented Mnesia table? 6. Why does fragmentation help Mnesia scale beyond what one table replica set can handle? 7. What is the difference between mnesia:match_object/1 and mnesia:select/2? 8. What is mnesia:all_keys/1 used for? 9. How do you integrate Mnesia with QLC for complex queries? 10. Why would you use qlc:q with mnesia:table/1 instead of mnesia:select/2? 11. What is a sticky lock in Mnesia and why does it improve performance? 12. When can sticky locks cause a problem in a distributed Mnesia cluster? 13. How do you dynamically add a new node to a running Mnesia cluster? 14. What is mnesia:change_config(extra_db_nodes, Nodes) used for? 15. How do you remove a table replica from a node without dropping the whole table? 16. What is mnesia:del_table_copy/2 used for? 17. How do you move a table copy from one node to another while the system stays live? 18. What is mnesia:move_table_copy/3 used for? 19. What is the {majority, true} table option and what tradeoff does it introduce? 20. Why would you enable the majority option on a table prone to network partitions? 21. What is a local_content table in Mnesia? 22. When would you use a local_content table instead of a normal replicated table? 23. What is mnesia:set_master_nodes/2 used for? 24. How does setting master nodes influence conflict resolution after a network partition? 25. What is mnesia_tm and what role does it play internally? 26. How does Mnesia's transaction manager serialize concurrent transactions touching the same records? 27. Why can a "hot" record or table become a bottleneck under heavy Mnesia write load? 28. How can you redesign a schema to reduce lock contention on a frequently-updated record? 29. What is mnesia:subscribe/1 used for? 30. How do you react to table events using Mnesia's subscription mechanism? 31. What is a nested Mnesia transaction and how does it behave differently from a top-level one? 32. Why should nested transactions generally be avoided or used carefully in Mnesia? 33. What is the dc_dump_limit configuration parameter used for? 34. Why does tuning the transaction log dump frequency matter for write-heavy Mnesia workloads? 35. How do you merge two previously-separate Mnesia clusters into one? 36. What complications arise when merging schemas from two independently-created Mnesia databases? 37. How does Mnesia decide which node's data wins during schema merge conflicts? 38. What is the difference between a global lock and a per-record lock in Mnesia transactions? 39. When would you use mnesia:read_lock_table/1 instead of relying on per-record locks? 40. How do you profile and optimize Mnesia transaction throughput in a write-heavy system? 41. Why is batching multiple related writes into a single transaction usually better than many small transactions? 42. How does Mnesia's schema interact with OTP release upgrades (appup/relup)? 43. What is the difference between mnesia:dirty_all_keys/1 and mnesia:all_keys/1? 44. Explain the internal working of how Mnesia chooses which replica to read from in a load-balanced cluster? 45. What is the where_to_read table_info item used for? 46. What is mnesia:dump_log/0 used for? 47. How do you handle a transaction that needs to retry after being aborted due to a deadlock? 48. What is the difference between mnesia:transaction/1's built-in behavior and manual retry logic you might add?
Could not find what you were looking for? send us the question and we would be happy to answer your question.

1. What is the difference between mnesia:transaction/1 and mnesia:sync_transaction/1?

Both give the same atomicity and locking guarantees, but they differ in when they return control to the caller relative to replication. mnesia:transaction/1 can return as soon as the commit is decided locally, while replication to other nodes finishes asynchronously in the background. mnesia:sync_transaction/1 waits until the commit has actually been acknowledged by every involved replica before returning.

mnesia:sync_transaction(fun() ->
    mnesia:write(#session{id = 1, user = "ada"})
end).

The practical difference shows up under load: with plain transaction/1, a caller could get {atomic, ok} back and, in theory, briefly race ahead of replication actually landing everywhere. sync_transaction/1 removes that gap at the cost of waiting longer per call, which matters when a caller needs a hard guarantee that a subsequent read from any node in the cluster will already see the write it just made.

What does mnesia:sync_transaction/1 wait for that mnesia:transaction/1 doesn't?
When does the extra wait in sync_transaction/1 matter most?

2. What is the difference between async_dirty and sync_dirty activity contexts?

Both are non-transactional access contexts you can use with mnesia:activity/2 or by directly calling dirty functions, trading Mnesia's usual locking/atomicity for speed — the difference between them is, again, about replication timing rather than locking.

async_dirtysync_dirty
Returns as soon as the local write succeeds; replicates to other nodes in the background. Waits for the write to be applied on every replica before returning.
Fastest option; least consistency guarantee. Slightly slower; stronger guarantee that replicas are caught up when the call returns.
mnesia:activity(sync_dirty, fun() ->
    mnesia:write(#metric{id = 1, value = 42})
end).

Neither context provides transactional atomicity across multiple operations or locking against concurrent writers — both remain "dirty" in that sense; sync_dirty just narrows the window where a reader on another node could see stale data immediately after a write.

What is the key difference between async_dirty and sync_dirty?
Does sync_dirty provide the same locking/atomicity as a real transaction?

3. How does mnesia:activity/4 let you customize the access context of an operation?

mnesia:activity(AccessContext, Fun, Args, Module) is the general-purpose entry point underlying transaction/1, sync_transaction/1, and the dirty variants — those are really just convenience wrappers around activity/4 with a specific context pre-selected.

mnesia:activity(transaction, fun update_balance/2, [AccountId, Amount], mnesia).
mnesia:activity(async_dirty, fun log_event/1, [Event], mnesia).

Calling activity/4 directly is useful when you want to select the access context dynamically — for example, choosing between a transactional and a dirty path for the same piece of business logic based on a runtime flag, rather than hardcoding which wrapper function you call. The last argument (Module) lets you plug in a completely custom access-context implementation, which is also how table fragmentation's mnesia_frag module hooks into the same mechanism.

What are mnesia:transaction/1 and mnesia:sync_transaction/1 really wrappers around?
Why might you call mnesia:activity/4 directly instead of a convenience wrapper?

4. What is table fragmentation in Mnesia and why would you use it?

Fragmentation splits a single logical table into multiple physical fragments, each of which can live on a different node (or set of nodes), while application code still addresses it as one table name. It exists for tables whose size or write volume would overwhelm what a single, fully-replicated table (and its associated lock/transaction coordination) can comfortably handle.

flowchart LR
    A[Logical table: sessions] --> B[Fragment 1 - nodeA]
    A --> C[Fragment 2 - nodeB]
    A --> D[Fragment 3 - nodeC]

Records are distributed across fragments based on a hash of the key, so reads/writes for a given key are routed to the fragment that owns it rather than every node needing a full copy of all the data. This trades some added routing complexity for horizontal scalability, letting a dataset (and its write throughput) grow beyond what a single-fragment replicated table could handle.

What does fragmenting a table let you do that a normal replicated table can't?
How are records distributed across a fragmented table's fragments?

5. How do you configure a fragmented Mnesia table?

Fragmentation is set up via the frag_properties option on mnesia:create_table/2, specifying how many fragments to create and how to distribute them across nodes.

mnesia:create_table(sessions,
    [{frag_properties,
        [{node_pool, [node1@host, node2@host, node3@host]},
         {n_fragments, 3}]},
     {attributes, [id, user, expiry]}]).

Once configured, ordinary API calls like mnesia:write/1 and mnesia:read/1 work exactly as they would on a normal table — the fragmentation logic (routing to the right fragment based on key hash) is handled transparently underneath by the mnesia_frag access-context module, so application code doesn't need to know or care which fragment a given record actually lives in.

What option on create_table/2 configures fragmentation?
Does application code need to know which fragment a record lives in when using mnesia:write/1?

6. Why does fragmentation help Mnesia scale beyond what one table replica set can handle?

A normal, fully-replicated Mnesia table means every node holding a copy stores (and, for disc_copies, durably persists) the entire dataset, and every write must be coordinated and applied across all of those full copies via two-phase commit. Both the storage requirement and the write coordination cost scale with the whole table's size, no matter how many nodes you add.

Fragmentation breaks that link: each fragment is its own independently replicated unit, so a write to one key only needs to coordinate with the (smaller) set of nodes replicating that fragment, not the whole logical table. Storage is spread across fragments too, so the effective dataset size the system can hold grows with the number of fragments (and nodes) rather than being capped by what any single node/replica-set can store or coordinate writes across.

In a normal (non-fragmented) replicated table, what does every full replica need to store?
What does fragmentation change about write coordination cost?

7. What is the difference between mnesia:match_object/1 and mnesia:select/2?

Both let you query beyond a simple primary-key lookup, but match_object/1 uses a simpler, pattern-only interface, while select/2 supports full match specifications with guard conditions.

mnesia:match_object/1mnesia:select/2
A single pattern with `'_'` wildcards; returns whole matching records. Pattern + guard conditions (e.g. comparisons) + a custom result shape.
Simple to write for straightforward field-equality matches. More expressive: supports `>`, `<`, `andalso`, and returning only specific fields.
mnesia:match_object(#person{age = 30, _ = '_'}).
%% vs.
mnesia:select(person,
    [{#person{age = '$1', _ = '_'}, [{'>', '$1', 30}], ['$_']}]).

Use match_object/1 for a quick equality-style filter; reach for select/2 once you need comparisons, combined conditions, or a projection that returns less than the full record.

Which of the two supports comparison guards like `>` or `<` on fields?
When is mnesia:match_object/1 sufficient on its own?

8. What is mnesia:all_keys/1 used for?

mnesia:all_keys(Table) returns every primary key currently stored in a table, run inside a transaction (or a dirty context via mnesia:dirty_all_keys/1) — a quick way to enumerate a table's contents by key without writing a match specification.

mnesia:transaction(fun() ->
    mnesia:all_keys(person)
end).
%% -> {atomic, [1, 2, 3, 4]}

It's convenient for small-to-moderate tables (iterating configuration, listing registered sessions), but it does mean scanning the whole table's key space, so on a very large table a more targeted select/2 query with an actual filter condition is usually a better fit than pulling every key and filtering client-side afterward.

What does mnesia:all_keys/1 return?
Why might all_keys/1 be a poor choice on a very large table?

9. How do you integrate Mnesia with QLC for complex queries?

QLC (Query List Comprehension) provides a SQL-like, declarative query syntax over any "queryable" data source, and Mnesia tables can be exposed to it via mnesia:table/1, letting you write joins, sorting, and filtering across one or more tables in a single expression.

mnesia:transaction(fun() ->
    Q = qlc:q([P#person.name || P <- mnesia:table(person), P#person.age > 30]),
    qlc:e(Q)
end).

The real power shows up when joining across multiple tables — something a single select/2 call on one table can't express directly:

Q = qlc:q([{P#person.name, O#order.total} ||
    P <- mnesia:table(person),
    O <- mnesia:table(order),
    P#person.id =:= O#order.person_id]),

QLC queries still need to run inside a transaction (or another activity context) since they ultimately read from Mnesia tables, and QLC handles picking a reasonably efficient evaluation order/plan across the joined sources for you.

What does mnesia:table/1 do in a QLC query?
What can QLC express that a single mnesia:select/2 call cannot?

10. Why would you use qlc:q with mnesia:table/1 instead of mnesia:select/2?

select/2 is a single-table, match-specification-based query — excellent for filtering one table efficiently, but it can't express a relationship spanning multiple tables in one query. QLC's comprehension syntax reads much closer to how you'd describe the query in plain language (join these two tables, filter, sort), and it can combine several mnesia:table/1 sources in one expression.

%% Multi-table join with QLC
Q = qlc:q([{P#person.name, T#tag.tag} ||
    P <- mnesia:table(person),
    T <- mnesia:table(item_tag),
    P#person.id =:= T#item_tag.item_id]),
qlc:e(Q).

The tradeoff is that QLC's flexibility comes with more overhead than a tightly targeted select/2 call on a single table — for a simple single-table filter, plain select/2 (or even match_object/1) is usually the leaner, faster choice; QLC earns its keep specifically once the query genuinely needs to correlate data across more than one table.

What can QLC do that select/2 alone cannot?
When is plain select/2 usually the better choice over QLC?

11. What is a sticky lock in Mnesia and why does it improve performance?

Acquiring a write lock on a replicated table normally requires coordinating with every node holding a copy, even if, in practice, all the writes to a particular record consistently originate from the same node. A sticky lock lets a node "claim" ownership of a table's locking for itself after its first access, so subsequent lock requests from that same node can be granted locally without a fresh network round-trip to every other replica each time.

sequenceDiagram
    participant NodeA
    participant NodeB
    NodeA->>NodeB: First write: acquire lock (full coordination)
    NodeB-->>NodeA: Lock granted, marked sticky to NodeA
    NodeA->>NodeA: Subsequent writes: lock granted locally, no round-trip

This is a significant win specifically for workloads where one node is the dominant writer for a given table over a stretch of time — it turns a network round-trip per lock acquisition into a purely local operation. Mnesia manages sticky locks automatically as an internal optimization; there's no special API call needed to opt in.

What does a sticky lock let a node skip on subsequent lock requests?
What workload pattern benefits most from sticky locks?

12. When can sticky locks cause a problem in a distributed Mnesia cluster?

A sticky lock optimizes for the common case where the same node keeps writing to a table, but it means lock "ownership" has to be reclaimed if a different node suddenly needs to write — and if the node currently holding the sticky lock has become unreachable (crashed, network partition), reclaiming that lock can take noticeably longer than an ordinary non-sticky lock request would.

flowchart TD
    A[NodeB wants to write, lock is sticky to NodeA] --> B{Is NodeA reachable?}
    B -->|Yes| C[Lock ownership transferred to NodeB, then proceeds]
    B -->|No, NodeA down/partitioned| D[Extra delay reclaiming lock ownership before NodeB can proceed]

This is why sticky locks are a good fit for workloads with a genuinely stable, dominant writer per table, but a poor fit for workloads where write ownership legitimately shifts between nodes frequently — in that case, the overhead of repeatedly reclaiming sticky ownership can end up costing more than the round-trips it was meant to save.

What can happen if the node holding a sticky lock becomes unreachable and another node needs to write?
What workload pattern makes sticky locks a poor fit?

13. How do you dynamically add a new node to a running Mnesia cluster?

Bringing a new node into an already-running Mnesia cluster involves connecting it at the Erlang distribution level first, then explicitly telling Mnesia's schema about it via mnesia:change_config(extra_db_nodes, [NewNode]), which merges the new node into the existing schema rather than creating a separate one.

%% on the new node, after connecting to the cluster:
mnesia:start(),
mnesia:change_config(extra_db_nodes, ['existing_node@host']).

After this, the new node knows about the cluster's existing tables but doesn't automatically hold a copy of any of them — you still need mnesia:add_table_copy/3 for each table you want it to replicate. This two-step process (join the schema, then opt into specific tables) is deliberate: it lets a new node join as, say, a lightweight participant that only replicates a subset of tables relevant to its role.

What does mnesia:change_config(extra_db_nodes, [Node]) do?
After joining the schema, does the new node automatically replicate existing tables?

14. What is mnesia:change_config(extra_db_nodes, Nodes) used for?

This call tells the local Mnesia instance about additional nodes it should try to connect and merge schema information with — it's the mechanism behind both joining a brand-new node to an existing cluster and reconnecting a node that was started independently but should now become part of a shared database.

mnesia:change_config(extra_db_nodes, ['nodeb@host', 'nodec@host']).
%% -> {ok, ['nodeb@host', 'nodec@host']}

It returns which of the listed nodes it actually managed to connect and merge schema with — not every listed node is guaranteed to succeed, for instance if one is unreachable. This is typically run once during a node's startup sequence (often from an application's startup code or a release's boot script) rather than being called repeatedly during normal operation.

What does change_config(extra_db_nodes, Nodes) return?
When is this call typically made?

15. How do you remove a table replica from a node without dropping the whole table?

mnesia:del_table_copy(Table, Node) removes just one node's copy of a table from the schema and frees its local storage, while the table itself keeps running normally on every other node still holding a copy — it's the inverse of add_table_copy/3.

mnesia:del_table_copy(session, 'nodeb@host').

This is the right tool when you're decommissioning a node, or deliberately reducing how many replicas a table has (say, to lower write coordination overhead once you no longer need that level of redundancy), without wanting to disrupt the table's availability on the remaining nodes. As long as at least one replica remains somewhere in the cluster, the table's data isn't lost by this operation.

What does mnesia:del_table_copy(Table, Node) do?
When is del_table_copy/2 typically used?

16. What is mnesia:del_table_copy/2 used for?

mnesia:del_table_copy(Table, Node) updates the schema to stop treating the given node as a replica of the specified table, and reclaims whatever storage (memory and/or disk) that copy was using on that node — a schema-level operation, run as part of deliberate cluster maintenance rather than everyday application logic.

mnesia:transaction(fun() ->
    mnesia:del_table_copy(archive_log, 'old_node@host')
end).

Because this changes replication topology, it's schema-affecting and typically done during planned maintenance windows rather than as part of routine request handling — the same caution that applies to add_table_copy/3 or change_table_copy_type/3 applies here: verify the table still has sufficient remaining replicas for your durability/availability needs before removing this one.

What kind of operation is mnesia:del_table_copy/2 best described as?
What should you verify before removing a table copy from a node?

17. How do you move a table copy from one node to another while the system stays live?

mnesia:move_table_copy(Table, FromNode, ToNode) relocates a table's replica from one node to another without taking the table offline — useful for rebalancing load, retiring a node, or migrating data ahead of decommissioning old hardware.

mnesia:move_table_copy(session, 'old_node@host', 'new_node@host').

Internally, this is effectively an add_table_copy/3 onto the new node (copying the current data across) followed by a del_table_copy/2 on the old one, performed as a coordinated operation so the table remains continuously available for reads and writes throughout — readers and writers don't need to pause or retry while the move is in progress, though the operation itself takes time proportional to how much data has to be copied.

What does mnesia:move_table_copy/3 accomplish?
What is move_table_copy/3 effectively equivalent to internally?

18. What is mnesia:move_table_copy/3 used for?

This function is the standard tool for live data rebalancing and node decommissioning: moving a specific table's replica to a different node without a maintenance window where the table is unavailable.

mnesia:move_table_copy(person, 'nodeA@old_host', 'nodeD@new_host').

Common scenarios: migrating off aging hardware node by node, rebalancing which nodes hold which tables after adding new capacity to a cluster, or shrinking a table's footprint away from a node that's becoming overloaded. Because the table stays available throughout, this is preferred over a manual "delete then recreate" approach, which would risk a window of reduced availability or lost writes during the transition.

What is a typical scenario for using move_table_copy/3?
Why is move_table_copy/3 preferred over manually deleting and recreating a replica?

19. What is the {majority, true} table option and what tradeoff does it introduce?

Setting {majority, true} on a table requires that a write be acknowledged by a majority of the nodes holding a copy of that table before the transaction is allowed to commit — if a network partition leaves a node (or minority group of nodes) unable to reach a majority of replicas, writes on that isolated side are refused rather than silently accepted.

mnesia:create_table(critical_config,
    [{disc_copies, [n1, n2, n3]},
     {majority, true},
     {attributes, [key, value]}]).

This directly trades availability for consistency during a partition: the minority side can't write at all until connectivity is restored, whereas without the option, both sides of a partition could keep accepting (potentially conflicting) writes independently. It's the closest thing Mnesia offers to a quorum-based consistency guarantee, at the cost of write availability on whichever side of a split doesn't hold a majority.

What does {majority, true} require before a write can commit?
What tradeoff does the majority option introduce during a network partition?

20. Why would you enable the majority option on a table prone to network partitions?

Without the majority option, a partition can let two isolated groups of nodes both keep accepting writes to the same replicated table independently, setting up an inconsistent-database conflict that has to be manually or programmatically resolved once the partition heals — and any conflicting writes made on the losing side get discarded, meaning real data loss for whatever was written there during the split.

Enabling {majority, true} prevents that scenario for the table it's set on: the minority side simply can't commit writes at all while partitioned, so there's no conflicting history to reconcile afterward. This makes sense specifically for tables where correctness genuinely depends on there being one agreed-upon sequence of writes (critical shared configuration, a distributed lock table) — and makes less sense for tables where availability matters more than strict consistency during a split, since it directly reduces uptime for writes on a minority partition.

What problem does the majority option prevent during a network partition?
For which kind of table does enabling majority make the most sense?

21. What is a local_content table in Mnesia?

A local_content table is replicated in the sense that its schema exists on every listed node, but each node keeps its own independent data in it — writes on one node are never propagated to other nodes' copies, unlike a normal replicated table where every copy is meant to converge on the same content.

mnesia:create_table(node_stats,
    [{ram_copies, [node1@host, node2@host]},
     {local_content, true},
     {attributes, [metric, value]}]).

It's a way of using Mnesia's table/schema machinery for data that's genuinely node-specific by nature — per-node metrics, local caches, or node-specific logs — while still getting Mnesia's table management (starting, stopping, backup integration) for free, without the overhead or semantic mismatch of trying to keep genuinely different per-node data artificially "consistent" across replicas.

What makes a local_content table different from a normal replicated table?
What kind of data fits well in a local_content table?

22. When would you use a local_content table instead of a normal replicated table?

Reach for local_content specifically when the data is conceptually per-node rather than shared — each node's copy is meant to hold genuinely different information, not a synchronized replica of the same logical dataset. A normal replicated table is the wrong fit here since it's designed around every copy converging on identical content.

mnesia:create_table(node_health,
    [{ram_copies, [node()]},
     {local_content, true},
     {attributes, [check, status, checked_at]}]).

Typical examples: recording each node's own health-check results, keeping a node-local request cache that doesn't need to match other nodes' caches, or logging events specific to that node's own activity. If you find yourself wanting the exact same data visible identically from every node, that's the signal you actually want a normal replicated table, not local_content.

What is the deciding factor for choosing local_content over normal replication?
What is a good example use case for local_content?

23. What is mnesia:set_master_nodes/2 used for?

mnesia:set_master_nodes(Table, Nodes) designates specific nodes as the authoritative source for a table's data when Mnesia needs to decide which replica to trust — most notably when recovering from an inconsistent database state after a network partition, where by default Mnesia can't automatically pick a winner on its own.

mnesia:set_master_nodes(person, ['nodeA@host']).

By marking nodeA as the master for the person table, you're telling Mnesia that if a conflict needs resolving, this node's copy should be treated as correct, and other replicas should sync from it rather than the reverse. This is a proactive configuration step, typically set up as part of planned disaster-recovery procedures, rather than something decided reactively in the middle of an actual partition event.

What does designating a master node for a table influence?
When is set_master_nodes/2 typically configured?

24. How does setting master nodes influence conflict resolution after a network partition?

When a partition heals and Mnesia detects that a table's replicas diverged, its default behavior is to raise an inconsistent_database_event and leave the decision of which side to trust to an operator or a configured event handler, since it has no inherent basis to prefer one side over the other. Master node configuration removes that ambiguity ahead of time for a given table.

flowchart TD
    A[Partition heals, conflict detected] --> B{Master node configured for this table?}
    B -->|Yes| C[Master node's data treated as authoritative; other replicas resync from it]
    B -->|No| D[inconsistent_database_event raised; manual/handler resolution required]

With master nodes set, the recovery process becomes more automatic and predictable: replicas that disagree with the designated master simply resync to match it, rather than requiring someone to manually inspect both sides and decide (potentially under time pressure during an incident) which one to keep.

Without a configured master node, what does Mnesia do when it detects conflicting replicas?
What happens to non-master replicas that disagree with the master's data?

25. What is mnesia_tm and what role does it play internally?

mnesia_tm is Mnesia's internal transaction manager process — the component actually responsible for coordinating a transaction's lifecycle: acquiring locks, tracking what a transaction has read/written, driving the two-phase commit exchange with other nodes for replicated tables, and deciding whether to commit or abort.

flowchart LR
    A[mnesia:transaction Fun] --> B[mnesia_tm coordinates locks + commit protocol]
    B --> C[Local storage: ETS/DETS tables updated]
    B --> D[Remote replicas: 2PC coordination]

It's not something application code calls directly — it's the machinery behind mnesia:transaction/1 and related functions, handling the bookkeeping so your transaction function just reads and writes records without needing to manage locks or replication itself. Understanding its existence matters mainly for debugging deep issues (deadlocks, stuck transactions) where its internal state (visible via tracing or mnesia:info/0) is what you'd inspect.

What is mnesia_tm responsible for?
Does application code call mnesia_tm directly?

26. How does Mnesia's transaction manager serialize concurrent transactions touching the same records?

Mnesia uses pessimistic locking rather than optimistic concurrency control: when a transaction reads or writes a record, mnesia_tm acquires the appropriate lock (read or write) on that record before proceeding, and a conflicting lock request from another concurrent transaction simply has to wait until the first transaction releases it at commit or abort.

sequenceDiagram
    participant T1
    participant T2
    participant TM as mnesia_tm
    T1->>TM: request write lock on Key
    TM-->>T1: granted
    T2->>TM: request write lock on Key
    Note over TM: T2 blocks, waiting
    T1->>TM: commit, release lock
    TM-->>T2: lock granted, T2 proceeds

This guarantees serializability for conflicting operations without needing to detect and retry after the fact the way optimistic approaches do — the cost is that a transaction can block waiting on a lock held by another, which is exactly the scenario that can escalate into a deadlock if two transactions each hold a lock the other is waiting for, something mnesia_tm detects and resolves by aborting one of them.

Does Mnesia use pessimistic locking or optimistic concurrency control by default?
What happens if a transaction requests a lock already held by another transaction?

27. Why can a "hot" record or table become a bottleneck under heavy Mnesia write load?

Because Mnesia uses per-record (or sometimes per-table) locking inside transactions, any record that many concurrent transactions all need to write to becomes a serialization point: every transaction touching it has to wait its turn for the write lock, no matter how many CPU cores or schedulers are otherwise free. A single shared counter or a global "last updated" timestamp record are classic examples of accidental hot spots.

%% classic hot-spot pattern: every request updates the same record
mnesia:transaction(fun() ->
    [C] = mnesia:read({counter, global_hits}),
    mnesia:write(C#counter{value = C#counter.value + 1})
end).

As concurrency increases, throughput on that specific record plateaus (and transactions start piling up waiting on its lock) well before the rest of the system's capacity is exhausted, since the bottleneck isn't CPU or I/O — it's contention for one specific lock. This shows up as rising transaction latency and retries concentrated on operations touching that one hot record, even while the node's overall load looks otherwise unremarkable.

Why does a single frequently-updated record become a bottleneck under concurrency?
What symptom would you see from a hot record bottleneck?

28. How can you redesign a schema to reduce lock contention on a frequently-updated record?

The core fix is usually to split one hot, shared record into several independent pieces that different transactions can update without contending for the same lock, then combine them only when actually reading a final value.

%% instead of one shared counter record:
%% #counter{name = hits, value = N}

%% shard it across several independent records:
%% #counter_shard{shard_id = 1, value = N1}
%% #counter_shard{shard_id = 2, value = N2}
%% ...
%% writers pick a shard (e.g. by process/PID hash) to update independently;
%% readers sum across all shards for the total.

Other common techniques: moving the hot value to a dirty operation if strict consistency on every single increment isn't actually required (accepting some race potential in exchange for no locking at all), batching many logical updates into fewer actual writes (aggregate in a process's own state, flush periodically), or restructuring the data model so what looked like "one shared thing everyone updates" is actually several independent things that only need to be reconciled occasionally.

What is the core technique for reducing contention on one hot, shared record?
What is another way to reduce lock contention if strict per-update consistency isn't required?

29. What is mnesia:subscribe/1 used for?

mnesia:subscribe/1 registers the calling process to receive events about Mnesia activity — table changes, system events like node up/down, or schema changes — delivered as ordinary messages, letting application code react to data changes without polling.

mnesia:subscribe({table, person, simple}),
receive
    {mnesia_table_event, {write, NewRecord, _ActivityId}} ->
        handle_change(NewRecord)
end.

Subscribing to {table, Table, simple} delivers write/delete events for that table as they happen; subscribing to system delivers cluster-level events like a node connecting or disconnecting. This is the foundation for building cache invalidation, live-updating dashboards, or replication-aware logic that reacts to Mnesia changes in near real time rather than re-querying on a timer.

What kind of events can mnesia:subscribe/1 deliver to a process?
What common use case does table event subscription enable?

30. How do you react to table events using Mnesia's subscription mechanism?

After subscribing, the subscribing process simply receives ordinary messages in its mailbox whenever a matching event occurs, and handles them with a normal receive clause — there's no separate callback registration mechanism the way gen_event handlers work; it's just messages.

mnesia:subscribe({table, session, simple}),

loop() ->
    receive
        {mnesia_table_event, {write, Session, _}} ->
            notify_cache_invalidation(Session),
            loop();
        {mnesia_table_event, {delete, {session, Key}, _}} ->
            evict_from_cache(Key),
            loop()
    end.

Because these arrive as plain messages, a gen_server can subscribe during its init/1 and handle the events in its normal handle_info/2 callback, integrating cleanly with the usual OTP message loop rather than needing a separate event-handling framework layered on top.

How do table events arrive to a subscribing process?
Where would a gen_server typically handle these subscribed events?

31. What is a nested Mnesia transaction and how does it behave differently from a top-level one?

Calling mnesia:transaction/1 from inside code that's already running within another transaction creates a nested transaction. Rather than being a fully independent transaction, it shares the outer transaction's locks and only really commits when the outermost transaction itself commits — an abort of the inner one aborts the whole outer transaction too.

mnesia:transaction(fun() ->
    mnesia:write(#a{id = 1}),
    mnesia:transaction(fun() ->     %% nested
        mnesia:write(#b{id = 1})
    end),
    mnesia:write(#c{id = 1})
end).

This differs from what "nested transaction" might suggest in some other databases (an independently committable sub-unit) — in Mnesia, there's really just one logical transaction underneath, and nesting mainly matters for code organization (letting a helper function wrap its own operations in a transaction without caring whether it's already inside one) rather than providing partial-commit semantics.

If a nested transaction aborts, what happens to the outer transaction?
What is the main practical benefit of nesting in Mnesia?

32. Why should nested transactions generally be avoided or used carefully in Mnesia?

Since a nested transaction shares the outer transaction's fate (an inner abort takes down the whole outer transaction), relying on nesting to isolate a "risky" operation from the rest doesn't actually provide the isolation you might expect from true nested/savepoint semantics in some relational databases — a failure several layers deep still unwinds everything above it.

It can also make transaction logic harder to reason about: a function written assuming it's always the top-level transaction might behave subtly differently when called from inside another transaction (locks already held by the outer transaction interacting with what the inner one requests), which can be a source of confusing bugs if the nesting isn't intentional and well understood by whoever wrote the calling code. In practice, most Mnesia code is written to run as a single, flat transaction per logical operation, only nesting incidentally when composing reusable helper functions rather than as a deliberate transaction-design tool.

Why doesn't nesting provide true savepoint-style isolation the way some relational databases offer?
What is a common source of confusing bugs with unintentional nesting?

33. What is the dc_dump_limit configuration parameter used for?

Mnesia doesn't write every single transaction directly and immediately into a disc_copies table's on-disk data file — it appends to a transaction log first, periodically "dumping" that log into the actual table files. dc_dump_limit controls how large that log is allowed to grow (as a multiple of the table's own data file size) before Mnesia forces a dump.

%% in sys.config
{mnesia, [{dc_dump_limit, 40}]}.

A higher limit lets more writes accumulate in the (cheaper-to-append) log before triggering the heavier work of dumping into the actual table file, which can improve raw write throughput at the cost of a longer log to replay if the node restarts before the next dump. Tuning it is a tradeoff between steady-state write throughput and how long recovery/restart takes afterward.

What does dc_dump_limit control?
What is the tradeoff of raising dc_dump_limit?

34. Why does tuning the transaction log dump frequency matter for write-heavy Mnesia workloads?

Every write to a disc_copies table involves appending to a log file, which is cheap, versus periodically folding that log into the actual on-disk table representation, which is comparatively expensive (it involves more disk I/O restructuring the actual data file). Dumping too frequently adds overhead directly in the write path; dumping too rarely means a much larger log to replay if the node crashes or restarts before the next scheduled dump.

flowchart LR
    A[Write] --> B[Append to transaction log - cheap]
    B --> C{Log size exceeds dc_dump_limit?}
    C -->|Yes| D[Dump log into table data file - expensive]
    C -->|No| E[Continue appending]

For genuinely write-heavy systems, this tuning knob is one of the more impactful (and easy to overlook) levers for sustained throughput — getting it wrong in either direction either taxes every write with frequent dumps, or risks a long, disruptive replay window on the next restart if the log has been allowed to grow very large.

What happens if the transaction log is dumped too frequently?
What is the risk of dumping too rarely (a very high dc_dump_limit)?

35. How do you merge two previously-separate Mnesia clusters into one?

Mnesia doesn't provide a single built-in "merge two schemas" command — merging two independently created databases is a deliberate, mostly manual migration process, since each side already has its own complete schema and potentially overlapping table names or keys.

%% typical approach: export data from cluster B, import into cluster A
mnesia:backup("/tmp/cluster_b_backup.bak"),
%% on cluster A, restore selectively:
mnesia:restore("/tmp/cluster_b_backup.bak",
    [{default_op, keep_tables}]).

The practical approach is usually: back up the data you want to bring over from the smaller/secondary cluster, then restore it into the target cluster with restore options chosen carefully to avoid clobbering tables that already exist there, resolving any key collisions in application code rather than relying on Mnesia to reconcile them automatically. For genuinely live merges (both clusters staying up throughout), this typically means a staged migration table-by-table, rather than attempting a single atomic cutover.

Does Mnesia provide a single built-in command to merge two separate schemas?
What must application code handle when merging data from two independent clusters?

36. What complications arise when merging schemas from two independently-created Mnesia databases?

The core problem is that each database's schema and table content evolved independently, with no shared history — so there's no inherent way for Mnesia to know how to reconcile them. Concretely:

  • Key collisions — the same primary key value may exist in both databases referring to completely different logical entities.
  • Conflicting table definitions — a table with the same name might have different attributes, storage types, or table types on each side.
  • Node name collisions — if both clusters happen to use overlapping node names, they can't simply be connected together without renaming.
  • No transactional atomicity across the merge — unlike a normal Mnesia transaction, the merge itself isn't an atomic operation Mnesia coordinates for you.

This is why merges are typically planned as an application-level data migration exercise — exporting, transforming, and importing data deliberately — rather than treated as a database-level operation Mnesia handles automatically.

Why can the same primary key value be a problem when merging two independent Mnesia databases?
Does Mnesia treat a schema merge as an atomic, coordinated operation?

37. How does Mnesia decide which node's data wins during schema merge conflicts?

Left to its own defaults, Mnesia doesn't have an inherent basis for deciding whose data "wins" — when two nodes reconnect and discover their schemas or table contents disagree, it raises an inconsistent_database_event rather than guessing. Resolution happens through one of a few explicit mechanisms you configure or invoke:

  1. Master nodes (mnesia:set_master_nodes/2) — pre-designating an authoritative node for a table ahead of time.
  2. A custom mnesia_event handler — subscribing to system events and implementing your own resolution policy in response to the inconsistency notification.
  3. Manual intervention — an operator inspecting both sides and using force_load_table/1 on whichever node's data should be treated as correct.

None of these happen automatically without configuration or explicit action — this is a deliberate design choice, since silently picking a winner risks quietly discarding legitimate data on whichever side loses.

Does Mnesia automatically pick a winning replica without any configuration?
Why doesn't Mnesia silently pick a winner automatically?

38. What is the difference between a global lock and a per-record lock in Mnesia transactions?

A per-record lock (the default behavior of read/1/write/1 inside a transaction) only blocks other transactions from touching that specific record, letting unrelated concurrent transactions on other records in the same table proceed freely. A global (table-level) lock, taken via mnesia:read_lock_table/1 or mnesia:write_lock_table/1, blocks access to the entire table for the duration of the transaction.

Per-record lockTable-level lock
Only affects the specific record being read/written. Affects every record in the table.
Maximizes concurrency for unrelated operations. Serializes all access to the table, sacrificing concurrency.
Default behavior for normal read/write. Requires explicitly calling a table-lock function.

Table-level locks trade away most of the concurrency benefit of fine-grained locking, so they're reserved for operations that genuinely need a consistent view of, or exclusive access to, the whole table at once.

What does a per-record lock allow that a table-level lock doesn't?
What is the tradeoff of taking a table-level lock?

39. When would you use mnesia:read_lock_table/1 instead of relying on per-record locks?

Table-level locking makes sense specifically when an operation needs a consistent view of, or exclusive access to, the entire table at once — something per-record locks can't provide, since they only protect individual rows from concurrent modification while leaving the rest of the table open.

mnesia:transaction(fun() ->
    mnesia:read_lock_table(person),
    %% now guaranteed no concurrent writer can modify ANY record
    %% in this table until this transaction finishes
    Total = length(mnesia:all_keys(person))
end).

Good candidates: a bulk migration or schema transformation that needs the whole table to hold still while it works, or a consistency check/report that must see a single, unchanging snapshot across every record rather than potentially observing some records mid-update by other concurrent transactions. It's a deliberate, heavier-weight tool — using it routinely for ordinary operations would erase most of the concurrency benefit Mnesia's per-record locking otherwise provides.

What guarantee does read_lock_table/1 provide that per-record locks cannot?
What kind of operation is a good candidate for table-level locking?

40. How do you profile and optimize Mnesia transaction throughput in a write-heavy system?

Profiling starts with identifying where time is actually going: lock contention on specific records/tables, replication coordination overhead across nodes, or transaction log dump frequency — each has a different fix.

  1. Check for hot records/tables via tracing or by inspecting which transactions are waiting on locks (mnesia:info/0 shows waiting processes); shard or restructure data that's contended.
  2. Reconsider replication scope — does every node really need a full copy of this table, or would fragmentation or fewer replicas reduce coordination overhead per write?
  3. Batch related writes into a single transaction instead of many small ones, amortizing per-transaction overhead across more work.
  4. Reach for dirty operations selectively, for the specific subset of writes where strict consistency genuinely isn't required.
  5. Tune dc_dump_limit if disc_copies write overhead from frequent log dumps is a measurable factor.

The common thread: throughput problems in Mnesia are almost always about contention or coordination overhead specifically, not raw CPU, so profiling should aim squarely at finding which of those is the actual bottleneck before reaching for a fix.

What does mnesia:info/0 help reveal when profiling throughput issues?
What is usually the actual bottleneck behind Mnesia throughput problems?

41. Why is batching multiple related writes into a single transaction usually better than many small transactions?

Every transaction carries fixed overhead beyond the actual work it does: acquiring locks, and for replicated tables, coordinating a two-phase commit round-trip with every replica. Running ten related writes as ten separate transactions pays that coordination overhead ten times over; wrapping them in one transaction pays it once, for all ten writes together.

%% worse: 10 separate round-trips of coordination overhead
[mnesia:transaction(fun() -> mnesia:write(R) end) || R <- Records].

%% better: one transaction, one round-trip of overhead, for all writes
mnesia:transaction(fun() ->
    [mnesia:write(R) || R <- Records]
end).

Batching also means the writes are genuinely atomic together — if you actually need all ten to succeed or fail as a unit, separate transactions can't give you that guarantee at all, on top of the performance cost of the repeated coordination overhead.

What overhead does batching multiple writes into one transaction reduce?
What atomicity benefit does batching provide beyond raw performance?

42. How does Mnesia's schema interact with OTP release upgrades (appup/relup)?

A release upgrade's appup instructions describe how to hot-swap module code and transform running gen_server state via code_change/3, but Mnesia's schema and table data live outside that per-process state entirely — changing a Mnesia record's shape isn't something code_change/3 touches, since it's stored data, not process memory.

flowchart TD
    A[Release upgrade begins] --> B[appup: hot-swap module code]
    B --> C[code_change/3: transforms gen_server process state]
    C --> D{Mnesia table shape also changing?}
    D -->|Yes| E[Separate step: mnesia:transform_table/3 run explicitly]
    D -->|No| F[Upgrade complete, no table changes needed]

If a release upgrade also needs to change a table's record shape, that has to be handled as an explicit, separate step — typically calling mnesia:transform_table/3 as part of the upgrade procedure (often triggered from an application's own upgrade callback) — rather than something the standard relup/appup machinery does for you automatically.

Does code_change/3 automatically handle changes to a Mnesia table's record shape?
What function is typically used to migrate a Mnesia table's shape during a release upgrade?

43. What is the difference between mnesia:dirty_all_keys/1 and mnesia:all_keys/1?

Both return every primary key in a table, but they differ in the same way other dirty/transactional pairs do: mnesia:all_keys/1 must run inside a transaction (or another activity context) and takes the appropriate lock, giving you a result consistent with the rest of that transaction's view of the data. mnesia:dirty_all_keys/1 reads directly with no transaction or locking.

%% transactional
mnesia:transaction(fun() -> mnesia:all_keys(person) end).

%% dirty
mnesia:dirty_all_keys(person).

The dirty version is faster and simpler to call, but the key list it returns could be stale by the time you act on it if concurrent writers are actively adding/removing records at the same moment — acceptable for a rough count or a best-effort listing, but not for logic that depends on the key list being exactly consistent with other reads happening in the same transaction.

What must mnesia:all_keys/1 run inside, unlike its dirty counterpart?
What risk does mnesia:dirty_all_keys/1 carry that the transactional version avoids?

44. Explain the internal working of how Mnesia chooses which replica to read from in a load-balanced cluster?

For a table replicated across multiple nodes, Mnesia tracks a per-table where_to_read preference — by default, favoring reading from the local node's own copy if it holds one, since a local read avoids any network round-trip entirely.

flowchart TD
    A[Read request for Table] --> B{Does this node hold a local copy?}
    B -->|Yes| C[Read directly from local copy - no network round-trip]
    B -->|No| D[Route read to another node's replica per where_to_read]

If the local node doesn't hold a copy of that table at all, Mnesia routes the read to whichever remote node is currently designated to serve it, tracked internally and adjustable via mnesia:table_info(Table, where_to_read) to inspect, or influenced by sticky lock behavior for writes. This local-first preference is exactly why replicating a hot, read-heavy table onto every node that actually queries it can meaningfully cut read latency, compared to a setup where most reads have to cross the network to reach a node that holds a copy.

What does Mnesia prefer by default when reading from a replicated table?
Why might you replicate a hot, read-heavy table onto every node that queries it?

45. What is the where_to_read table_info item used for?

mnesia:table_info(Table, where_to_read) returns which node the local Mnesia instance would currently read that table from — useful for diagnosing read-routing behavior directly, rather than inferring it indirectly from latency measurements.

mnesia:table_info(session, where_to_read).
%% -> node() | 'nonode@nohost' (no copy currently reachable) | some_remote_node@host

If it returns the local node itself, reads are being served locally with no network involved; if it returns a remote node, every read for that table is crossing the network to reach it, which is worth knowing when investigating unexpectedly high read latency on a specific table. It's mainly a diagnostic tool for understanding cluster behavior at a point in time, not something application logic typically branches on directly.

What does mnesia:table_info(Table, where_to_read) reveal?
Why would you check where_to_read when investigating high read latency on a table?

46. What is mnesia:dump_log/0 used for?

mnesia:dump_log/0 forces an immediate dump of the current transaction log into the actual on-disk table files, rather than waiting for the automatic threshold (governed by dc_dump_limit) to be reached naturally.

mnesia:dump_log().
%% -> dumped

It's mainly useful around planned maintenance moments — right before taking a node offline for upgrade, or right before a backup, where you want the on-disk table files to reflect the most current state rather than relying on the log replay to catch things up later. Calling it routinely in application code isn't typical; it's more of an operational/administrative tool for specific moments where you want disk state brought current on demand.

What does mnesia:dump_log/0 do?
When would you typically call dump_log/0 manually?

47. How do you handle a transaction that needs to retry after being aborted due to a deadlock?

When Mnesia detects a deadlock between two transactions, it aborts one of them automatically, but it doesn't retry it for you — the calling code gets back {aborted, Reason} and is responsible for deciding whether and how to retry.

retry_transaction(Fun, Retries) when Retries > 0 ->
    case mnesia:transaction(Fun) of
        {atomic, Result} -> {ok, Result};
        {aborted, {deadlock, _}} ->
            timer:sleep(rand:uniform(50)),   %% small random backoff
            retry_transaction(Fun, Retries - 1);
        {aborted, Reason} -> {error, Reason}
    end;
retry_transaction(_Fun, 0) -> {error, max_retries_exceeded}.

A short, randomized backoff before retrying helps avoid two competing transactions immediately re-colliding in the same way; a bounded retry count avoids looping forever if the underlying contention doesn't clear. This retry logic is application-level code you write yourself — Mnesia's job ends at correctly detecting and aborting one side of the deadlock, not resubmitting the work.

Does Mnesia automatically retry a transaction it aborts due to deadlock?
Why add a small randomized backoff before retrying an aborted transaction?

48. What is the difference between mnesia:transaction/1's built-in behavior and manual retry logic you might add?

mnesia:transaction/1 itself already retries internally in one specific, narrow case: if the transaction is aborted purely because of a lock conflict that Mnesia's own internal deadlock resolution detected as safely retryable, it can re-run the transaction function automatically without the caller seeing an intermediate failure at all.

%% This may silently re-run Fun internally one or more times
%% before returning a final {atomic, Result} or {aborted, Reason}
mnesia:transaction(fun() ->
    mnesia:write(#counter{id = x, value = V + 1})
end).

What it does not do is retry for reasons outside its own lock-conflict handling — a node going down mid-transaction, a majority-option write failing due to a partition, or a genuine application-level exception inside your function all surface as a final {aborted, Reason} that your own code must decide how to handle. The practical implication: don't assume every transient failure is already retried for you; only the internal lock-conflict case is, and everything else needs your own retry strategy layered on top if that's the behavior you want.

What kind of failure does mnesia:transaction/1 sometimes retry internally on its own?
What must your own application code still handle after a transaction returns {aborted, Reason}?
«
»

Comments & Discussions