erli18n_server (erli18n v0.6.1)

Copy Markdown View Source

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:

  1. Read path (hot path, lock-free). lookup_singular/4, lookup_plural_form/5 and lookup_header/2 read persistent_term directly 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).

  2. Write path (serialized). insert_*, unload/2 and the load commits are gen_server:calls; handle_call/3 is the only critical section that mutates persistent_term. The single mailbox closes the check-then-install race that persistent_term (which has no compare-and-swap) cannot close on its own, and lets a batch load issue its puts back to back.

  3. Load orchestration (heavy work OUTSIDE the mailbox). ensure_loaded/4 and reload/4 run the heavy, failable phase (size-check, read, parse, plural compile, CLDR divergence, map build) in the CALLING process, producing a pure in-memory staged() — including the fully-built catalog map. Only this validated payload travels to the server for a microsecond-scale commit (a single persistent_term:put). A large/slow/pathological .po from 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

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

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

bound_error()

-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.

catalog_entry()

-type catalog_entry() :: singular_entry() | plural_entry().

context()

-type context() :: undefined | binary().

divergence_info()

-type divergence_info() :: none | {plural_divergence, binary(), binary()}.

domain()

-type domain() :: atom().

ensure_error()

-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.

ensure_result()

-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 (only ensure_loaded/ensure_loaded_many; reload never returns this).
  • {error, ensure_error()}: structured error; the prior catalog stays intact (all errors occur BEFORE any mutation).

header_state()

-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 compiled Plural-Forms rule (erli18n_plural:plural_compiled()), or the atom fallback when the .po came without a plural header (the lookup then uses the C/Germanic default, see lookup_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()none or the vs-CLDR divergence warning.
  • fuzzy_included: whether the load included #, fuzzy entries.
  • num_entries: entry count (singular + plural aggregated as the parser counts them), the number reported in {ok, NewlyLoaded}.

load_spec()

-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}.

locale()

-type locale() :: binary().

msgid()

-type msgid() :: binary().

msgid_plural()

-type msgid_plural() :: undefined | binary().

opts()

-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 (default false): includes entries marked #, fuzzy.
  • max_bytes (default application:get_env(erli18n, max_po_bytes), 16 MiB): rejects the file BEFORE reading it whole (via filelib:file_size/1). infinity disables the cap.
  • max_entries (default application:get_env(erli18n, max_po_entries), 500000): rejects the catalog AFTER the parse if it has more than N entries. infinity disables the cap.
  • timeout (default 5000 ms): deadline of the commit gen_server:call/3. Since the heavy phase no longer runs behind the mailbox, the deadline covers only the single persistent_term:put (microsecond scale).

plural_entries()

-type plural_entries() :: [{plural_index(), translation()}].

plural_entry()

-type plural_entry() :: {plural, context(), msgid(), msgid_plural(), plural_entries()}.

plural_index()

-type plural_index() :: non_neg_integer().

singular_entry()

-type singular_entry() :: {singular, context(), msgid(), translation()}.

translation()

-type translation() :: binary().

Functions

code_change(OldVsn, State, Extra)

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}.

default_po_path(App, Domain, Locale)

-spec default_po_path(atom(), domain(), locale()) -> file:filename().

Computes the conventional gettext .po path for an application.

Parameters

  • App: the OTP application whose priv contains the catalogs (resolved via code: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.

ensure_loaded(Domain, Locale, PoPath)

-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).

ensure_loaded(Domain, Locale, PoPath, Opts)

-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 (default false): includes entries marked #, fuzzy.
  • max_bytes (non_neg_integer() | infinity): rejects the file BEFORE reading it whole (via filelib:file_size/1); default application: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; default application:get_env(erli18n, max_po_entries) (500000). infinity = no cap.

  • timeout (timeout()): deadline of the commit gen_server:call/3 (default 5000 ms; the commit is a single persistent_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().

ensure_loaded_many(Specs)

-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().

handle_call/3

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; reply ok.
  • {insert_plural, D, L, Ctx, Msgid, Entries} -> merges one entry per form; reply ok.
  • {insert_catalog, D, L, Entries} -> merges the batch; reply ok.
  • {unload, D, L} -> erases the catalog term; emits the span [erli18n, catalog, unload]; reply ALWAYS ok (historical contract).
  • {commit, ensure | reload, D, L, Staged} -> installs an ALREADY validated staged() (the heavy phase ran in the caller). Mode ensure RE-CHECKS idempotency under serialization; mode reload always reinstalls. No span here — it already fired caller-side.

  • {commit_many, Items} -> installs N payloads in one critical section, with ONE memory_warning_check at 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.

handle_cast(Msg, State)

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}.

handle_info(Info, 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}.

init/1

-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.

insert_catalog(Domain, Locale, Entries)

-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}]}. The MsgidPlural is preserved in the parsed format for dump/1 round-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.

insert_plural(Domain, Locale, Context, Msgid, Entries)

-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 in insert_singular/5.
  • Entries: the list [{FormIndex, Translation}] — one plural form per pair, where FormIndex is 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).
undefined

See also insert_singular/5, lookup_plural_form/5, ensure_loaded/3.

insert_singular(Domain, Locale, Context, Msgid, Translation)

-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: the msgctxt, or undefined when 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.

loaded_catalogs()

-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 .po is 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.

loaded_locales()

-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.

lookup_header(Domain, Locale)

-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} — see header_state() for the contents (compiled plural rule or fallback, raw Plural-Forms, .po path, load instant, vs-CLDR divergence, entry count).
  • undefined if the catalog is not loaded (or was populated only by insert_*).

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">>).
undefined

See also lookup_singular/4, lookup_plural_form/5, header_state().

lookup_plural_form(Domain, Locale, Context, Msgid, N)

-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 — the Plural-Forms rule converts it into an index.

Return

  • {ok, Translation} when the form exists.
  • undefined on a miss — it is up to the caller to fall back to msgid_plural (PSD-003).

Fallback rules (order matters)

  • Header absent (catalog not loaded, or populated only by insert_*) -> undefined directly.
  • 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/2 is total (it clamps malformed rules instead of crashing), so the form index is computed directly — no per-request try on 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).
undefined

See also lookup_singular/4, lookup_header/2.

lookup_singular(Domain, Locale, Context, Msgid)

-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: the msgctxt, or undefined. A lookup with the wrong Context is a miss — msgctxt is part of the key.
  • Msgid: the source text being looked up.

Return

  • {ok, Translation} if the entry exists.
  • undefined on a miss (absent catalog or absent key) — it is up to the caller (the erli18n façade) to apply the fallback to the raw Msgid. 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">>).
undefined

See also lookup_plural_form/5, lookup_header/2.

memory_info()

-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 now persistent_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 .po does 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.

reload(Domain, Locale, PoPath)

-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.

reload(Domain, Locale, PoPath, Opts)

-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_term literal-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 as ensure_loaded/4 apply; 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().

start_link()

-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).
true

See also init/1.

terminate(Reason, State)

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.

unload(Domain, Locale)

-spec unload(domain(), locale()) -> 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
ok

See also reload/3, loaded_catalogs/0.

which_keys(Domain, Locale)

-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.