Reads and appends to the literal lists (crontab: [...], plugins: [...],
queues: [...]) inside a host's own config :app, Oban block, by text.
Every list-append in PhoenixKit.Install.ObanConfig used to do the same
thing: trim the list body, look at whether it ended in ,, and glue
",\n" <> entries on. That reads the tail of the source text as if it
were the tail of the list, and a host's list is full of things that are not
list elements — a # every minute after the last tuple, a block of comment
lines before the closing ]. The comma landed inside the comment, the
candidate did not parse, ConfigVerify.verify_or_rollback/3 rolled it back,
and the host was told to add the entries by hand. A real host lost four cron
entries (the job-run sweeper among them) to a five-line comment.
How it avoids reading the tail
The content is first masked: comments (and, for bracket matching, the insides of strings, sigils, heredocs and character literals) are overwritten with spaces, byte for byte, newlines kept — so every offset in the masked copy is the same offset in the original. All decisions (where the block and the list open and close, which character is the last real token) are made on the masked copy; the edit is made on the original. The new entries go after the last real token and the comma that separates them goes right behind that token — in front of any trailing comment, never inside it. Whatever the host wrote after that token (comment lines, an end-of-line comment, an unusual indent) stays where it was.
Inserting at the start of the list was the alternative: it makes the tail
irrelevant too. It was not chosen because it reorders a host's list (Lifeline
would land ahead of Pruner, queues ahead of default), which a host reads as
PhoenixKit rewriting its config; appending keeps the diff a pure addition at
the end.
Two nets
The masking is a lexer written for this purpose, not Elixir's, so it can be wrong about an unusual file. Two checks keep a wrong guess from becoming a corrupted file:
- a file whose masked text does not balance its brackets is refused as
:unreadable— the usual symptom of a construct the masker lost track of; - callers verify the candidate (
ConfigVerify.verify_or_rollback/3: it parses and the entries are members of the intended list) and thenpreserves_original?/3(nothing but the entries changed).
Refusals
A list this module cannot take returns {:error, reason} — see
reason_text/2 for the wording a caller shows. Notably an empty
queues: [] is refused when allow_empty: false (Oban documents an empty
queue list as "run no queues", which a node should not silently be given).
Summary
Functions
Appends entries (each a string, possibly several lines) to the literal
list opened by key: inside config :app_name, Oban.
config :app_name, Oban's own text with comments blanked (strings: true
blanks string bodies too), or nil when there is no literal block to scope
to.
The comments of content and nothing else: code and string bodies are
blanked, the # markers and the comment text stay, newlines are kept. A
commented-out entry that spans several lines reads as consecutive text.
Every spelling of the Cron plugin the file can use: both module names, plus
each short form its alias lines give them (alias Oban.Plugins.Cron,
alias Oban.Plugins.{Cron, Pruner}, alias Oban.{Plugins.Cron},
alias Oban.Plugins.Cron, as: C, alias Oban.Plugins + Plugins.Cron).
Whether a top-level Oban config schedules module, optionally with a digest
cadence. Reads only worker positions in direct crontab entries, resolving
aliases; a module named in a job's arguments does not schedule that worker.
Other cron plugins' literal crontabs count too, to avoid scheduling twice.
The whole file's code with comments blanked and every config :x, Oban block
blanked too — the app's own, other apps', and any nested in an if / case
(a prod-only block says nothing about the other environments). What is left
is what a presence check may read when the app's own list is not a literal
one: the variable or attribute defined outside any Oban block.
True when candidate has a duplicate that original did not: the same service
twice (plugins: and the top-level service options together, names as Oban
renames them), the same tuple twice in one crontab (worker aliases resolved),
the same key twice in queues:. Oban refuses a duplicate plugin at boot, so
this is the check that catches a splice whose idea of the block was wrong,
whatever the reason.
The services that ARE known, even when some option is not readable: {modules, unknown?}. A plugins: held in a variable hides its plugins, but a literal
top-level cron: or lifeline: next to it still counts. :error when the file
does not parse or has no config :app, Oban.
The text between the brackets of key:'s list, as written (:original) and
with comments blanked (:code).
Where key:'s literal list sits inside config :app_name, Oban: the offsets
of its [ and its matching ], and the masked copy they index into.
{:error, reason} when there is no such list (see reason_text/2).
Replaces comments with spaces (newlines kept). With strings: true, string,
sigil, heredoc and character-literal bodies are blanked too (their delimiters
stay) — use that before matching brackets or module names; leave it off (the
default) when the text of a string matters, e.g. cadence: "daily". With
strings: :all the delimiters go as well: nothing of a string is left, so
a " or """ standing in column 0 cannot be mistaken for code there.
True when module (a full name, as a string) is used in the code of the app's
TOP-LEVEL Oban block, with alias resolved: alias PhoenixKit.Jobs.SweepWorker
plus {"*/5 * * * *", SweepWorker} uses PhoenixKit.Jobs.SweepWorker.
True when a nested config :app, Oban comes AFTER the app's top-level block —
the order Config evaluates them in, so the nested one can override it.
Whether a refusal means the list is not a literal one (crontab: some_var).
True when the app's block is config :app, Oban, false (or nil).
True when the block says key: false (or nil).
True when candidate equals original apart from the entries: with every
list element equal to one of the entries removed from both, the two parse to
the same tree (source positions ignored).
Whether a refusal is the host's own choice (nothing to add), not a failure.
Operator-facing explanation of a refusal.
Oban's name for a plugin module: Oban.Plugins.Cron and Oban.Cron are the
same service, as are the other renamed plugins.
The services the app's Oban config sets up, as Oban sees them: the modules of
plugins: (aliases resolved, renamed), the top-level cron:, pruner:,
lifeline:, reindexer: options, and a legacy crontab:. :error when the
file does not parse, there is no config :app, Oban, or an option holds
something the tree cannot read (a variable) — see known_services/2 for what
can be said anyway.
Applies fun to the original text of config :app_name, Oban and splices the
result back. The content is returned untouched when there is no literal block.
Types
Functions
@spec append_to_list(String.t(), atom() | String.t(), atom(), [String.t()], keyword()) :: {:ok, String.t()} | {:error, reason()}
Appends entries (each a string, possibly several lines) to the literal
list opened by key: inside config :app_name, Oban.
Continuation lines of an entry are re-indented to the list's entry indent,
and the line ending (LF or CRLF) of the list's own neighbourhood is kept.
Returns {:ok, candidate} — still to be verified by the caller — or
{:error, reason}.
Options: allow_empty: false refuses a list with no real element.
config :app_name, Oban's own text with comments blanked (strings: true
blanks string bodies too), or nil when there is no literal block to scope
to.
The comments of content and nothing else: code and string bodies are
blanked, the # markers and the comment text stay, newlines are kept. A
commented-out entry that spans several lines reads as consecutive text.
Every spelling of the Cron plugin the file can use: both module names, plus
each short form its alias lines give them (alias Oban.Plugins.Cron,
alias Oban.Plugins.{Cron, Pruner}, alias Oban.{Plugins.Cron},
alias Oban.Plugins.Cron, as: C, alias Oban.Plugins + Plugins.Cron).
@spec crontab_entry_used?( String.t(), atom() | String.t(), String.t(), String.t() | nil ) :: boolean()
Whether a top-level Oban config schedules module, optionally with a digest
cadence. Reads only worker positions in direct crontab entries, resolving
aliases; a module named in a job's arguments does not schedule that worker.
Other cron plugins' literal crontabs count too, to avoid scheduling twice.
The whole file's code with comments blanked and every config :x, Oban block
blanked too — the app's own, other apps', and any nested in an if / case
(a prod-only block says nothing about the other environments). What is left
is what a presence check may read when the app's own list is not a literal
one: the variable or attribute defined outside any Oban block.
True when candidate has a duplicate that original did not: the same service
twice (plugins: and the top-level service options together, names as Oban
renames them), the same tuple twice in one crontab (worker aliases resolved),
the same key twice in queues:. Oban refuses a duplicate plugin at boot, so
this is the check that catches a splice whose idea of the block was wrong,
whatever the reason.
The services that ARE known, even when some option is not readable: {modules, unknown?}. A plugins: held in a variable hides its plugins, but a literal
top-level cron: or lifeline: next to it still counts. :error when the file
does not parse or has no config :app, Oban.
@spec list_text(String.t(), atom() | String.t(), atom()) :: {:ok, %{original: String.t(), code: String.t()}} | {:error, reason()}
The text between the brackets of key:'s list, as written (:original) and
with comments blanked (:code).
@spec locate_list(String.t(), atom() | String.t(), atom()) :: {:ok, %{masked: String.t(), open: non_neg_integer(), close: non_neg_integer()}} | {:error, reason()}
Where key:'s literal list sits inside config :app_name, Oban: the offsets
of its [ and its matching ], and the masked copy they index into.
{:error, reason} when there is no such list (see reason_text/2).
For :crontab only the list of an Oban.Plugins.Cron tuple counts — a
crontab: of another plugin (DynamicCron) is not the one entries belong
in.
Replaces comments with spaces (newlines kept). With strings: true, string,
sigil, heredoc and character-literal bodies are blanked too (their delimiters
stay) — use that before matching brackets or module names; leave it off (the
default) when the text of a string matters, e.g. cadence: "daily". With
strings: :all the delimiters go as well: nothing of a string is left, so
a " or """ standing in column 0 cannot be mistaken for code there.
The result has the same byte length as the input and the same newlines.
True when module (a full name, as a string) is used in the code of the app's
TOP-LEVEL Oban block, with alias resolved: alias PhoenixKit.Jobs.SweepWorker
plus {"*/5 * * * *", SweepWorker} uses PhoenixKit.Jobs.SweepWorker.
True when a nested config :app, Oban comes AFTER the app's top-level block —
the order Config evaluates them in, so the nested one can override it.
Whether a refusal means the list is not a literal one (crontab: some_var).
True when the app's block is config :app, Oban, false (or nil).
True when the block says key: false (or nil).
True when candidate equals original apart from the entries: with every
list element equal to one of the entries removed from both, the two parse to
the same tree (source positions ignored).
This is the net under the masking heuristic. A splice that put the comma inside one of the host's strings, or merged two of its elements, still parses — and still contains the new entries — but changes something the host wrote, which this catches. It does not say where the entries went; callers confirm that with their own check on the parsed candidate.
Whether a refusal is the host's own choice (nothing to add), not a failure.
Operator-facing explanation of a refusal.
Oban's name for a plugin module: Oban.Plugins.Cron and Oban.Cron are the
same service, as are the other renamed plugins.
The services the app's Oban config sets up, as Oban sees them: the modules of
plugins: (aliases resolved, renamed), the top-level cron:, pruner:,
lifeline:, reindexer: options, and a legacy crontab:. :error when the
file does not parse, there is no config :app, Oban, or an option holds
something the tree cannot read (a variable) — see known_services/2 for what
can be said anyway.
Applies fun to the original text of config :app_name, Oban and splices the
result back. The content is returned untouched when there is no literal block.