Catalog gen_server: the serialized writer of the translation catalogs.
What it is and which problem it solves
This module is the heart of the erli18n runtime: it loads .po catalogs, keeps
the translations live and answers lookups (singular, plural and header). It
reconciles two contradictory requirements: translation reads are extremely hot
(every UI string goes through a lookup) and must be lock-free; writes must be
serialized so two concurrent loaders of the same catalog cannot clobber each
other. The solution is a strict split between the write path (serialized by this
process's mailbox) and the read path (straight from persistent_term, with no
roundtrip to the server).
Storage substrate: persistent_term
Each {Domain, Locale} catalog is stored as ONE persistent term (key
{erli18n_catalog, Domain, Locale}) holding a map of all its entries plus the
header — see erli18n_pt_store. persistent_term:get/2 returns the term WITHOUT
copying it onto the caller's heap, so reads are copy-free and lock-free (the
benchmark measured ~55% faster than the previous per-row ETS storage). The
trade-off is the write side: installing/erasing a catalog defers a node-wide
literal-area cleanup (a major GC on processes still holding the old catalog plus
an all-process heap scan). erli18n loads catalogs once at boot and rarely
reloads, so this is acceptable — but it is a real cost the old ETS storage did
not have, paid once per reload/3,4 and unload/2.
Mental model
Three layers:
Read path (hot path, lock-free).
lookup_singular/4,lookup_plural_form/5andlookup_header/2readpersistent_termdirectly in the CALLING process — no message reaches the server. N processes read in parallel with no bottleneck. The load-bearing rule: each lookup fetches the catalog map fresh and lets it be transient; the map is NEVER cached in a long-lived process (a holder forces a major GC on reload and would serve a stale catalog).Write path (serialized).
insert_*,unload/2and the load commits aregen_server:calls;handle_call/3is the only critical section that mutatespersistent_term. The single mailbox closes the check-then-install race thatpersistent_term(which has no compare-and-swap) cannot close on its own, and lets a batch load issue its puts back to back.Load orchestration (heavy work OUTSIDE the mailbox).
ensure_loaded/4andreload/4run the heavy, failable phase (size-check, read, parse, plural compile, CLDR divergence, map build) in the CALLING process, producing a pure in-memorystaged()— including the fully-built catalog map. Only this validated payload travels to the server for a microsecond-scale commit (a singlepersistent_term:put). A large/slow/pathological.pofrom one tenant never blocks another's load.
Trusted vs untrusted. A .po's Plural-Forms rule is untrusted input; it is
compiled with bounds (see erli18n_plural), whose evaluate/2 is total — it
clamps malformed rules instead of raising, so lookup_plural_form/5 evaluates
them directly. The anti-DoS bounds (max_bytes, max_entries) reject large
catalogs BEFORE any mutation.
Durability. persistent_term is owned by the runtime, not by this process,
so a crash of this worker destroys NOTHING: every loaded catalog survives the
restart untouched. The server keeps no catalog data in its State (it is #{}):
the truth lives entirely in persistent_term. Because the terms are node-global
and are NOT cleared on application stop, erli18n_app:stop/1 erases them on
shutdown (otherwise a stop/start cycle would leak stale catalogs).
When and how a dev touches this module
- To load/reload a
.po:ensure_loaded/3,4(idempotent),reload/3,4(always reinstalls, atomic) orensure_loaded_many/1(batch). - To unload:
unload/2. - To read a low-level translation (the
erli18nfaçade is the usual front-door):lookup_singular/4,lookup_plural_form/5,lookup_header/2. - To write individual entries (tests, non-
.posources):insert_singular/5,insert_plural/5,insert_catalog/3. - For observability:
memory_info/0,loaded_catalogs/0,which_keys/2.
Quickstart
1> application:ensure_all_started(erli18n).
{ok, [erli18n]}
2> Po = erli18n_server:default_po_path(my_app, my_domain, <<"fr">>).
"/.../priv/locale/fr/LC_MESSAGES/my_domain.po"
3> erli18n_server:ensure_loaded(my_domain, <<"fr">>, Po).
{ok, 128}
4> erli18n_server:ensure_loaded(my_domain, <<"fr">>, Po).
{ok, already}
5> erli18n_server:lookup_singular(my_domain, <<"fr">>, undefined, <<"Hello">>).
{ok, <<"Bonjour">>}
6> erli18n_server:lookup_plural_form(my_domain, <<"fr">>, undefined, <<"file">>, 2).
{ok, <<"fichiers">>}
7> erli18n_server:memory_info().
#{ets_bytes => 24576, num_catalogs => 1, num_keys => 131}(num_keys counts ALL stored keys, including the header; loaded_catalogs/0
counts only the 130 data entries — see both functions.)
Main entry points
- Load:
ensure_loaded/3,ensure_loaded/4,ensure_loaded_many/1,reload/3,reload/4. - Read (lock-free):
lookup_singular/4,lookup_plural_form/5,lookup_header/2. - Write:
insert_singular/5,insert_plural/5,insert_catalog/3,unload/2. - Observability:
memory_info/0,loaded_catalogs/0,which_keys/2. - Lifecycle / OTP:
start_link/0,init/1.
Summary
Types
Errors from the anti-DoS bounds (finding #6), a subset of ensure_error().
Both are surfaced in the CALLER's heavy phase, BEFORE any mutation:
input_too_large when the file size exceeds max_bytes (checked without reading
the bytes, via filelib:file_size/1); too_many_entries when the post-parse
count exceeds max_entries. The second element is the observed value, the third
is the configured limit.
Union of all structured errors a load can return. Each variant maps a failable
step of the stage pipeline (in the order it can fail): an I/O error reading the
file ({file_error, _}), a .po parse error (erli18n_po:parse_error()), a
plural-rule compile error ({plural_compile_error, _}) and the anti-DoS caps
(bound_error()). None of them leaves a catalog mutated.
Result of a load (ensure_loaded/3,4, reload/3,4).
Header state of a loaded catalog, returned by lookup_header/2 and stored under
the catalog map's '$header' key. The PRESENCE of the header is the idempotency
signal used by ensure_loaded/3 ("catalog already loaded").
A catalog to load in the bulk API ensure_loaded_many/1. Same positional shape
as the ensure_loaded/4 arguments: {Domain, Locale, PoPath, Opts}.
Load options accepted by ensure_loaded/4, reload/4 and each item of
ensure_loaded_many/1. All fields are optional; omitting one preserves the
legacy behaviour (modulo the safety-cap defaults).
Functions
No state migration: the State is an empty #{} (the truth lives in
persistent_term), so a code upgrade has no state to transform.
Returns {ok, State}.
Computes the conventional gettext .po path for an application.
Idempotent load of a .po catalog. Same as ensure_loaded/4 with #{}.
Idempotent load of a .po catalog with resource options.
Bulk load of N catalogs with a single commit on the server.
Serialized critical section — NOTE FOR THE MAINTAINER. This is the ONLY place
where persistent_term is mutated; every write in the module passes through here
under the single mailbox, which closes the check-then-install race that
persistent_term (no compare-and-swap) cannot close on its own.
Inert by design: this server uses no casts (every write is a synchronous call,
so the caller gets acknowledgement and backpressure). It exists only to satisfy
the gen_server behaviour. Messages are ignored with {noreply, State}.
Inert by design: this server expects no out-of-band messages (there is no ETS
table, no 'ETS-TRANSFER'). Messages are ignored with {noreply, State}.
Initialization callback (do not call by hand; the supervisor invokes it via
start_link/0).
Inserts a batch of entries (singular and plural) of a catalog in one write.
Inserts/overwrites the plural forms of a Msgid, serialized by the server.
Inserts/overwrites a singular translation, serialized by the server.
Lists the loaded catalogs with the data-entry count of each.
The sorted, distinct locales across all loaded catalogs — the locale projection
of loaded_catalogs/0. Backed by the loaded-catalog index: ONE keyed,
copy-free persistent_term read plus a usort, NOT a node-wide scan, so it is
cheap enough for the per-request locale-negotiation default path.
Lock-free lookup of the (Domain, Locale) catalog header, straight from
persistent_term.
The CORRECT entry point for plural reads (form-aware, lock-free).
Lock-free lookup of a singular translation, straight from persistent_term.
Returns the memory usage of the loaded catalogs.
Atomic reload of a .po catalog. Same as reload/4 with #{}.
Atomic reload of a .po catalog with resource options (STAGE -> INSTALL).
Starts the catalog gen_server, registered locally as erli18n_server.
No cleanup to do: the catalogs live in persistent_term, which is owned by the
runtime and survives a crash of this worker, so terminate/2 must NOT erase them
(that would lose every catalog on a transient crash). Application-stop cleanup is
erli18n_app:stop/1's job. Returns ok.
Removes the (Domain, Locale) catalog entirely (entries + header).
Enumerates the keys (singular and plural) loaded for (Domain, Locale).
Types
-type bound_error() :: {input_too_large, Bytes :: non_neg_integer(), Limit :: non_neg_integer()} | {too_many_entries, Count :: non_neg_integer(), Limit :: non_neg_integer()}.
Errors from the anti-DoS bounds (finding #6), a subset of ensure_error().
Both are surfaced in the CALLER's heavy phase, BEFORE any mutation:
input_too_large when the file size exceeds max_bytes (checked without reading
the bytes, via filelib:file_size/1); too_many_entries when the post-parse
count exceeds max_entries. The second element is the observed value, the third
is the configured limit.
-type catalog_entry() :: singular_entry() | plural_entry().
-type context() :: undefined | binary().
-type domain() :: atom().
-type ensure_error() :: erli18n_po:parse_error() | {plural_compile_error, erli18n_plural:compile_error()} | {file_error, file:posix() | badarg | terminated | system_limit} | bound_error() | {load_failed, term()}.
Union of all structured errors a load can return. Each variant maps a failable
step of the stage pipeline (in the order it can fail): an I/O error reading the
file ({file_error, _}), a .po parse error (erli18n_po:parse_error()), a
plural-rule compile error ({plural_compile_error, _}) and the anti-DoS caps
(bound_error()). None of them leaves a catalog mutated.
-type ensure_result() :: {ok, NewlyLoaded :: non_neg_integer()} | {ok, already} | {error, ensure_error()}.
Result of a load (ensure_loaded/3,4, reload/3,4).
{ok, NewlyLoaded}: a real load — number of entries parsed, compiled and installed.{ok, already}: idempotent fast-path, the catalog was already loaded (onlyensure_loaded/ensure_loaded_many;reloadnever returns this).{error, ensure_error()}: structured error; the prior catalog stays intact (all errors occur BEFORE any mutation).
-type header_state() :: #{plural := erli18n_plural:plural_compiled() | fallback, plural_raw := binary(), po_path := file:filename(), loaded_at := integer(), divergence := divergence_info(), fuzzy_included := boolean(), num_entries := non_neg_integer()}.
Header state of a loaded catalog, returned by lookup_header/2 and stored under
the catalog map's '$header' key. The PRESENCE of the header is the idempotency
signal used by ensure_loaded/3 ("catalog already loaded").
plural: the ALREADY compiledPlural-Formsrule (erli18n_plural:plural_compiled()), or the atomfallbackwhen the.pocame without a plural header (the lookup then uses the C/Germanic default, seelookup_plural_form/5).plural_raw: the raw text of the rule (or the fallback rule) for observability/round-trip.po_path: the path of the source.po.loaded_at:erlang:system_time(millisecond)at the moment of the load.divergence:divergence_info()—noneor the vs-CLDR divergence warning.fuzzy_included: whether the load included#, fuzzyentries.num_entries: entry count (singular + plural aggregated as the parser counts them), the number reported in{ok, NewlyLoaded}.
-type load_spec() :: {domain(), locale(), file:filename(), opts()}.
A catalog to load in the bulk API ensure_loaded_many/1. Same positional shape
as the ensure_loaded/4 arguments: {Domain, Locale, PoPath, Opts}.
-type locale() :: binary().
-type msgid() :: binary().
-type msgid_plural() :: undefined | binary().
-type opts() :: #{include_fuzzy => boolean(), max_bytes => non_neg_integer() | infinity, max_entries => non_neg_integer() | infinity, timeout => timeout()}.
Load options accepted by ensure_loaded/4, reload/4 and each item of
ensure_loaded_many/1. All fields are optional; omitting one preserves the
legacy behaviour (modulo the safety-cap defaults).
include_fuzzy(defaultfalse): includes entries marked#, fuzzy.max_bytes(defaultapplication:get_env(erli18n, max_po_bytes), 16 MiB): rejects the file BEFORE reading it whole (viafilelib:file_size/1).infinitydisables the cap.max_entries(defaultapplication:get_env(erli18n, max_po_entries), 500000): rejects the catalog AFTER the parse if it has more than N entries.infinitydisables the cap.timeout(default 5000 ms): deadline of the commitgen_server:call/3. Since the heavy phase no longer runs behind the mailbox, the deadline covers only the singlepersistent_term:put(microsecond scale).
-type plural_entries() :: [{plural_index(), translation()}].
-type plural_entry() :: {plural, context(), msgid(), msgid_plural(), plural_entries()}.
-type plural_index() :: non_neg_integer().
-type singular_entry() :: {singular, context(), msgid(), translation()}.
-type translation() :: binary().
Functions
No state migration: the State is an empty #{} (the truth lives in
persistent_term), so a code upgrade has no state to transform.
Returns {ok, State}.
-spec default_po_path(atom(), domain(), locale()) -> file:filename().
Computes the conventional gettext .po path for an application.
Parameters
App: the OTP application whoseprivcontains the catalogs (resolved viacode:priv_dir/1).Domain: the gettext domain (becomes the file name<Domain>.po).Locale: the binary locale (becomes the directory segment<Locale>).
Return
Returns <priv>/locale/<Locale>/LC_MESSAGES/<Domain>.po (a string). This
function only COMPOSES the path — it does not check whether the file exists.
Failure modes
Crashes with {priv_dir_not_found, App} if the application is unknown
(code:priv_dir/1 returns {error, bad_name}).
1> erli18n_server:default_po_path(my_app, my_domain, <<"fr">>).
"/path/to/my_app/priv/locale/fr/LC_MESSAGES/my_domain.po"See ensure_loaded/3.
-spec ensure_loaded(domain(), locale(), file:filename()) -> ensure_result().
Idempotent load of a .po catalog. Same as ensure_loaded/4 with #{}.
If (Domain, Locale) is already loaded (header present), returns
{ok, already} without touching disk. Otherwise it runs the full pipeline (read
file, parse, compile the plural rule, validate against CLDR as a WARNING —
divergence never blocks the load, install in persistent_term) and returns
{ok, NewlyLoaded}, or {error, ensure_error()} leaving any prior catalog
INTACT.
1> erli18n_server:ensure_loaded(my_domain, <<"fr">>, "priv/locale/fr/LC_MESSAGES/my_domain.po").
{ok, 128}
2> erli18n_server:ensure_loaded(my_domain, <<"fr">>, "priv/locale/fr/LC_MESSAGES/my_domain.po").
{ok, already}
3> erli18n_server:ensure_loaded(my_domain, <<"fr">>, "/no/such/file.po").
{error, {file_error, enoent}}See ensure_loaded/4 (options and bounds), reload/3 (forces reinstall),
ensure_loaded_many/1 (batch).
-spec ensure_loaded(domain(), locale(), file:filename(), opts()) -> ensure_result().
Idempotent load of a .po catalog with resource options.
Idempotent fast-path: if the catalog is already loaded, returns {ok, already}
via a pure read (no disk, no server roundtrip). On a miss, the heavy phase
(read+parse+compile+validate+bounds+map build) runs in the CALLING process,
inside the span [erli18n, catalog, load], and only the validated payload is
handed to the server for the microsecond commit.
Opts (all optional):
include_fuzzy(defaultfalse): includes entries marked#, fuzzy.max_bytes(non_neg_integer() | infinity): rejects the file BEFORE reading it whole (viafilelib:file_size/1); defaultapplication:get_env(erli18n, max_po_bytes)(16 MiB).infinity= no cap.max_entries(non_neg_integer() | infinity): rejects the catalog AFTER the parse if it has more than N entries; defaultapplication:get_env(erli18n, max_po_entries)(500000).infinity= no cap.timeout(timeout()): deadline of the commitgen_server:call/3(default 5000 ms; the commit is a singlepersistent_term:put).
Returns {ok, NewlyLoaded}, {ok, already} or {error, ensure_error()}
(including {input_too_large, _, _} / {too_many_entries, _, _}), always before
any mutation.
Edge cases
- Check-then-install race: the idempotent fast-path reads outside the
serialization, but the commit RE-CHECKS idempotency under the mailbox (mode
ensure), so two concurrent callers of the same catalog do not overwrite each other — the second sees{ok, already}. - CLDR divergence: never an error; emits a warning log/telemetry and
proceeds, storing the divergence in the
header_state().
1> erli18n_server:ensure_loaded(my_domain, <<"fr">>, "fr.po", #{include_fuzzy => true}).
{ok, 131}
2> erli18n_server:ensure_loaded(my_domain, <<"de">>, "big.po", #{max_bytes => 1024}).
{error, {input_too_large, 6553600, 1024}}See ensure_loaded/3, reload/4, ensure_loaded_many/1, opts(),
ensure_error().
-spec ensure_loaded_many([load_spec()]) -> [{domain(), locale(), ensure_result()}].
Bulk load of N catalogs with a single commit on the server.
Specs is [{Domain, Locale, PoPath, Opts}]. The heavy phase of each spec runs
in the calling process (sequential preparation — the v0.1 trade-off; a parallel
fan-out is a future evolution) and all ready payloads are delivered in a SINGLE
commit, collapsing N roundtrips into one. Each Opts follows ensure_loaded/4.
Returns [{Domain, Locale, ensure_result()}] — already-loaded or failed catalogs
are reported individually; one catalog's error never blocks the others.
Edge cases
- Each result element is an independent
ensure_result(): you can have a mix of{ok, N},{ok, already}and{error, _}in the same list. - Empty list ->
[](no roundtrip to the server). - If ALL specs are idempotent/error in the preparation phase, no commit is sent.
1> erli18n_server:ensure_loaded_many([
.. {my_domain, <<"fr">>, "fr.po", #{}},
.. {my_domain, <<"de">>, "de.po", #{}},
.. {my_domain, <<"xx">>, "/missing.po", #{}}
.. ]).
[{my_domain, <<"fr">>, {ok, 128}},
{my_domain, <<"de">>, {ok, 96}},
{my_domain, <<"xx">>, {error, {file_error, enoent}}}]See ensure_loaded/4, load_spec().
Serialized critical section — NOTE FOR THE MAINTAINER. This is the ONLY place
where persistent_term is mutated; every write in the module passes through here
under the single mailbox, which closes the check-then-install race that
persistent_term (no compare-and-swap) cannot close on its own.
Message protocol (all call variants)
{insert_singular, D, L, Ctx, Msgid, T}-> merges one entry; replyok.{insert_plural, D, L, Ctx, Msgid, Entries}-> merges one entry per form; replyok.{insert_catalog, D, L, Entries}-> merges the batch; replyok.{unload, D, L}-> erases the catalog term; emits the span[erli18n, catalog, unload]; reply ALWAYSok(historical contract).{commit, ensure | reload, D, L, Staged}-> installs an ALREADY validatedstaged()(the heavy phase ran in the caller). ModeensureRE-CHECKS idempotency under serialization; modereloadalways reinstalls. No span here — it already fired caller-side.{commit_many, Items}-> installs N payloads in one critical section, with ONEmemory_warning_checkat the end (not N).- Any other call ->
{reply, {error, unknown_call}, State}.
Invariant
The server receives ONLY validated payloads in the commits — no heavy read/parse/compile/build runs behind this mailbox (finding #6). That is why this callback is the microsecond section.
Inert by design: this server uses no casts (every write is a synchronous call,
so the caller gets acknowledgement and backpressure). It exists only to satisfy
the gen_server behaviour. Messages are ignored with {noreply, State}.
Inert by design: this server expects no out-of-band messages (there is no ETS
table, no 'ETS-TRANSFER'). Messages are ignored with {noreply, State}.
-spec init([]) -> {ok, map()}.
Initialization callback (do not call by hand; the supervisor invokes it via
start_link/0).
The catalogs live in persistent_term, which is owned by the runtime and
survives a crash of this worker, so there is no table to claim and no index to
rebuild: the State is simply #{} (the server holds no catalog data). Returns
{ok, #{}}.
See start_link/0.
-spec insert_catalog(domain(), locale(), [catalog_entry()]) -> ok.
Inserts a batch of entries (singular and plural) of a catalog in one write.
Parameters
Domain,Locale: the target catalog.Entries: a list mixing{singular, Context, Msgid, Translation}and{plural, Context, Msgid, MsgidPlural, [{Index, Translation}]}. TheMsgidPluralis preserved in the parsed format fordump/1round-trips, but plays no part in lookup keying (finding #14).
Return and effects
Each entry is merged into the catalog map (one key per plural form). Synchronous;
always returns ok. Does NOT install the catalog header — for the full
pipeline (.po parse + plural compile + header) use ensure_loaded/3. Without a
header, lookup_plural_form/5 returns undefined; use this function for seeding
singular data or in tests.
Failure modes
A non-atom Domain / non-binary Locale / non-list Entries crash with
function_clause. An entry with an unknown tag crashes the server.
1> erli18n_server:insert_catalog(my_domain, <<"fr">>, [
.. {singular, undefined, <<"Hello">>, <<"Bonjour">>},
.. {plural, undefined, <<"file">>, <<"files">>, [{0, <<"fichier">>}, {1, <<"fichiers">>}]}
.. ]).
ok
2> erli18n_server:lookup_singular(my_domain, <<"fr">>, undefined, <<"Hello">>).
{ok, <<"Bonjour">>}See also insert_singular/5, insert_plural/5, ensure_loaded/3.
-spec insert_plural(domain(), locale(), context(), msgid(), plural_entries()) -> ok.
Inserts/overwrites the plural forms of a Msgid, serialized by the server.
Parameters
Domain,Locale,Context,Msgid: as ininsert_singular/5.Entries: the list[{FormIndex, Translation}]— one plural form per pair, whereFormIndexis the form index (0 = gettext singular, 1, 2, ...).
Return and effects
Merges one entry per form, {plural, Context, Msgid, FormIndex} => Translation,
into the catalog map. Synchronous; always returns ok. An empty list is a
no-op: it stores nothing AND does not create the catalog. Selecting the
correct form at read time (evaluating Plural-Forms against N) is the
responsibility of lookup_plural_form/5, NOT of this function: here you supply
the raw indices.
Failure modes
Arguments outside the guards crash with function_clause. Each pair must have an
integer FormIndex >= 0; a negative or non-integer index crashes the server
(loud contract, via erli18n_pt_store).
The forms are immediately readable via a direct read, but lookup_plural_form/5
only SELECTS a form when the (Domain, Locale) catalog header is already loaded
(it reads the header first to obtain the Plural-Forms rule; without a header it
returns undefined). insert_plural/5 does NOT install any header.
1> erli18n_server:insert_plural(my_domain, <<"fr">>, undefined, <<"file">>,
.. [{0, <<"fichier">>}, {1, <<"fichiers">>}]).
ok
%% Without a header, lookup_plural_form/5 is a miss:
2> erli18n_server:lookup_plural_form(my_domain, <<"fr">>, undefined, <<"file">>, 1).
undefinedSee also insert_singular/5, lookup_plural_form/5, ensure_loaded/3.
-spec insert_singular(domain(), locale(), context(), msgid(), translation()) -> ok.
Inserts/overwrites a singular translation, serialized by the server.
Low-level write API — to load entire .po files prefer ensure_loaded/3.
Useful in tests or when feeding translations from a source other than .po.
Parameters
Domain: the catalog's gettext domain (atom).Locale: the binary locale (e.g.<<"fr">>).Context: themsgctxt, orundefinedwhen absent.Msgid: the source text (lookup key).Translation: the translation to store.
Return and effects
Merges the entry {singular, Context, Msgid} => Translation into the catalog
map for {Domain, Locale} (creating the catalog if absent, preserving an
existing header). Synchronous; always returns ok. Overwrites any prior
translation for the same key. Does NOT install a header — this entry is readable
via lookup_singular/4, but the catalog will not have lookup_header/2 unless an
ensure_loaded/3 installs one.
Failure modes
Arguments outside the guards (e.g. a non-atom Domain) crash with
function_clause in the caller. A server reply other than ok crashes with
badmatch (contract break — only handle_call/3 writes that reply).
1> erli18n_server:insert_singular(my_domain, <<"fr">>, undefined, <<"Hello">>, <<"Bonjour">>).
ok
2> erli18n_server:lookup_singular(my_domain, <<"fr">>, undefined, <<"Hello">>).
{ok, <<"Bonjour">>}See also insert_plural/5, insert_catalog/3, lookup_singular/4.
-spec loaded_catalogs() -> [{domain(), locale(), non_neg_integer()}].
Lists the loaded catalogs with the data-entry count of each.
Returns [{Domain, Locale, NumEntries}], where NumEntries counts the data
entries (singulars + EACH plural form counted separately; the header does NOT
count — that is why this number differs from header_state().num_entries,
which counts logical entries). The list order is unspecified. Only catalogs with
=1 data entry appear (a header-only
.pois omitted).
1> erli18n_server:ensure_loaded(my_domain, <<"fr">>, "fr.po").
{ok, 128}
2> erli18n_server:loaded_catalogs().
[{my_domain, <<"fr">>, 130}]See also memory_info/0, which_keys/2.
-spec loaded_locales() -> [locale()].
The sorted, distinct locales across all loaded catalogs — the locale projection
of loaded_catalogs/0. Backed by the loaded-catalog index: ONE keyed,
copy-free persistent_term read plus a usort, NOT a node-wide scan, so it is
cheap enough for the per-request locale-negotiation default path.
See also loaded_catalogs/0.
-spec lookup_header(domain(), locale()) -> {ok, header_state()} | undefined.
Lock-free lookup of the (Domain, Locale) catalog header, straight from
persistent_term.
Return
{ok, HeaderState}— seeheader_state()for the contents (compiled plural rule orfallback, rawPlural-Forms,.popath, load instant, vs-CLDR divergence, entry count).undefinedif the catalog is not loaded (or was populated only byinsert_*).
Why this matters
The PRESENCE of the header is the idempotency signal ensure_loaded/3 consults
and what lookup_plural_form/5 reads first to obtain the plural rule.
Failure modes
A non-atom Domain / non-binary Locale crash with function_clause.
1> erli18n_server:ensure_loaded(my_domain, <<"fr">>, "fr.po").
{ok, 128}
2> {ok, H} = erli18n_server:lookup_header(my_domain, <<"fr">>), maps:get(num_entries, H).
128
3> erli18n_server:lookup_header(my_domain, <<"de">>).
undefinedSee also lookup_singular/4, lookup_plural_form/5, header_state().
-spec lookup_plural_form(domain(), locale(), context(), msgid(), integer()) -> {ok, translation()} | undefined.
The CORRECT entry point for plural reads (form-aware, lock-free).
The caller does NOT need to know the form index for N: this function reads the
header, evaluates the catalog's compiled Plural-Forms rule against the count
N to obtain the form index, and then reads the entry at that index. It is the
encapsulation of the locale-specific knowledge the library exists to provide.
Parameters
Domain,Locale,Context,Msgid: identify the plural msgid.N: the count (integer) that decides the form. NOT the form index — thePlural-Formsrule converts it into an index.
Return
{ok, Translation}when the form exists.undefinedon a miss — it is up to the caller to fall back tomsgid_plural(PSD-003).
Fallback rules (order matters)
- Header absent (catalog not loaded, or populated only by
insert_*) ->undefineddirectly. - Header present without
Plural-Forms(plural := fallback) -> uses the C/Germanic default (N == 1 -> form 0; otherwise form 1). - Header with a compiled rule -> evaluates the rule.
erli18n_plural:evaluate/2is total (it clamps malformed rules instead of crashing), so the form index is computed directly — no per-requesttryon this hot path (finding #1).
Failure modes
A non-integer N (or other args outside the guards) is function_clause.
1> erli18n_server:lookup_plural_form(my_domain, <<"fr">>, undefined, <<"file">>, 1).
{ok, <<"fichier">>}
2> erli18n_server:lookup_plural_form(my_domain, <<"fr">>, undefined, <<"file">>, 42).
{ok, <<"fichiers">>}
3> erli18n_server:lookup_plural_form(my_domain, <<"de">>, undefined, <<"file">>, 1).
undefinedSee also lookup_singular/4, lookup_header/2.
-spec lookup_singular(domain(), locale(), context(), msgid()) -> {ok, translation()} | undefined.
Lock-free lookup of a singular translation, straight from persistent_term.
The singular read hot path: a single persistent_term:get/2 + map lookup, with
no roundtrip to the gen_server, executed in the calling process (that is why N
processes read in parallel with no bottleneck). The fetched catalog map is
transient — never cache it.
Parameters
Domain,Locale: the catalog.Context: themsgctxt, orundefined. A lookup with the wrongContextis a miss —msgctxtis part of the key.Msgid: the source text being looked up.
Return
{ok, Translation}if the entry exists.undefinedon a miss (absent catalog or absent key) — it is up to the caller (theerli18nfaçade) to apply the fallback to the rawMsgid. There is no automatic fallback here.
Failure modes
Arguments outside the guards are function_clause (contract break, LOUD
failure), never a silent undefined (finding #16).
1> erli18n_server:insert_singular(my_domain, <<"fr">>, undefined, <<"Hello">>, <<"Bonjour">>).
ok
2> erli18n_server:lookup_singular(my_domain, <<"fr">>, undefined, <<"Hello">>).
{ok, <<"Bonjour">>}
3> erli18n_server:lookup_singular(my_domain, <<"fr">>, undefined, <<"Missing">>).
undefinedSee also lookup_plural_form/5, lookup_header/2.
-spec memory_info() -> #{ets_bytes := non_neg_integer(), num_catalogs := non_neg_integer(), num_keys := non_neg_integer()}.
Returns the memory usage of the loaded catalogs.
Observability read in the calling process; not a hot path (do not call it per request — it scans the node's persistent terms). Returns a map with:
ets_bytes: the catalogs' approximate storage in bytes. The field name is historical (storage is nowpersistent_term, not ETS); it is kept for backwards compatibility with the 0.3.0 return shape.num_catalogs: distinct loaded catalogs that have >=1 data entry (a header-only.podoes not count).num_keys: total stored keys across all catalogs, INCLUDING each catalog's header. So for a single catalog with 130 data entries + 1 header,num_keys = 131.
1> erli18n_server:memory_info().
#{ets_bytes => 24576, num_catalogs => 1, num_keys => 131}See also loaded_catalogs/0, which_keys/2.
-spec reload(domain(), locale(), file:filename()) -> ensure_result().
Atomic reload of a .po catalog. Same as reload/4 with #{}.
Unlike ensure_loaded/3, it NEVER takes the idempotent fast-path: it always
parses and reinstalls, replacing the old catalog wholesale (AMB-001). It never
returns {ok, already}. See reload/4 for the atomic STAGE -> INSTALL
semantics.
1> erli18n_server:reload(my_domain, <<"fr">>, "fr.po").
{ok, 128}
%% An invalid .po does NOT destroy the good catalog in use:
2> erli18n_server:reload(my_domain, <<"fr">>, "broken.po").
{error, {parse_error, ...}}
3> erli18n_server:lookup_singular(my_domain, <<"fr">>, undefined, <<"Hello">>).
{ok, <<"Bonjour">>}See reload/4, ensure_loaded/3.
-spec reload(domain(), locale(), file:filename(), opts()) -> ensure_result().
Atomic reload of a .po catalog with resource options (STAGE -> INSTALL).
The entire failable half (read, parse, compile plural, CLDR divergence, map
build) runs in the CALLING process into an in-memory staged/0 WITHOUT touching
persistent_term, so a reload whose new .po is invalid returns a structured
{error, _} and leaves the previous catalog FULLY INTACT. On success, the only
mutation is a single whole-catalog persistent_term:put — a concurrent reader
sees the entire old or the entire new catalog, never a gap.
The heavy phase runs inside the span [erli18n, catalog, reload]; only the
install commit travels to the server, with a tunable timeout. Opts is
identical to ensure_loaded/4's. Returns {ok, NewlyLoaded} or
{error, ensure_error()} (never {ok, already}).
Edge cases
- The install defers a node-wide
persistent_termliteral-area cleanup (a major GC on processes holding the old catalog plus an all-process heap scan) — the reload cost the old per-row ETS storage did not have. Paid once per reload; negligible for the load-once workload erli18n targets. - The same
bound_error()s asensure_loaded/4apply; on any error the previous catalog stays intact.
1> erli18n_server:reload(my_domain, <<"fr">>, "fr.po", #{timeout => 30000}).
{ok, 128}See reload/3, ensure_loaded/4, opts().
-spec start_link() -> gen_server:start_ret().
Starts the catalog gen_server, registered locally as erli18n_server.
Called by the supervisor — in general you do NOT call this by hand. The server
holds NO catalog data in its state (the catalogs live in persistent_term), so
init/1 is trivial and a crash of this worker loses nothing.
1> {ok, Pid} = erli18n_server:start_link().
{ok, <0.123.0>}
2> is_pid(Pid).
trueSee also init/1.
No cleanup to do: the catalogs live in persistent_term, which is owned by the
runtime and survives a crash of this worker, so terminate/2 must NOT erase them
(that would lose every catalog on a transient crash). Application-stop cleanup is
erli18n_app:stop/1's job. Returns ok.
Removes the (Domain, Locale) catalog entirely (entries + header).
Return and effects
Erases the catalog's single persistent term in O(1). After the unload, lookup_*
of that catalog returns undefined. Synchronous; always returns ok.
Idempotent: unloading a never-loaded catalog is a no-op (also returns ok).
Emits the telemetry span [erli18n, catalog, unload] whose stop metadata
includes result (ok | not_loaded) and keys_removed.
The erase defers a node-wide persistent_term literal-area cleanup (a major GC
on processes holding the old catalog plus an all-process heap scan) — paid once,
acceptable for the admin-frequency unload but documented honestly.
Failure modes
A non-atom Domain / non-binary Locale crash with function_clause.
1> erli18n_server:ensure_loaded(my_domain, <<"fr">>, "fr.po").
{ok, 128}
2> erli18n_server:unload(my_domain, <<"fr">>).
ok
3> erli18n_server:lookup_header(my_domain, <<"fr">>).
undefined
4> erli18n_server:unload(my_domain, <<"fr">>). %% idempotent
okSee also reload/3, loaded_catalogs/0.
-spec which_keys(domain(), locale()) -> [{singular, context(), msgid()} | {plural, context(), msgid()}].
Enumerates the keys (singular and plural) loaded for (Domain, Locale).
Returns a SORTED list of {singular, Context, Msgid} and
{plural, Context, Msgid}. Plural entries are DEDUPLICATED by
(Context, Msgid): a plural msgid with N forms appears ONCE, not N times.
Absent catalog -> empty list. Observability, not a hot path.
1> erli18n_server:insert_singular(d, <<"fr">>, undefined, <<"Hello">>, <<"Bonjour">>).
ok
2> erli18n_server:insert_plural(d, <<"fr">>, undefined, <<"file">>,
.. [{0, <<"fichier">>}, {1, <<"fichiers">>}]).
ok
3> erli18n_server:which_keys(d, <<"fr">>).
[{plural, undefined, <<"file">>}, {singular, undefined, <<"Hello">>}]See also loaded_catalogs/0, memory_info/0.