-module(erli18n_server).
-moduledoc """
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:call`s; `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
- To **load/reload** a `.po`: `ensure_loaded/3,4` (idempotent),
`reload/3,4` (always reinstalls, atomic) or `ensure_loaded_many/1` (batch).
- To **unload**: `unload/2`.
- To **read** a low-level translation (the `erli18n` façade is the usual
front-door): `lookup_singular/4`, `lookup_plural_form/5`, `lookup_header/2`.
- To **write** individual entries (tests, non-`.po` sources):
`insert_singular/5`, `insert_plural/5`, `insert_catalog/3`.
- For **observability**: `memory_info/0`, `loaded_catalogs/0`, `which_keys/2`.
## Quickstart
```erlang
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`.
""".
-behaviour(gen_server).
-include_lib("kernel/include/logger.hrl").
%% eqwalizer suppressions for the `term() -> T' boundary casts below. A
%% gen_server reply (and an `erli18n_telemetry:span/3' result) is specced
%% `term()' because the callback module is resolved at runtime; the cast helpers
%% re-announce a type already proven server-side WITHOUT a runtime
%% `eqwalizer:dynamic_cast/1' call (that helper ships only in the test-only
%% `eqwalizer_support' git dependency Hex cannot package). Full rationale at each
%% function.
-eqwalizer({nowarn_function, cast_ensure_result/1}).
-eqwalizer({nowarn_function, cast_commit_many/1}).
%% Write API (serialized via gen_server — only the server mutates persistent_term).
-export([
start_link/0,
insert_singular/5,
insert_plural/5,
insert_catalog/3,
unload/2
]).
%% Read API (direct persistent_term lookup from caller process — lock-free hot
%% path, per RISK-012 anti-bottleneck pattern).
%%
%% Finding #16 (lookup-plural-5-exported-footgun-bypasses-form-evaluation): the
%% plural read is exposed ONLY through the form-aware `lookup_plural_form/5',
%% which evaluates the catalog's compiled `Plural-Forms' rule against the count N
%% before reading the form. There is no exported raw, index-based plural read:
%% exporting one invited callers to pass the count N as the form index and
%% silently get the wrong plural form. The index selection lives inside
%% `erli18n_pt_store:get_plural_form/5'.
-export([
lookup_singular/4,
lookup_header/2,
lookup_plural_form/5
]).
%% Observability (read-only from caller process).
-export([
memory_info/0,
loaded_catalogs/0,
loaded_locales/0,
which_keys/2
]).
%% Load orchestration: parse .po + compile plural + validate vs CLDR + install
%% atomically. Per BR-MIGRAR-022/029 and RISK-012 this is a serialized write
%% path; idempotency makes the second call cheap.
-export([
ensure_loaded/3,
ensure_loaded/4,
ensure_loaded_many/1,
reload/3,
reload/4,
default_po_path/3
]).
%% gen_server callbacks.
-export([
init/1,
handle_call/3,
handle_cast/2,
handle_info/2,
terminate/2,
code_change/3
]).
-type domain() :: atom().
-type locale() :: binary().
-type context() :: undefined | binary().
-type msgid() :: binary().
-type translation() :: binary().
-type plural_index() :: non_neg_integer().
-type plural_entries() :: [{plural_index(), translation()}].
-type msgid_plural() :: undefined | binary().
-type singular_entry() :: {singular, context(), msgid(), translation()}.
%% Finding #14: the parsed plural entry carries the `msgid_plural` form text (4th
%% element). It plays no part in lookup keying and is dropped when the catalog
%% map is built (it exists purely so `erli18n_po:dump/1` round-trips faithfully).
-type plural_entry() ::
{plural, context(), msgid(), msgid_plural(), plural_entries()}.
-type catalog_entry() :: singular_entry() | plural_entry().
%% Load orchestration types (Part 5).
%%
%% Finding #6 (load-pipeline-serialized-in-gen-server-no-bounds-or-timeout):
%% `opts()` gains resource bounds and a tunable commit timeout. Every field is
%% optional; omitting them preserves the legacy behaviour (modulo the safety-cap
%% defaults). The heavy read+parse+compile+build runs in the CALLING process, so
%% these are the boundary knobs a multi-tenant deployment (ADR-0003) needs:
%% * `max_bytes` — reject the file (via `filelib:file_size/1`) BEFORE reading
%% it whole into memory. `infinity` = no cap.
%% * `max_entries` — reject the catalog AFTER the parse if it has more than N
%% entries. `infinity` = no cap.
%% * `timeout` — timeout of the commit `gen_server:call/3'. The heavy phase
%% no longer runs behind the mailbox, so the deadline only
%% covers the single `persistent_term:put'.
-doc """
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).
""".
-type opts() :: #{
include_fuzzy => boolean(),
max_bytes => non_neg_integer() | infinity,
max_entries => non_neg_integer() | infinity,
timeout => timeout()
}.
-doc """
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).
""".
-type ensure_result() ::
{ok, NewlyLoaded :: non_neg_integer()}
| {ok, already}
| {error, ensure_error()}.
-doc """
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_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()}.
%% Finding #6: errors introduced by the resource bounds. A subset of
%% `ensure_error()', surfaced from the caller-side heavy phase BEFORE any
%% mutation (same "errors before mutation" ordering the load pipeline always had).
-doc """
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 bound_error() ::
{input_too_large, Bytes :: non_neg_integer(), Limit :: non_neg_integer()}
| {too_many_entries, Count :: non_neg_integer(), Limit :: non_neg_integer()}.
%% Finding #6: a single catalog to load in the bulk API. Same positional shape as
%% the `ensure_loaded/4' arguments.
-doc """
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 load_spec() :: {domain(), locale(), file:filename(), opts()}.
-type divergence_info() ::
none
| {plural_divergence, binary(), binary()}.
-doc """
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}`.
""".
-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()
}.
%% Finding #4 (reload-not-atomic-destroys-catalog-and-empty-window) +
%% Finding #6: the product of the pure, failable half of the load pipeline (read
%% + parse + compile + divergence + map build). A `staged/0' is built WITHOUT
%% touching `persistent_term', entirely in the CALLING process, so any error
%% leaves the prior catalog intact. The commit then performs the only observable
%% mutation as a single whole-catalog `persistent_term:put'. `map' is the
%% ready-to-install catalog map (data entries + header); `num_entries' is the
%% count reported back to the caller; `fuzzy_skipped' is the caller-precomputed
%% telemetry count (no re-parse on the server).
-type staged() :: #{
map := erli18n_pt_store:catalog_map(),
divergence := divergence_info(),
domain := domain(),
locale := locale(),
num_entries := non_neg_integer(),
fuzzy_skipped := non_neg_integer()
}.
-export_type([
domain/0,
locale/0,
context/0,
msgid/0,
translation/0,
plural_index/0,
plural_entries/0,
singular_entry/0,
plural_entry/0,
catalog_entry/0,
opts/0,
ensure_result/0,
ensure_error/0,
bound_error/0,
load_spec/0,
divergence_info/0,
header_state/0
]).
%% =========================
%% Public API
%% =========================
-doc """
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.
```erlang
1> {ok, Pid} = erli18n_server:start_link().
{ok, <0.123.0>}
2> is_pid(Pid).
true
```
See also `init/1`.
""".
-spec start_link() -> gen_server:start_ret().
start_link() ->
gen_server:start_link({local, ?MODULE}, ?MODULE, [], []).
-doc """
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).
```erlang
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 insert_singular(domain(), locale(), context(), msgid(), translation()) -> ok.
insert_singular(Domain, Locale, Context, Msgid, Translation) when
is_atom(Domain),
is_binary(Locale),
(Context =:= undefined orelse is_binary(Context)),
is_binary(Msgid),
is_binary(Translation)
->
%% `gen_server:call/2` is typed `term()`. We pattern-match `ok` so the
%% public contract is enforced: the matching `handle_call/3` clause is the
%% only writer of this reply and always returns `{reply, ok, State}`, so any
%% other shape is a contract break and should crash with badmatch.
ok = gen_server:call(
?MODULE,
{insert_singular, Domain, Locale, Context, Msgid, Translation}
).
-doc """
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.
```erlang
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`.
""".
-spec insert_plural(domain(), locale(), context(), msgid(), plural_entries()) -> ok.
insert_plural(Domain, Locale, Context, Msgid, Entries) when
is_atom(Domain),
is_binary(Locale),
(Context =:= undefined orelse is_binary(Context)),
is_binary(Msgid),
is_list(Entries)
->
ok = gen_server:call(
?MODULE,
{insert_plural, Domain, Locale, Context, Msgid, Entries}
).
-doc """
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.
```erlang
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_catalog(domain(), locale(), [catalog_entry()]) -> ok.
insert_catalog(Domain, Locale, Entries) when
is_atom(Domain), is_binary(Locale), is_list(Entries)
->
ok = gen_server:call(?MODULE, {insert_catalog, Domain, Locale, Entries}).
-doc """
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`.
```erlang
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`.
""".
-spec unload(domain(), locale()) -> ok.
unload(Domain, Locale) when is_atom(Domain), is_binary(Locale) ->
ok = gen_server:call(?MODULE, {unload, Domain, Locale}).
%% Finding #16: guarded so a malformed argument is a loud `function_clause' (a
%% contract break) rather than a silent `undefined' miss — consistent with
%% `lookup_plural_form/5'.
-doc """
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).
```erlang
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`.
""".
-spec lookup_singular(domain(), locale(), context(), msgid()) ->
{ok, translation()} | undefined.
lookup_singular(Domain, Locale, Context, Msgid) when
is_atom(Domain),
is_binary(Locale),
(Context =:= undefined orelse is_binary(Context)),
is_binary(Msgid)
->
erli18n_pt_store:get_singular(Domain, Locale, Context, Msgid).
-doc """
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`.
```erlang
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()`.
""".
-spec lookup_header(domain(), locale()) -> {ok, header_state()} | undefined.
lookup_header(Domain, Locale) when is_atom(Domain), is_binary(Locale) ->
erli18n_pt_store:lookup_header(Domain, Locale).
-doc """
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`.
```erlang
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`.
""".
-spec lookup_plural_form(
domain(),
locale(),
context(),
msgid(),
integer()
) ->
{ok, translation()} | undefined.
lookup_plural_form(Domain, Locale, Context, Msgid, N) when
is_atom(Domain),
is_binary(Locale),
(Context =:= undefined orelse is_binary(Context)),
is_binary(Msgid),
is_integer(N)
->
erli18n_pt_store:get_plural_form(Domain, Locale, Context, Msgid, N).
-doc """
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`.
```erlang
1> erli18n_server:memory_info().
#{ets_bytes => 24576, num_catalogs => 1, num_keys => 131}
```
See also `loaded_catalogs/0`, `which_keys/2`.
""".
-spec memory_info() ->
#{
ets_bytes := non_neg_integer(),
num_catalogs := non_neg_integer(),
num_keys := non_neg_integer()
}.
memory_info() ->
Catalogs = erli18n_pt_store:all(),
#{
ets_bytes => sum_nonneg([erli18n_pt_store:storage_bytes(M) || {_D, _L, M} <- Catalogs]),
num_keys => sum_nonneg([erli18n_pt_store:key_count(M) || {_D, _L, M} <- Catalogs]),
num_catalogs =>
length([yes || {_D, _L, M} <- Catalogs, erli18n_pt_store:data_count(M) > 0])
}.
%% Total a list of non-negative integers, narrowing the accumulator back to
%% `non_neg_integer()' at the boundary (integer `+' widens to `integer()' under
%% eqwalizer; the guarded clause re-pins the proven non-negativity).
-spec sum_nonneg([non_neg_integer()]) -> non_neg_integer().
sum_nonneg(Ns) ->
case sum_nonneg(Ns, 0) of
N when is_integer(N), N >= 0 -> N
end.
-spec sum_nonneg([non_neg_integer()], non_neg_integer()) -> integer().
sum_nonneg([], Acc) ->
Acc;
sum_nonneg([N | Rest], Acc) ->
sum_nonneg(Rest, Acc + N).
-doc """
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).
```erlang
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_catalogs() -> [{domain(), locale(), non_neg_integer()}].
loaded_catalogs() ->
[
{D, L, erli18n_pt_store:data_count(M)}
|| {D, L, M} <- erli18n_pt_store:all(), erli18n_pt_store:data_count(M) > 0
].
-doc """
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 loaded_locales() -> [locale()].
loaded_locales() ->
erli18n_pt_store:loaded_locales().
-doc """
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.
```erlang
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`.
""".
-spec which_keys(domain(), locale()) ->
[{singular, context(), msgid()} | {plural, context(), msgid()}].
which_keys(Domain, Locale) when is_atom(Domain), is_binary(Locale) ->
case erli18n_pt_store:get_map(Domain, Locale) of
undefined ->
[];
Map ->
{Singulars, PluralSet} =
split_data_keys(
erli18n_pt_store:data_keys(Map), [], sets:new([{version, 2}])
),
Plurals = plural_set_to_list(PluralSet),
sort_keys(Singulars, Plurals)
end.
%% Split a catalog's data keys into the singular list and the deduplicated plural
%% set (collapsing a multi-form plural msgid to one `{Context, Msgid}').
-spec split_data_keys(
[erli18n_pt_store:data_key()],
[{singular, context(), msgid()}],
sets:set({context(), msgid()})
) -> {[{singular, context(), msgid()}], sets:set({context(), msgid()})}.
split_data_keys([], Singulars, PluralSet) ->
{Singulars, PluralSet};
split_data_keys([{singular, Ctx, Msgid} | Rest], Singulars, PluralSet) ->
split_data_keys(Rest, [{singular, Ctx, Msgid} | Singulars], PluralSet);
split_data_keys([{plural, Ctx, Msgid, _Idx} | Rest], Singulars, PluralSet) ->
split_data_keys(Rest, Singulars, sets:add_element({Ctx, Msgid}, PluralSet)).
-type key_entry() ::
{singular, context(), msgid()} | {plural, context(), msgid()}.
-spec sort_keys(
[{singular, context(), msgid()}],
[{plural, context(), msgid()}]
) -> [key_entry()].
sort_keys(Singulars, Plurals) ->
%% `lists:sort/1,2' is specced `[T] -> [T]' but eqwalizer's solver drops the
%% T binding when T is a union of tuple shapes. A hand-rolled merge sort over
%% the union type carries the precise type through and is acceptable here
%% because `which_keys/2' is an observability call, not a hot path.
Combined = combine_keys(Singulars, Plurals),
merge_sort(Combined).
-spec merge_sort([key_entry()]) -> [key_entry()].
merge_sort([]) ->
[];
merge_sort([X]) ->
[X];
merge_sort(List) ->
{Left, Right} = split_in_half(List, [], []),
merge_sorted(merge_sort(Left), merge_sort(Right)).
-spec split_in_half(
[key_entry()],
[key_entry()],
[key_entry()]
) -> {[key_entry()], [key_entry()]}.
split_in_half([], L, R) -> {L, R};
split_in_half([X], L, R) -> {[X | L], R};
split_in_half([X, Y | Rest], L, R) -> split_in_half(Rest, [X | L], [Y | R]).
-spec merge_sorted([key_entry()], [key_entry()]) -> [key_entry()].
merge_sorted([], B) ->
B;
merge_sorted(A, []) ->
A;
merge_sorted([Ah | At], [Bh | Bt]) ->
case Ah =< Bh of
true -> [Ah | merge_sorted(At, [Bh | Bt])];
false -> [Bh | merge_sorted([Ah | At], Bt)]
end.
-spec combine_keys(
[{singular, context(), msgid()}],
[{plural, context(), msgid()}]
) -> [{singular, context(), msgid()} | {plural, context(), msgid()}].
combine_keys([], Plurals) ->
Plurals;
combine_keys([S | Rest], Plurals) ->
[S | combine_keys(Rest, Plurals)].
%% Materialize the plural set into a precisely-typed list. `sets:to_list/1' has a
%% generic spec eqwalizer can fail to instantiate when the result flows into a
%% heterogeneous context (mixed singular/plural tuples into the sort). Doing the
%% conversion in a helper with an explicit spec is the idiomatic narrow.
-spec plural_set_to_list(sets:set({context(), msgid()})) ->
[{plural, context(), msgid()}].
plural_set_to_list(Set) ->
sets:fold(
fun({C, M}, Acc) -> [{plural, C, M} | Acc] end,
[],
Set
).
%% =========================
%% Load orchestration (Part 5)
%% =========================
-doc """
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.
```erlang
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()) -> ensure_result().
ensure_loaded(Domain, Locale, PoPath) ->
ensure_loaded(Domain, Locale, PoPath, #{}).
%% Finding #6: the heavy half (read+parse+compile+validate+bounds+map build) runs
%% in the CALLING process inside the `[erli18n, catalog, load]' span, so the
%% measurement is per-tenant and OUTSIDE the server mailbox. Only the validated
%% payload is handed to the server for the microsecond commit, with a
%% caller-tunable timeout. The idempotent fast-path stays a pure read (no disk,
%% no server roundtrip).
-doc """
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()`.
```erlang
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(domain(), locale(), file:filename(), opts()) ->
ensure_result().
ensure_loaded(Domain, Locale, PoPath, Opts) when
is_atom(Domain), is_binary(Locale), is_map(Opts)
->
IncludeFuzzy = maps:get(include_fuzzy, Opts, false),
StartMeta = #{
domain => Domain,
locale => Locale,
language => lc_messages,
po_path => to_binary_path(PoPath),
fuzzy_included => IncludeFuzzy
},
%% `erli18n_telemetry:span/3' is specced `span_result() = term()' — it returns
%% the first element of the closure tuple, which is always our
%% `ensure_result()' (proven at the origin: `do_ensure_loaded/4' has a
%% precise `-spec'). Re-announce that type at the boundary with one typed cast
%% (findings #12/#18).
cast_ensure_result(
erli18n_telemetry:span(
erli18n_telemetry:event_catalog_load(),
StartMeta,
fun() ->
Inner = do_ensure_loaded(Domain, Locale, PoPath, Opts),
{Inner, maps:merge(StartMeta, load_stop_metadata(Inner))}
end
)
).
%% Idempotent fast-path (RISK-012 mitigation 2): a pure read, no disk, no server
%% roundtrip. On a miss the heavy phase (`stage_catalog/4') runs in this process
%% and only the validated payload is committed.
-spec do_ensure_loaded(domain(), locale(), file:filename(), opts()) ->
ensure_result().
do_ensure_loaded(Domain, Locale, PoPath, Opts) ->
case lookup_header(Domain, Locale) of
{ok, _} ->
{ok, already};
undefined ->
case stage_catalog(Domain, Locale, PoPath, Opts) of
{error, _} = E ->
E;
{ok, Staged} ->
%% Mode `ensure': the server re-checks idempotency under
%% serialization, closing the check-then-install race between
%% two concurrent callers of the same catalog.
commit_call({commit, ensure, Domain, Locale, Staged}, Opts)
end
end.
%% Reload bypasses the idempotency check: always parses and re-installs.
%% Resolves AMB-001 overwrite semantics.
%%
%% Finding #4 (reload-not-atomic-destroys-catalog-and-empty-window): reload is
%% STAGE -> ATOMIC-INSTALL. The entire failable pipeline (read, parse, plural
%% compile, CLDR divergence, map build) runs 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 previously-good catalog FULLY INTACT.
%% On success the only mutation is a single whole-catalog `persistent_term:put':
%% a concurrent reader sees either the entire old catalog or the entire new one,
%% never a half-applied state — atomicity stronger than the old per-row swap.
-doc """
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.
```erlang
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()) -> ensure_result().
reload(Domain, Locale, PoPath) ->
reload(Domain, Locale, PoPath, #{}).
%% Finding #6: like `ensure_loaded/4', the heavy STAGE runs in the caller inside
%% the `[erli18n, catalog, reload]' span; only the atomic INSTALL commit travels
%% to the server with a tunable timeout. reload never takes the idempotent
%% fast-path: it always re-stages and re-installs.
-doc """
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.
```erlang
1> erli18n_server:reload(my_domain, <<"fr">>, "fr.po", #{timeout => 30000}).
{ok, 128}
```
See `reload/3`, `ensure_loaded/4`, `opts()`.
""".
-spec reload(domain(), locale(), file:filename(), opts()) ->
ensure_result().
reload(Domain, Locale, PoPath, Opts) when
is_atom(Domain), is_binary(Locale), is_map(Opts)
->
IncludeFuzzy = maps:get(include_fuzzy, Opts, false),
StartMeta = #{
domain => Domain,
locale => Locale,
language => lc_messages,
po_path => to_binary_path(PoPath),
fuzzy_included => IncludeFuzzy
},
%% See `ensure_loaded/4': re-announce the `span_result() = term()' as
%% `ensure_result()' at the boundary with one typed cast.
cast_ensure_result(
erli18n_telemetry:span(
erli18n_telemetry:event_catalog_reload(),
StartMeta,
fun() ->
Inner =
case stage_catalog(Domain, Locale, PoPath, Opts) of
{error, _} = E ->
E;
{ok, Staged} ->
commit_call(
{commit, reload, Domain, Locale, Staged}, Opts
)
end,
{Inner, maps:merge(StartMeta, load_stop_metadata(Inner))}
end
)
).
%% Hand the validated payload to the server and narrow the reply. The commit is a
%% single `persistent_term:put', so the default 5000ms is generous; the override
%% exists for deployments that want it tighter or `infinity'.
-spec commit_call(commit_msg(), opts()) -> ensure_result().
commit_call(Msg, Opts) ->
Timeout = maps:get(timeout, Opts, 5000),
cast_ensure_result(gen_server:call(?MODULE, Msg, Timeout)).
-type commit_msg() ::
{commit, ensure | reload, domain(), locale(), staged()}.
%% Finding #6, bulk API. Load N catalogs: the heavy phase of each runs in THIS
%% process (sequential prepare — the v0.1 trade-off; a parallel fan-out is a
%% future evolution), and every ready-to-install payload is delivered in a SINGLE
%% commit. That collapses N server roundtrips into one. Already-loaded or failing
%% catalogs are reported individually; one catalog's error never blocks the
%% others.
-doc """
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.
```erlang
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()`.
""".
-spec ensure_loaded_many([load_spec()]) ->
[{domain(), locale(), ensure_result()}].
ensure_loaded_many(Specs) when is_list(Specs) ->
Prepared = [prepare_one(Spec) || Spec <- Specs],
{ToCommit, Resolved} = partition_prepared(Prepared),
Committed =
case ToCommit of
[] ->
[];
[_ | _] ->
cast_commit_many(
gen_server:call(?MODULE, {commit_many, ToCommit})
)
end,
Resolved ++ Committed.
%% Prepare one spec in the caller: idempotent fast-path or heavy stage.
-spec prepare_one(load_spec()) ->
{domain(), locale(), already}
| {domain(), locale(), {prepared, {ok, staged()} | {error, ensure_error()}}}.
prepare_one({D, L, Path, Opts}) ->
case lookup_header(D, L) of
{ok, _} ->
{D, L, already};
undefined ->
{D, L, {prepared, stage_catalog(D, L, Path, Opts)}}
end.
%% Split prepared specs into those needing a commit (validated payloads) and
%% those already resolved (idempotent hits and prepare errors).
-spec partition_prepared([
{domain(), locale(), already}
| {domain(), locale(), {prepared, {ok, staged()} | {error, ensure_error()}}}
]) ->
{[{domain(), locale(), staged()}], [{domain(), locale(), ensure_result()}]}.
partition_prepared(Prepared) ->
lists:foldr(fun partition_one/2, {[], []}, Prepared).
-spec partition_one(
{domain(), locale(), already}
| {domain(), locale(), {prepared, {ok, staged()} | {error, ensure_error()}}},
{[{domain(), locale(), staged()}], [{domain(), locale(), ensure_result()}]}
) ->
{[{domain(), locale(), staged()}], [{domain(), locale(), ensure_result()}]}.
partition_one({D, L, already}, {Commit, Done}) ->
{Commit, [{D, L, {ok, already}} | Done]};
partition_one({D, L, {prepared, {ok, Payload}}}, {Commit, Done}) ->
{[{D, L, Payload} | Commit], Done};
partition_one({D, L, {prepared, {error, _} = Err}}, {Commit, Done}) ->
{Commit, [{D, L, Err} | Done]}.
%% Findings #12 / #18 — single typed boundary cast.
%%
%% A `gen_server:call/2,3' reply (and an `erli18n_telemetry:span/3' result) is
%% specced `term()' in OTP because the callback module is resolved at RUNTIME.
%% For `erli18n_server' the call is always same-node, same-module, synchronous:
%% every reply is an `ensure_result()' (proven at the ORIGIN — `do_ensure_loaded/4',
%% `do_commit/4', `do_commit_many/1' and `install_staged/3' all carry precise
%% `-spec's). The cast helper returns the value unchanged, annotated with
%% `-eqwalizer({nowarn_function, ...})'. We deliberately do NOT use
%% `eqwalizer:dynamic_cast/1': that is a RUNTIME call into the `eqwalizer' module
%% shipped by `eqwalizer_support', a test-only `git_subdir' dependency Hex cannot
%% package — a published build would crash with `undefined function
%% eqwalizer:dynamic_cast/1'. The static annotation is equivalent at zero runtime
%% cost.
-spec cast_ensure_result(term()) -> ensure_result().
cast_ensure_result(Reply) ->
Reply.
%% As `cast_ensure_result/1' but for the bulk `{commit_many, _}' reply: the
%% server callback (`do_commit_many/1', specced precisely) returns a list of
%% `{domain(), locale(), ensure_result()}'. One cast re-announces that type.
-spec cast_commit_many(term()) -> [{domain(), locale(), ensure_result()}].
cast_commit_many(Reply) ->
Reply.
%% Compute the gettext-style convention path for a given application, domain, and
%% locale: `<priv>/locale/<Locale>/LC_MESSAGES/<Domain>.po'.
-doc """
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}`).
```erlang
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 default_po_path(atom(), domain(), locale()) -> file:filename().
default_po_path(App, Domain, Locale) when
is_atom(App), is_atom(Domain), is_binary(Locale)
->
%% `code:priv_dir/1' returns `file:filename() | {error, bad_name}'. A
%% `bad_name' means the application is unknown — crash explicitly so the
%% operator sees the misconfiguration immediately, instead of silently
%% building a path with `{error, bad_name}' embedded in it.
PrivDir =
case code:priv_dir(App) of
{error, bad_name} ->
error({priv_dir_not_found, App});
Dir when is_list(Dir) ->
Dir
end,
%% `filename:join/1' is specced `file:filename_all()'; we need
%% `file:filename()' (a string) for the public contract. All inputs are
%% strings, so the result is a string too; narrow at the boundary so an
%% impossible binary would surface as a `case_clause' crash.
Joined = filename:join([
PrivDir,
"locale",
binary_to_list(Locale),
"LC_MESSAGES",
atom_to_list(Domain) ++ ".po"
]),
case Joined of
Str when is_list(Str) -> Str
end.
%% =========================
%% gen_server callbacks
%% =========================
-doc """
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 init([]) -> {ok, map()}.
init([]) ->
{ok, #{}}.
-doc """
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_call({insert_singular, D, L, Ctx, Msgid, T}, _From, State) ->
ok = erli18n_pt_store:merge_entries(D, L, [{singular, Ctx, Msgid, T}]),
{reply, ok, State};
handle_call({insert_plural, D, L, Ctx, Msgid, Entries}, _From, State) ->
%% `undefined' is the parsed `msgid_plural' slot (irrelevant to keying); the
%% form-index validation lives in `erli18n_pt_store' (loud on a bad index).
ok = erli18n_pt_store:merge_entries(D, L, [{plural, Ctx, Msgid, undefined, Entries}]),
{reply, ok, State};
handle_call({insert_catalog, D, L, Entries}, _From, State) ->
ok = erli18n_pt_store:merge_entries(D, L, Entries),
{reply, ok, State};
handle_call({unload, D, L}, _From, State) ->
%% Span: [erli18n, catalog, unload]. Always-on (admin operation, not hot
%% path). telemetry:span/3 passes ONLY the stop metadata returned by the
%% closure to the stop event, so the closure builds the full stop metadata.
StartMeta = #{domain => D, locale => L},
_ = erli18n_telemetry:span(
erli18n_telemetry:event_catalog_unload(),
StartMeta,
fun() ->
{Result, KeysRemoved} = do_unload_with_count(D, L),
StopMeta = StartMeta#{
result => Result,
keys_removed => KeysRemoved
},
{ok, StopMeta}
end
),
%% Preserve the historical public contract of `unload/2'.
{reply, ok, State};
%% Finding #6: the server receives ONLY validated, ready-to-install payloads. The
%% heavy read+parse+compile+build already ran in the caller (inside the
%% load/reload span), so this clause is the microsecond critical section. The
%% telemetry span fired caller-side, so no span here. `ensure' mode re-checks
%% idempotency UNDER serialization; `reload' always reinstalls.
handle_call({commit, Mode, D, L, Staged}, _From, State) ->
{reply, do_commit(Mode, D, L, Staged), State};
%% Bulk commit (finding #6): N validated payloads installed in one critical
%% section, with a single deferred `memory_warning_check' at the end instead of
%% one per catalog.
handle_call({commit_many, Items}, _From, State) ->
{reply, do_commit_many(Items), State};
handle_call(_Other, _From, State) ->
{reply, {error, unknown_call}, State}.
-doc """
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_cast(_Msg, State) ->
{noreply, State}.
-doc """
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}`.
""".
handle_info(_Info, State) ->
{noreply, State}.
-doc """
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`.
""".
terminate(_Reason, _State) ->
ok.
-doc """
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}`.
""".
code_change(_OldVsn, State, _Extra) ->
{ok, State}.
%% =========================
%% Internal: commit (serialized critical section) — finding #6
%% =========================
%%
%% The heavy half (read+parse+compile+validate+bounds+map build) already ran in
%% the caller (`stage_catalog/4'), producing a validated `staged/0' payload with
%% the fully-built catalog map. `do_commit/4' is the only mutation: a single
%% `persistent_term:put' (atomic whole-catalog replacement). `ensure' re-checks
%% idempotency under serialization; `reload' always reinstalls.
-spec do_commit(ensure | reload, domain(), locale(), staged()) ->
ensure_result().
do_commit(ensure, Domain, Locale, Staged) ->
%% Re-check idempotency INSIDE serialization: if a concurrent caller
%% installed this catalog while we were preparing, we do not overwrite.
case lookup_header(Domain, Locale) of
{ok, _} -> {ok, already};
undefined -> install_staged(Domain, Locale, Staged)
end;
do_commit(reload, Domain, Locale, Staged) ->
%% Whole-catalog atomic replacement (finding #4): the put overwrites the
%% term, so there are no stale entries to prune.
install_staged(Domain, Locale, Staged).
%% Bulk commit (finding #6): install N validated payloads in one critical
%% section. Each catalog is idempotency-checked and installed without its own
%% `memory_warning_check' — that scan is deferred to a SINGLE call after the whole
%% batch, so a bulk of N is not N memory checks.
-spec do_commit_many([{domain(), locale(), staged()}]) ->
[{domain(), locale(), ensure_result()}].
do_commit_many(Items) ->
Results = [commit_one_no_memcheck(Item) || Item <- Items],
_ = erli18n_telemetry:memory_warning_check(memory_info()),
Results.
-spec commit_one_no_memcheck({domain(), locale(), staged()}) ->
{domain(), locale(), ensure_result()}.
commit_one_no_memcheck({D, L, Staged}) ->
R =
case lookup_header(D, L) of
{ok, _} -> {ok, already};
undefined -> install_staged_no_memcheck(D, L, Staged)
end,
{D, L, R}.
%% Install a validated `staged/0': emit the precomputed side-effects, put the
%% catalog map, and run the post-install `memory_warning_check'. Nothing here can
%% fail (the failable work happened in the caller), so the commit is total and
%% cheap.
-spec install_staged(domain(), locale(), staged()) ->
{ok, non_neg_integer()}.
install_staged(Domain, Locale, Staged) ->
Result = install_staged_no_memcheck(Domain, Locale, Staged),
%% Memory warning check runs after the install so the measurement reflects the
%% post-install state (RISK-011 mitigation 2). Rate-limited inside
%% `erli18n_telemetry:memory_warning_check/1'.
_ = erli18n_telemetry:memory_warning_check(memory_info()),
Result.
%% As `install_staged/3' but WITHOUT the per-catalog memory check, so the bulk
%% path can defer it to one call after the whole batch.
-spec install_staged_no_memcheck(domain(), locale(), staged()) ->
{ok, non_neg_integer()}.
install_staged_no_memcheck(Domain, Locale, Staged) ->
#{
map := Map,
divergence := Divergence,
num_entries := NumEntries,
fuzzy_skipped := FuzzySkipped
} = Staged,
emit_divergence_log(Domain, Locale, Divergence),
%% Telemetry: [erli18n, plural, divergence_warning]. Always-on (load-time,
%% infrequent).
emit_divergence_telemetry(Domain, Locale, Divergence),
emit_fuzzy_skip(Domain, Locale, FuzzySkipped),
%% The only mutation: a single whole-catalog `persistent_term:put'.
ok = erli18n_pt_store:put_map(Domain, Locale, Map),
{ok, NumEntries}.
%% Erase a catalog and report how many stored keys (data entries + header) were
%% removed, for the unload span. `not_loaded' when the catalog was absent.
-spec do_unload_with_count(domain(), locale()) ->
{ok | not_loaded, non_neg_integer()}.
do_unload_with_count(Domain, Locale) ->
case erli18n_pt_store:get_map(Domain, Locale) of
undefined ->
{not_loaded, 0};
Map ->
Removed = erli18n_pt_store:key_count(Map),
ok = erli18n_pt_store:unload(Domain, Locale),
{ok, Removed}
end.
%% Emit the (caller-precomputed) fuzzy-skip count. The heavy second parse that
%% produced this count ran in the caller (`compute_fuzzy_skipped/3'), so the
%% server only fires the telemetry event — no re-parse on the server.
-spec emit_fuzzy_skip(domain(), locale(), non_neg_integer()) -> ok.
emit_fuzzy_skip(_Domain, _Locale, 0) ->
ok;
emit_fuzzy_skip(Domain, Locale, Count) when Count > 0 ->
erli18n_telemetry:emit(
erli18n_telemetry:event_lookup_fuzzy_skip(),
#{count => Count},
#{domain => Domain, locale => Locale}
),
ok.
%% Compile the plural header into the in-memory bundle. Returns
%% `{ok, Compiled | fallback}' where `fallback' signals "no header was present" —
%% the lookup hot path then uses the C/Germanic default instead of evaluating an
%% AST. The parser always emits #{plural_forms := _}, so the two clauses are
%% exhaustive; a missing key would be a parser invariant break (function_clause).
%%
%% Findings #12 / #18: this `-spec' anchors the `compile_error()' union at the
%% ORIGIN, so eqwalizer rejects in BUILD any return outside this union.
-spec maybe_compile_plural(erli18n_po:header_map()) ->
{ok, erli18n_plural:plural_compiled() | fallback}
| {error, erli18n_plural:compile_error()}.
maybe_compile_plural(#{plural_forms := <<>>}) ->
{ok, fallback};
maybe_compile_plural(#{plural_forms := PluralRaw}) ->
case erli18n_plural:compile(PluralRaw) of
{ok, _} = OK -> OK;
{error, _} = E -> E
end.
%% Header divergence vs CLDR is informational only (PSD-004). When the header is
%% absent (`fallback') or the locale is not in the CLDR table, we report `none'.
%%
%% Finding #17: takes the ALREADY compiled plural bundle and hands it straight to
%% `validate_against_cldr_ast/2', which reuses the parsed AST and a memoised
%% CLDR-AST table (no second compile of the same expression).
-spec compute_divergence(
locale(),
erli18n_plural:plural_compiled() | fallback
) -> none | {plural_divergence, binary(), binary()}.
compute_divergence(_Locale, fallback) ->
none;
compute_divergence(Locale, #{} = PluralCompiled) ->
case erli18n_plural:validate_against_cldr_ast(Locale, PluralCompiled) of
ok ->
none;
{warning, {plural_divergence, _Loc, HdrRule, CldrRule}} ->
{plural_divergence, HdrRule, CldrRule}
end.
%% Per BR-MIGRAR-030, log uses OTP logger with `#{domain => [erli18n, server]}'
%% metadata. The divergence info is preserved in the header_state so a telemetry
%% layer can publish it without re-loading the catalog.
emit_divergence_log(_Domain, _Locale, none) ->
ok;
emit_divergence_log(Domain, Locale, {plural_divergence, HdrRule, CldrRule}) ->
Report = #{
event => plural_divergence,
domain_name => Domain,
locale => Locale,
header_rule => HdrRule,
cldr_rule => CldrRule
},
?LOG_WARNING(Report, #{domain => [erli18n, server]}),
ok.
%% Telemetry counterpart to the ?LOG_WARNING above. Always emitted on real
%% divergence; skipped on `none'. Emits `[erli18n, plural, divergence_warning]'.
emit_divergence_telemetry(_Domain, _Locale, none) ->
ok;
emit_divergence_telemetry(
Domain,
Locale,
{plural_divergence, HdrRule, CldrRule}
) ->
erli18n_telemetry:emit(
erli18n_telemetry:event_plural_divergence(),
#{count => 1},
#{
domain => Domain,
locale => Locale,
po_rule => HdrRule,
cldr_rule => CldrRule
}
),
ok.
%% =========================
%% Finding #4/#6: STAGE (heavy phase, runs in the CALLER)
%% =========================
%%
%% `stage_catalog/4' runs the entire FAILABLE, heavy half of the load pipeline —
%% bounds check, file read, parse, plural compile, CLDR divergence, map build —
%% and produces a pure in-memory `staged/0' payload. It performs ZERO mutation,
%% so on `{error, _}' the prior catalog is provably untouched. Finding #6: it runs
%% in the CALLING process, so a large/slow/pathological `.po' from one tenant
%% never blocks another's load.
%%
%% Failure order (all BEFORE any mutation):
%% 0. size cap (filelib:file_size/1, no read) -> {input_too_large, _, _}
%% 1. file:read_file/1 -> {file_error, Posix}
%% 2. erli18n_po:parse/2 -> parse_error()
%% 3. entry cap (post-parse) -> {too_many_entries, _, _}
%% 4. compile plural header -> {plural_compile_error, _}
%% 5. compute_divergence/2 -> never fails (informational)
-spec stage_catalog(domain(), locale(), file:filename(), opts()) ->
{ok, staged()} | {error, ensure_error()}.
stage_catalog(Domain, Locale, PoPath, Opts) ->
IncludeFuzzy = maps:get(include_fuzzy, Opts, false),
MaxBytes = maps:get(max_bytes, Opts, default_max_bytes()),
MaxEntries = maps:get(max_entries, Opts, default_max_entries()),
case check_size(PoPath, MaxBytes) of
{error, _} = SizeErr ->
SizeErr;
ok ->
case file:read_file(PoPath) of
{error, Posix} ->
{error, {file_error, Posix}};
{ok, Bin} ->
case erli18n_po:parse(Bin, #{include_fuzzy => IncludeFuzzy}) of
{error, _} = E ->
E;
{ok, Parsed} ->
stage_parsed(
Domain,
Locale,
PoPath,
IncludeFuzzy,
MaxEntries,
Bin,
Parsed
)
end
end
end.
%% Size cap applied BEFORE reading the whole file into memory: `filelib:file_size/1'
%% stats the file, it does not load bytes (finding #6). `infinity' = no cap.
-spec check_size(file:filename(), non_neg_integer() | infinity) ->
ok | {error, bound_error()}.
check_size(_PoPath, infinity) ->
ok;
check_size(PoPath, MaxBytes) when is_integer(MaxBytes) ->
case filelib:file_size(PoPath) of
Size when Size =< MaxBytes ->
ok;
Size ->
{error, {input_too_large, Size, MaxBytes}}
end.
%% Pure entry-cap + compile + map build half of staging. The entry cap rejects an
%% over-large catalog AFTER the parse; compile failure is the last failable step.
%% On success we build the catalog map and the caller-computed fuzzy_skipped count
%% so the commit has nothing heavy left.
-spec stage_parsed(
domain(),
locale(),
file:filename(),
boolean(),
non_neg_integer() | infinity,
binary(),
erli18n_po:parsed_catalog()
) -> {ok, staged()} | {error, ensure_error()}.
stage_parsed(Domain, Locale, PoPath, IncludeFuzzy, MaxEntries, Bin, Parsed) ->
#{header := Header, entries := Entries} = Parsed,
NumEntries = length(Entries),
case within_entry_cap(NumEntries, MaxEntries) of
{too_many, Max} ->
{error, {too_many_entries, NumEntries, Max}};
ok ->
stage_compiled(
Domain,
Locale,
PoPath,
IncludeFuzzy,
Bin,
Header,
Entries,
NumEntries
)
end.
%% `ok' when within the entry cap, or `{too_many, Max}' carrying the INTEGER cap
%% when exceeded (`infinity' never reaches the error path).
-spec within_entry_cap(non_neg_integer(), non_neg_integer() | infinity) ->
ok | {too_many, non_neg_integer()}.
within_entry_cap(_N, infinity) ->
ok;
within_entry_cap(N, Max) when is_integer(Max) ->
case N =< Max of
true -> ok;
false -> {too_many, Max}
end.
-spec stage_compiled(
domain(),
locale(),
file:filename(),
boolean(),
binary(),
erli18n_po:header_map(),
[erli18n_po:entry()],
non_neg_integer()
) -> {ok, staged()} | {error, ensure_error()}.
stage_compiled(Domain, Locale, PoPath, IncludeFuzzy, Bin, Header, Entries, NumEntries) ->
PluralRaw =
case maps:get(plural_forms, Header, <<>>) of
<<>> -> erli18n_plural:fallback_rule();
Other -> Other
end,
case maybe_compile_plural(Header) of
{error, CompileErr} ->
{error, {plural_compile_error, CompileErr}};
{ok, PluralCompiled} ->
%% Finding #17: pass the ALREADY compiled bundle, not the raw header
%% map, so the divergence check does not recompile the expression.
Divergence = compute_divergence(Locale, PluralCompiled),
HeaderState = #{
plural => PluralCompiled,
plural_raw => PluralRaw,
po_path => PoPath,
loaded_at => erlang:system_time(millisecond),
divergence => Divergence,
fuzzy_included => IncludeFuzzy,
num_entries => NumEntries
},
%% Build the ready-to-install catalog map (data entries + header) off
%% the write path — the commit then does a single `put'.
Map = erli18n_pt_store:build_map(Entries, HeaderState),
FuzzySkipped = compute_fuzzy_skipped(IncludeFuzzy, Bin, NumEntries),
{ok, #{
map => Map,
divergence => Divergence,
domain => Domain,
locale => Locale,
num_entries => NumEntries,
fuzzy_skipped => FuzzySkipped
}}
end.
%% Count the fuzzy entries the default parse dropped, computed in the CALLER
%% (finding #6: the heavy second parse no longer runs on the server). Only
%% re-parses when the consumer opted in to lookup telemetry AND the default
%% (non-fuzzy) load discarded fuzzy entries. The emit happens at commit time from
%% the precomputed count.
-spec compute_fuzzy_skipped(boolean(), binary(), non_neg_integer()) ->
non_neg_integer().
compute_fuzzy_skipped(true = _IncludeFuzzy, _Bin, _DefaultCount) ->
%% include_fuzzy => true: nothing was dropped.
0;
compute_fuzzy_skipped(false, Bin, DefaultCount) ->
case erli18n_telemetry:lookup_telemetry_enabled() of
false ->
0;
true ->
%% Re-parse with include_fuzzy => true against the same bytes that
%% already parsed successfully with include_fuzzy => false. A failure
%% here would be a parser invariant break, so we match exactly.
{ok, #{entries := AllEntries}} =
erli18n_po:parse(Bin, #{include_fuzzy => true}),
erlang:max(0, length(AllEntries) - DefaultCount)
end.
%% Bounds defaults (finding #6), configurable via application env so a deployment
%% can tune or disable (`infinity') them.
-spec default_max_bytes() -> non_neg_integer() | infinity.
default_max_bytes() ->
narrow_bound(application:get_env(erli18n, max_po_bytes, 16 * 1024 * 1024)).
-spec default_max_entries() -> non_neg_integer() | infinity.
default_max_entries() ->
narrow_bound(application:get_env(erli18n, max_po_entries, 500000)).
%% `application:get_env/3' is specced `term()'; narrow the configured value to the
%% bound shape at the boundary. A non-conforming env value is a deployment
%% misconfiguration and crashes with a descriptive payload.
-spec narrow_bound(term()) -> non_neg_integer() | infinity.
narrow_bound(infinity) -> infinity;
narrow_bound(N) when is_integer(N), N >= 0 -> N;
narrow_bound(Other) -> error({invalid_erli18n_bound, Other}).
%% Build the stop metadata for the catalog load/reload span. Maps the internal
%% load result onto the stop-metadata schema.
load_stop_metadata({ok, already}) ->
#{result => already, keys_loaded => 0};
load_stop_metadata({ok, N}) when is_integer(N) ->
#{result => ok, keys_loaded => N};
load_stop_metadata({error, Reason}) ->
#{result => {error, Reason}, keys_loaded => 0}.
%% The `po_path' metadata field must be binary (the catalog_load_metadata
%% typespec). `file:filename()' can be a list or binary; normalize at the
%% telemetry boundary so handlers never have to guard.
to_binary_path(Path) when is_binary(Path) -> Path;
to_binary_path(Path) when is_list(Path) -> unicode:characters_to_binary(Path).