Malachi. Policies
(malachi v0.22.7)
View Source
The administrative entry point for storage policies: define and delete a named policy, bind a topic
to one, and read back what a topic's retention actually is. The wire keys, mix malachi.policy and the
dashboard are thin adapters over this module, so validation, the error vocabulary, the audit trail and
the log line are written once.
Where each half lives
A policy's definition is one object for the cluster, in Malachi.Cluster.PolicyStore. Which policy a
topic uses is that topic's own state, in its vnode's Malachi.Metadata
({:bind_topic_policy, topic, name}), so it travels with the topic when a vnode split moves it.
The checks a replicated state machine cannot make
Neither side can see the other: a metadata vnode cannot read the policy store, and the store cannot read the topics. So this module checks what they cannot, before it submits:
bind/3refuses a name the store does not define (:no_such_policy). A topic bound to an undefined name holds its data: nothing expires under it (Malachi.Cluster.Retention.effective/4).delete/3refuses a policy some topic is still bound to ({:policy_in_use, topics}) unless it is givenforce: true, for the same reason.
The delete asks the vnodes themselves, read linearizably, and refuses ({:bindings_unavailable, _})
when one of them does not answer or a split is moving topics between them. The bind reads this node's
replica of the store. Both are still checks made before a submit, so a bind racing a delete can leave
a topic bound to a name that is gone. That topic then shows up in malachi_retention_unresolved_policy_sweeps_total and in
topic_policy/1 as resolution: :unresolved, and the operator's backstop bound
(MALACHI_RETENTION_UNRESOLVED_POLICY_MAX_AGE_MS) is the only one that applies to it.
Upgrades
Binding needs machine version 4. Until every member of a topic's metadata group runs a release that
implements it and the operator has lifted the version pin, the control plane refuses it identically on
every member with {:unsupported_command, key, 4, effective}, which reason_string/1 turns into
"finish the rolling upgrade". Defining and deleting policies need version 3 only.
Summary
Types
Who asked for a change, as the audit log records it: a username, or cli@<node>.
Why a request was refused. reason_string/1 renders every one of them.
A topic's policy as read back by topic_policy/1.
Functions
Binds topic to the policy name, or detaches it with nil. Refuses a name the store does not
define, so a typo is reported here rather than holding the topic's data forever.
Defines, or replaces, the policy name.
Deletes the policy name. Refuses with {:policy_in_use, topics} while any topic is bound to it,
unless opts has force: true. Deleting a name nothing defines is :ok.
The effective value of every field Malachi.Cluster.Policy.fields/0 lists, as {name, value, origin}
in table order: the flat form a surface prints or encodes. A field with no effective value to report
is left out.
Every definition, as a map from name to policy, read from this node's replica of the store.
A stable, human-readable rendering of a refusal, the same on the wire, in the CLI and on the dashboard. It starts with the reason's name, so a client can still branch on it.
What topic's retention and placement spread actually are, and where each value comes from.
Types
@type actor() :: String.t()
Who asked for a change, as the audit log records it: a username, or cli@<node>.
@type reason() :: :invalid_policy_name | :invalid_topic | :invalid_policy | :no_such_policy | :no_such_topic | :migrating | {:policy_in_use, [String.t()]} | {:bindings_unavailable, term()} | {:unknown_policy_field | :invalid_policy_field | :duplicate_policy_field, String.t()} | {:unsupported_policy_field, String.t(), non_neg_integer(), non_neg_integer()} | {:unsupported_command, term(), non_neg_integer(), non_neg_integer()} | term()
Why a request was refused. reason_string/1 renders every one of them.
@type topic_policy() :: %{ topic: String.t(), policy: Malachi.Cluster.Policy.name() | nil, definition: Malachi.Cluster.Policy.t() | nil, resolution: Malachi.Cluster.Retention.resolution(), retention: %{ required(atom()) => {non_neg_integer() | nil, Malachi.Cluster.Retention.origin()} }, spread_by: {String.t() | nil, :policy | :global} }
A topic's policy as read back by topic_policy/1.
Functions
@spec bind(String.t(), Malachi.Cluster.Policy.name() | nil, actor()) :: :ok | {:error, reason()}
Binds topic to the policy name, or detaches it with nil. Refuses a name the store does not
define, so a typo is reported here rather than holding the topic's data forever.
@spec define( Malachi.Cluster.Policy.name(), Malachi.Cluster.Policy.t() | [{String.t(), term()}], actor() ) :: :ok | {:error, reason()}
Defines, or replaces, the policy name.
policy is either a policy map or the flat {field, value} pairs every surface carries
(Malachi.Cluster.Policy.from_pairs/2). The store validates the definition against the cluster's
effective machine version.
@spec delete(Malachi.Cluster.Policy.name(), actor(), keyword()) :: :ok | {:error, reason()}
Deletes the policy name. Refuses with {:policy_in_use, topics} while any topic is bound to it,
unless opts has force: true. Deleting a name nothing defines is :ok.
@spec effective_pairs(topic_policy()) :: [{String.t(), term(), atom()}]
The effective value of every field Malachi.Cluster.Policy.fields/0 lists, as {name, value, origin}
in table order: the flat form a surface prints or encodes. A field with no effective value to report
is left out.
@spec list() :: {:ok, %{required(Malachi.Cluster.Policy.name()) => Malachi.Cluster.Policy.t()}} | {:error, reason()}
Every definition, as a map from name to policy, read from this node's replica of the store.
A stable, human-readable rendering of a refusal, the same on the wire, in the CLI and on the dashboard. It starts with the reason's name, so a client can still branch on it.
Examples
iex> Malachi.Policies.reason_string({:policy_in_use, ["audit", "events"]})
"policy_in_use: audit, events"
iex> Malachi.Policies.reason_string(:no_such_policy)
"no_such_policy"
@spec topic_policy(String.t()) :: {:ok, topic_policy()} | {:error, reason()}
What topic's retention and placement spread actually are, and where each value comes from.
The retention bounds are computed by Malachi.Cluster.Retention.effective/4, the function the sweep
applies, over this node's configured global limits and its replica of the policy store, so what this
reports is what the data sees. A store that cannot be read is an error here, never an empty store:
answering "no policies" would report the global limits for a topic whose policy holds its data longer.