Malachi.Cluster.Policy (malachi v0.22.7)

View Source

A named storage policy: what an administrator says about a topic's segments, rather than something a vnode decides.

A policy carries a retention override and a placement spread attribute, and a topic points at one by name. This module is only the shape and the rule for accepting one; where the definitions live is Malachi.Cluster.PolicyRegistry, and which topic points at which name stays in Malachi.Metadata, because that part is per-topic state and travels with the topic when it moves between vnodes.

Why validation is here and not at the edge

A policy that is stored is a policy every node will read, so the control plane is where the answer to "is this a policy" has to be the same on every replica. Keeping the predicate pure and in one module means the wire, a mix task and the replicated command cannot drift into three different opinions of what a policy is.

What is refused, and why each one is worth refusing:

  • An unknown key. %{retention_ms: 1000} is a plausible typo for :retention and would be stored, read back, and silently do nothing forever. There is no valid reason to keep a key the code does not read.
  • A bound that is not a non-negative integer. max_bytes: "10GB" is the shape an operator reaches for first. Malachi.Cluster.Retention compares it with >, which never matches against a binary, so the rule would simply never fire.
  • A negative bound. An age or a byte budget below zero expires everything the rule can see, which is the opposite of what anyone typing a negative number wants.
  • A bound below its field's floor. retention.segment_max_age_ms rolls a topic's active segment, and a roll every few seconds would multiply the topic's segments for no retention a sweep running once a minute could deliver.

nil is allowed for a bound and means that rule is off, which is how a policy overrides one of the two limits without inheriting the other.

Which fields exist, and since when

The settable fields are one table (fields/0), each with the machine version that introduced it, and validation reads that table at the group's effective version (validate/3). See the comment above the table for why a field is never added any other way.

Summary

Types

One settable field: its flat name (what the wire, the CLI and the dashboard say), its path in the policy map, its type, the machine version that introduced it (since), and, for a bound, the smallest value it accepts other than nil (min, default 0).

How a field's value is checked and carried: a :bound is a non-negative integer or nil (the rule is off); an :attribute is a non-empty string or nil.

A policy's name, as an administrator chose it.

t()

A named storage policy: per-topic retention overrides and a placement spread attribute. Both keys are optional; a policy applies only the ones it sets, falling back to the global defaults otherwise.

Functions

A topic's effective placement spread attribute and where it comes from: the policy's :spread_by when the policy sets the key (nil included, which turns spreading off for that topic), otherwise global. A topic with no policy, or bound to a name nothing defines, passes nil and gets global, which is what placement does (Malachi.Broker): placement fails open.

Every field a policy can set, in table order (see field/0).

Whether policy is a policy this build can act on: validate/3 at code_version/0.

Whether name can name a policy: a non-empty binary.

Types

field()

@type field() :: %{
  :name => String.t(),
  :path => [atom(), ...],
  :type => field_type(),
  :since => non_neg_integer(),
  optional(:min) => non_neg_integer()
}

One settable field: its flat name (what the wire, the CLI and the dashboard say), its path in the policy map, its type, the machine version that introduced it (since), and, for a bound, the smallest value it accepts other than nil (min, default 0).

field_type()

@type field_type() :: :bound | :attribute

How a field's value is checked and carried: a :bound is a non-negative integer or nil (the rule is off); an :attribute is a non-empty string or nil.

name()

@type name() :: String.t()

A policy's name, as an administrator chose it.

t()

@type t() :: %{
  optional(:retention) => %{
    optional(:max_age_ms) => non_neg_integer() | nil,
    optional(:max_bytes) => non_neg_integer() | nil,
    optional(:segment_max_age_ms) => pos_integer() | nil
  },
  optional(:spread_by) => String.t() | nil
}

A named storage policy: per-topic retention overrides and a placement spread attribute. Both keys are optional; a policy applies only the ones it sets, falling back to the global defaults otherwise.

Functions

effective_spread_by(arg1, global)

@spec effective_spread_by(t() | nil, String.t() | nil) ::
  {String.t() | nil, :policy | :global}

A topic's effective placement spread attribute and where it comes from: the policy's :spread_by when the policy sets the key (nil included, which turns spreading off for that topic), otherwise global. A topic with no policy, or bound to a name nothing defines, passes nil and gets global, which is what placement does (Malachi.Broker): placement fails open.

Examples

iex> Malachi.Cluster.Policy.effective_spread_by(%{spread_by: "rack"}, "dc")
{"rack", :policy}

iex> Malachi.Cluster.Policy.effective_spread_by(%{retention: %{}}, "dc")
{"dc", :global}

field(name, fields \\ [%{name: "retention.max_age_ms", path: [:retention, :max_age_ms], since: 3, type: :bound}, %{name: "retention.max_bytes", path: [:retention, :max_bytes], since: 3, type: :bound}, %{min: 60000, name: "retention.segment_max_age_ms", path: [:retention, :segment_max_age_ms], since: 6, type: :bound}, %{name: "spread_by", path: [:spread_by], since: 3, type: :attribute}])

