Database / Mnesia intermediate to advanced Interview questions
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.
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_dirty | sync_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.
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.
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.
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.
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.
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/1 | mnesia: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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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:
- Master nodes (
mnesia:set_master_nodes/2) — pre-designating an authoritative node for a table ahead of time. - A custom mnesia_event handler — subscribing to system events and implementing your own resolution policy in response to the inconsistency notification.
- Manual intervention — an operator inspecting both sides and using
force_load_table/1on 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.
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 lock | Table-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.
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.
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.
- Check for hot records/tables via tracing or by inspecting which transactions are waiting on locks
(
mnesia:info/0shows waiting processes); shard or restructure data that's contended. - 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?
- Batch related writes into a single transaction instead of many small ones, amortizing per-transaction overhead across more work.
- Reach for dirty operations selectively, for the specific subset of writes where strict consistency genuinely isn't required.
- Tune
dc_dump_limitif 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.
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.
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.
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.
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.
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.
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.
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.
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.
