Skip to content

mnesia: Release table fixes when coordinator dies - #11517

Open
stnby wants to merge 1 commit into
erlang:maintfrom
stnby:mnesia/fix-fixtable-leak-on-coordinator-death
Open

mnesia: Release table fixes when coordinator dies#11517
stnby wants to merge 1 commit into
erlang:maintfrom
stnby:mnesia/fix-fixtable-leak-on-coordinator-death

Conversation

@stnby

@stnby stnby commented Aug 20, 2026

Copy link
Copy Markdown

Summary

A transaction that iterates a table leaks a safe_fixtable hold whenever its
coordinator dies abnormally. The table stays fixed for the lifetime of the node.

mnesia:first/1, last/1, next/2, prev/2, select and select_reverse
on anything but an ordered_set route through do_fixtable/2, which asks
mnesia_tm to fix the table. The fix is therefore taken by the mnesia_tm
process
, not by the coordinator, so the automatic release that
ets:safe_fixtable/2 and dets:safe_fixtable/2 perform when the fixing process
dies never fires: mnesia_tm outlives the transaction. mnesia_tm has to
release the fix explicitly.

The normal termination path does that when it handles {delete_transaction, Tid}
(mnesia_tm.erl:411), but recover_coordinator/2 never did.

An application cannot work around this, because the client process never owned
the fix.

Which failures leak

Everything originating inside the transaction fun is handled correctly; only
signals arriving from outside the coordinator leak:

failure mode result
normal commit clean
mnesia:abort/1 clean
exception in the fun clean
throw in the fun clean
lock conflict, transaction restarted clean
exit(Pid, kill) leak
exit(Pid, shutdown) leak
linked parent dies, coordinator not trapping exits leak

Impact

A fixed table never reclaims the space of deleted objects, so this is not merely
a leaked handle.

With one leaked fix, a disc_only_copies table under steady-state churn
(20000 rows deleted and 20000 reinserted per cycle, row count constant) grew
from 19 197 280 to 177 473 872 bytes over four cycles, roughly 39.5 MB per
cycle without bound. The same table with no leaked fix stayed flat at
19 402 600 bytes.

An ets-backed table retains every deleted tuple under a sliding-window
workload, 5.2x the clean control after four cycles at identical row count.

Two properties worth noting:

  • The damage is binary per table, not cumulative. One leaked fix and fifty
    produce byte-identical results, since fixation is a refcount and the retained
    garbage depends on churn while fixed. A single kill is enough to disable space
    reclamation on a table permanently.
  • The obvious workaround does not cover the worst case. ordered_set avoids the
    fix entirely, but mnesia rejects ordered_set with disc_only_copies
    ({not_supported, ordered_set, disc_only_copies}).

It is silent: no crash, no error return, and the only log is a verbose/2
"Coordinator died" that does not mention fixtables.

Reproduction

mnesia:create_table(foo, [{type, set}, {ram_copies, [node()]}]),
P = spawn(fun() -> mnesia:transaction(
        fun() -> mnesia:first(foo), timer:sleep(infinity) end) end),
timer:sleep(100),
exit(P, kill),
timer:sleep(100),

%% the table is still fixed, by the mnesia_tm process
{_, [{_, 1}]} = ets:info(foo, safe_fixed_monotonic_time).

Age

The mechanism was introduced whole in mnesia 4.2 (OTP R10B, 2004-10-05) and was
already asymmetric there: clear_fixtable was wired into the
delete_transaction path but not into recover_coordinator/2. mnesia 4.1.12
(R9C-2) has neither do_fixtable nor clear_fixtable.

git log -S'clear_fixtable' -- lib/mnesia/src/mnesia_tm.erl returns a single
commit, 84adefa "The R13B03 release", the initial import, so the defect
predates the git history.

The change

One line in recover_coordinator/2, placed before erase_ets_tabs/1 because
clear_fixtable/1 reads the store that erase_ets_tabs/1 destroys. This is the
same order the normal path uses.

Nested transactions are covered, because copy_ets/2 copies the parent store
into each nested store, so hd(Etabs) carries all accumulated fixtable
entries.

Test

fixtable_released_when_coordinator_dies/1 in mnesia_atomicity_test, covering
both ram_copies and disc_only_copies. It asserts that a transaction ending by
itself releases its fix, that a fix is actually taken before the kill, and that
it is released after the coordinator is killed.

Verified to fail before the change (2 assertion failures, one per storage type)
and pass after it.

Verification

  • mnesia_atomicity_test 55/55 with the change, 54/55 without it (the new case
    being the failure)
  • mnesia_trans_access_test 40/40, mnesia_isolation_test 48/48,
    mnesia_evil_coverage_test 55/55, identical with and without the change

Performance: recover_coordinator/2 has a single caller, handle_exit/3,
reached only on abnormal coordinator death, so the normal transaction path is
untouched. Measured over 4 VM runs of 7 reps each, hot-path transaction
throughput is unchanged within run-to-run variance. The cold path costs
+1.9 us per abnormally terminated coordinator holding no fix (one
ets:lookup/2 returning []) and +5.9 us when a fix is held, which is the
release work that was previously skipped. mnesia_tm still recovers roughly
31 000 killed coordinators per second.

Not addressed here

mnesia.erl takes the fix and records it in the transaction store in two
separate statements. A coordinator killed between them leaves a hold that
clear_fixtable/1 cannot see, because it was never recorded. I confirmed the
window is reachable by widening it artificially, but it needs a different and
more invasive fix, so it is deliberately left out of this change.

Caveats

Tested against an installed OTP 29.0.1 / mnesia 4.26 rather than a from-source
OTP tree, driving the suites through mnesia_test_lib:eval_test_case/3.
./otp_build check has therefore not been run.

@CLAassistant

CLAassistant commented Aug 20, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@github-actions

github-actions Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

CT Test Results

  2 files   59 suites   18m 35s ⏱️
699 tests 548 ✅ 151 💤 0 ❌
754 runs  588 ✅ 166 💤 0 ❌

Results for commit 67fb374.

♻️ This comment has been updated with latest results.

To speed up review, make sure that you have read Contributing to Erlang/OTP and that all checks pass.

See the TESTING and DEVELOPMENT HowTo guides for details about how to run test locally.

Artifacts

// Erlang/OTP Github Action Bot

Iterating a table in a transaction (mnesia:first/1 and friends on
anything but an ordered_set) makes mnesia_tm fix the table. The fix is
held by mnesia_tm and not by the coordinator, so it has to be released
explicitly when the coordinator dies. The normal termination path does
that, recover_coordinator/2 did not.

A fixed table never reclaims the space of deleted objects, so a dets
table backing disc_only_copies grows without bound at a constant row
count.

To verify:
mnesia:create_table(foo, [{type, set}, {ram_copies, [node()]}]),
P = spawn(fun() -> mnesia:transaction(
        fun() -> mnesia:first(foo), timer:sleep(infinity) end) end),
timer:sleep(100),
exit(P, kill),
timer:sleep(100),

%% the table is still fixed, by the mnesia_tm process
{_, [{_, 1}]} = ets:info(foo, safe_fixed_monotonic_time).

Added a test case to the atomicity suite.
@stnby
stnby force-pushed the mnesia/fix-fixtable-leak-on-coordinator-death branch from 914ea3a to 67fb374 Compare August 20, 2026 11:08
@dgud

dgud commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Please sign the CLA

@Mikaka27 Mikaka27 added team:PS Assigned to OTP team PS testing currently being tested, tag is used by OTP internal CI labels Aug 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

team:PS Assigned to OTP team PS testing currently being tested, tag is used by OTP internal CI

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants