Malachi.Cluster.Retention (malachi v0.22.7)

View Source

Pure retention policy for sealed segments: the decision layer, no storage or timers.

Given the control-plane Malachi.Metadata, the current time, and a policy, it returns the sealed segment ids to expire. Two independent rules, unioned:

  • age. A sealed segment older than :max_age_ms (by its sealed_at) expires;
  • size. Per range, if the sealed segments total more than :max_bytes, the oldest expire until the range is back under the limit.

Size is scoped per range (the natural unit; a range's segments have contiguous offsets), age is per segment. The active segment is never returned: it is still being written. A nil bound disables that rule. Malachi.Cluster.RetentionCoordinator executes the returned ids.

The bound applied to each range is its topic's policy retention merged over the global policy (the policy overrides only the keys it sets), or the global policy passed in when the topic points at no policy, so retention is per-topic (C3c-2b). The topic's metadata says which policy NAME it points at (Malachi.Metadata.topic_policy_name/2); the definitions come from the cluster's policy store and are passed in already resolved, which keeps this module pure. A name the caller could not resolve expires nothing rather than falling back to the global limits: see expired/5.

Summary

Types

A topic's effective retention, as effective/4 reports it.

Where an effective bound came from.

A retention policy. A nil (or absent) bound disables that rule.

What an expire answered, as a closed set so it can label a metric: the control plane's own replies to a delete, and :other for anything else (a Raft timeout, an unexpected term).

How a topic's policy name resolved: no name, a defined name, or a name nothing defines.

Functions

Whether a control-plane answer authorizes deleting the segment's stored bytes on its replicas.

A topic's effective retention: each bound's value and where it came from, and how the topic's policy NAME resolved against policies.

The sealed segment ids to expire at now_ms (epoch ms). global_policy is the fallback; each range uses its topic's policy retention merged over it (see the module doc).

The reply_label of an expire's answer. :ok deleted the segment; :no_such_segment means it was already gone (a retried sweep), which is neither an expiry nor a failure; :migrating (the topic is moving between vnodes) and :segment_active (the segment is still the write head) are refusals.

The topics whose bound policy name policies does not resolve, sorted.

Types

effective()

@type effective() :: %{
  resolution: resolution(),
  retention: %{required(atom()) => {non_neg_integer() | nil, origin()}}
}

A topic's effective retention, as effective/4 reports it.

origin()

@type origin() :: :policy | :global | :unresolved_backstop

Where an effective bound came from.

policy()

@type policy() :: %{
  optional(:max_age_ms) => non_neg_integer() | nil,
  optional(:max_bytes) => non_neg_integer() | nil,
  optional(:segment_max_age_ms) => pos_integer() | nil
}

A retention policy. A nil (or absent) bound disables that rule.

reply_label()

@type reply_label() :: :ok | :no_such_segment | :migrating | :segment_active | :other

What an expire answered, as a closed set so it can label a metric: the control plane's own replies to a delete, and :other for anything else (a Raft timeout, an unexpected term).

resolution()

@type resolution() :: :none | :resolved | :unresolved

How a topic's policy name resolved: no name, a defined name, or a name nothing defines.

Functions

delete_replicas?(reply)

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

Whether a control-plane answer authorizes deleting the segment's stored bytes on its replicas.

Only two do, and both mean the same thing: the control plane no longer lists the segment, so no read can be served from it and nothing will ever repair it. :ok is this sweep having removed it; :no_such_segment is an earlier sweep having removed it and died before deleting the files, which is the only retry the files ever get, since a segment gone from the control plane never comes back from expired/3.

Every other answer keeps the files. A refusal (:migrating, :segment_active) leaves the segment listed with its replica set intact, and deleting the bytes under it would leave every replica short of its recorded byte_size: Malachi.Cluster.SelfHealing then finds no intact copy to repair from, and a consumer positioned in that segment reads :eof until some later sweep finally gets :ok. :other covers a Raft timeout, where the command may still have committed and may not: skipping the delete is the recoverable side of that ambiguity, because the next sweep gets :no_such_segment and deletes then, while deleting now cannot be undone.

effective(name, policies, global_policy, unresolved_max_age_ms)

A topic's effective retention: each bound's value and where it came from, and how the topic's policy NAME resolved against policies.

The one place that decides what a topic's retention is. The sweep applies it (expired/5) and the admin read-back reports it (Malachi.Policies.topic_policy/1), so what an operator is told is what the data sees. Three cases, and the third is the one worth spelling out.

A topic that points at no policy (name is nil) uses the global limits, as it always has. A topic whose policy resolves takes the bounds that policy sets, nil and 0 included, and inherits the others from the global limits.

A topic that points at a NAME policies does not hold is neither. Falling back to the global limits there is what the administrator did not ask for: the name exists because they wanted something other than the default, and the most common reason a more permissive policy is asked for is to keep data longer. Expiring under the global bound would then delete exactly the data the policy existed to hold. So nothing expires, unless the operator has set a backstop bound for this case (:retention_unresolved_policy_max_age_ms), which is the only bound anyone has stated for it.

Examples

iex> Malachi.Cluster.Retention.effective(nil, %{}, %{max_age_ms: 1_000}, nil)
%{
  resolution: :none,
  retention: %{max_age_ms: {1_000, :global}, max_bytes: {nil, :global}, segment_max_age_ms: {nil, :global}}
}

iex> policies = %{"keep" => %{retention: %{max_bytes: 0}}}
iex> Malachi.Cluster.Retention.effective("keep", policies, %{max_age_ms: 1_000}, nil)
%{
  resolution: :resolved,
  retention: %{max_age_ms: {1_000, :global}, max_bytes: {0, :policy}, segment_max_age_ms: {nil, :global}}
}

expired(metadata, now_ms, global_policy, policies \\ %{}, unresolved_max_age_ms \\ nil)

The sealed segment ids to expire at now_ms (epoch ms). global_policy is the fallback; each range uses its topic's policy retention merged over it (see the module doc).

policies maps a policy NAME to its definition. The metadata says which name a topic points at, and the definitions are an administrative object of the cluster (Malachi.Cluster.PolicyStore.fetch_all/0), so the caller resolves them once per sweep and hands them in: this stays pure, and a sweep costs one read of the policy store rather than one per range.

reply_label(arg1)

@spec reply_label(term()) :: reply_label()

The reply_label of an expire's answer. :ok deleted the segment; :no_such_segment means it was already gone (a retried sweep), which is neither an expiry nor a failure; :migrating (the topic is moving between vnodes) and :segment_active (the segment is still the write head) are refusals.

unresolved_policies(metadata, policies)

The topics whose bound policy name policies does not resolve, sorted.

What effective/4 refuses to expire under the global limits, named so a caller can count it. A name that stays unresolved is a misconfiguration (a policy deleted, or a typo in the binding) and the disk it holds is invisible otherwise.

Sorted explicitly, not by accident: metadata.topics is a map, and above 32 keys it iterates in hash order rather than key order, so the same set of topics would come back in a different order on a cluster large enough to matter and in a stable one on every cluster small enough to test.