@spec field(String.t(), [field()]) :: field() | nil

The field named name in fields, or nil.

fields()

@spec fields() :: [field()]

Every field a policy can set, in table order (see field/0).

from_pairs(pairs, fields \\ [%{name: "retention.max_age_ms", path: [:retention, :max_age_ms], since: 3, type: :bound}, %{name: "retention.max_bytes", path: [:retention, :max_bytes], since: 3, type: :bound}, %{min: 60000, name: "retention.segment_max_age_ms", path: [:retention, :segment_max_age_ms], since: 6, type: :bound}, %{name: "spread_by", path: [:spread_by], since: 3, type: :attribute}])

@spec from_pairs([{String.t(), term()}], [field()]) ::
  {:ok, t()}
  | {:error,
     {:unknown_policy_field | :invalid_policy_field | :duplicate_policy_field,
      String.t()}}

Builds a policy from flat {name, value} pairs, the form the wire, the CLI and the dashboard carry.

A field absent from pairs is absent from the policy (it inherits the global value), nil turns its rule off, and 0 is a real budget: the three stay distinct. Refuses an unknown name ({:unknown_policy_field, name}), a value its type does not allow ({:invalid_policy_field, name}) and a name given twice ({:duplicate_policy_field, name}). Checks the table of this build only: whether the cluster's effective version admits each field is the store's decision (validate/3).

to_pairs(policy, fields \\ [%{name: "retention.max_age_ms", path: [:retention, :max_age_ms], since: 3, type: :bound}, %{name: "retention.max_bytes", path: [:retention, :max_bytes], since: 3, type: :bound}, %{min: 60000, name: "retention.segment_max_age_ms", path: [:retention, :segment_max_age_ms], since: 6, type: :bound}, %{name: "spread_by", path: [:spread_by], since: 3, type: :attribute}])

@spec to_pairs(t(), [field()]) :: [{String.t(), term()}]

The fields policy sets, as {name, value} pairs in table order: the inverse of from_pairs/2.

valid?(policy)

@spec valid?(term()) :: boolean()

Whether policy is a policy this build can act on: validate/3 at code_version/0.

Examples

iex> Malachi.Cluster.Policy.valid?(%{retention: %{max_age_ms: 604_800_000}, spread_by: "rack"})
true

iex> Malachi.Cluster.Policy.valid?(%{retention: %{max_bytes: "10GB"}})
false

iex> Malachi.Cluster.Policy.valid?(%{retention_ms: 1_000})
false

iex> Malachi.Cluster.Policy.valid?(%{retention: %{max_bytes: nil}})
true

iex> Malachi.Cluster.Policy.valid?(%{spread_by: :rack})
false

valid?(policy, version, fields \\ [%{name: "retention.max_age_ms", path: [:retention, :max_age_ms], since: 3, type: :bound}, %{name: "retention.max_bytes", path: [:retention, :max_bytes], since: 3, type: :bound}, %{min: 60000, name: "retention.segment_max_age_ms", path: [:retention, :segment_max_age_ms], since: 6, type: :bound}, %{name: "spread_by", path: [:spread_by], since: 3, type: :attribute}])

@spec valid?(term(), non_neg_integer(), [field()]) :: boolean()

Whether policy is valid at machine version: validate/3 answering :ok.

valid_name?(name)

@spec valid_name?(term()) :: boolean()

Whether name can name a policy: a non-empty binary.

Examples

iex> Malachi.Cluster.Policy.valid_name?("durable")
true

iex> Malachi.Cluster.Policy.valid_name?("")
false

validate(policy, version, fields \\ [%{name: "retention.max_age_ms", path: [:retention, :max_age_ms], since: 3, type: :bound}, %{name: "retention.max_bytes", path: [:retention, :max_bytes], since: 3, type: :bound}, %{min: 60000, name: "retention.segment_max_age_ms", path: [:retention, :segment_max_age_ms], since: 6, type: :bound}, %{name: "spread_by", path: [:spread_by], since: 3, type: :attribute}])

@spec validate(term(), non_neg_integer(), [field()]) ::
  :ok
  | {:error,
     :invalid_policy
     | {:unsupported_policy_field, String.t(), non_neg_integer()}}

Checks policy against the fields of fields introduced at or below version.

Answers {:error, {:unsupported_policy_field, name, since}} when the policy sets a field this build knows but version does not yet admit, so a caller can say "finish the rolling upgrade" rather than "invalid", and {:error, :invalid_policy} for anything else that is not a policy: an unknown key, a bound that is neither nil nor a non-negative integer at or above its field's min, an attribute that is not a non-empty string or nil, or a value that is not a map where the table has a nested map.