blob: 724666b24e3ea5c81d16eb51dabc1fa077db4d53 [file]
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
//! Per-shard periodic segment cleaner.
//!
//! Local and unreplicated: every replica (primary and backup) trims its own
//! expired or over-budget sealed segments. Divergence in physical log start is
//! invisible to clients because reads are served by the partition primary. The
//! timer resolves each owned partition's retention policy from metadata and
//! stamps `now`, then hands a `CleanPartition` request to the shard pump, the
//! single writer of partition state, which performs the deletion serialized
//! with reads. This mirrors the legacy server's `MessagesCleaner` ->
//! message-pump `CleanTopicMessages` path.
use crate::shell::ServerShard;
use consensus::{MetadataHandle, PartitionsHandle};
use iggy_common::{IggyExpiry, IggyTimestamp, MaxTopicSize};
use metadata::impls::metadata::StreamsFrontend;
use shard::Receiver;
use std::collections::HashSet;
use std::rc::Rc;
use std::time::Duration;
use tracing::{trace, warn};
/// Disclosure for a `max_topic_size` the server cannot enforce as written,
/// shared by admission (which says it once, when the cap is set) and the
/// cleaner (which says it once per topic, when it floors the budget).
pub const UNENFORCEABLE_TOPIC_SIZE_WARN: &str = concat!(
"max_topic_size is below what retention can enforce; the segment cleaner keeps at ",
"least one sealed segment per partition instead, and enforces nothing while disabled"
);
/// Run the cleaner until `stop` fires. Wakes every `interval`; expiry and size
/// are evaluated against wall-clock and resident bytes, so no metadata-commit
/// wake is needed.
pub async fn run_segment_cleaner(shard: Rc<ServerShard>, stop: Receiver<()>, interval: Duration) {
trace!(
shard = shard.id,
interval_ms = interval.as_millis(),
"segment cleaner started"
);
// Topic incarnations whose floored budget has already been reported. Only
// grows, and only for topics whose configured cap is below what can be
// enforced, so it stays far smaller than the topic table.
let mut reported_floors = HashSet::new();
loop {
// `Ok(_)`: stop signalled -> exit. `Err(_)`: interval elapsed -> pass.
match compio::time::timeout(interval, stop.recv()).await {
Ok(_) => break,
Err(_) => stage_owned_partitions(&shard, &mut reported_floors),
}
}
trace!(shard = shard.id, "segment cleaner exited");
}
/// Stage a cleaner pass for every partition this shard owns whose topic has a
/// retention policy. Reads config off-pump and hands the resolved decision to
/// the pump; partitions with no policy are skipped without a frame.
///
/// `reported_floors` carries the topics already named in a floor-raise log
/// across passes, so the disclosure lands once per topic instead of once per
/// interval tick. Slab ids are reused after a delete, so the creation revision
/// is part of the key: a topic recreated into the same slots discloses again.
fn stage_owned_partitions(
shard: &Rc<ServerShard>,
reported_floors: &mut HashSet<(usize, usize, u64)>,
) {
let now = IggyTimestamp::now();
let namespaces: Vec<_> = shard.plane.partitions().namespaces().copied().collect();
let streams = shard.plane.metadata().mux_stm.streams();
let max_message_size = u64::try_from(shard.bus_max_message_size()).unwrap_or(u64::MAX);
for namespace in namespaces {
let Some((message_expiry, max_topic_size, partition_count, segment_size)) =
streams.topic_retention_config(namespace.stream_id(), namespace.topic_id())
else {
continue;
};
// `ServerDefault` resolves to "never expire" here, matching the legacy
// cleaner: a topic created with the server default never expires
// segments unless an explicit duration was stored.
let has_expiry = !matches!(
message_expiry,
IggyExpiry::NeverExpire | IggyExpiry::ServerDefault
);
// A topic that left `segment_size` unset rotates at the node default,
// which is the same value admission floors its cap against.
let segment_size = segment_size.map_or(iggy_common::DEFAULT_SEGMENT_SIZE, |segment_size| {
segment_size.as_bytes_u64()
});
let budget = per_partition_size_budget(
max_topic_size,
iggy_common::DEFAULT_MAX_TOPIC_SIZE,
partition_count,
segment_size.saturating_add(max_message_size),
);
if !has_expiry && budget.is_none() {
continue;
}
if let Some(budget) = budget
&& budget.max_bytes > budget.configured_share
&& reported_floors.insert((
namespace.stream_id(),
namespace.topic_id(),
streams
.created_revision_for_namespace(namespace)
.unwrap_or_default(),
))
{
warn!(
shard = shard.id,
stream_id = namespace.stream_id(),
topic_id = namespace.topic_id(),
max_topic_size = budget.resolved_cap,
partition_count,
configured_share = budget.configured_share,
enforced_per_partition = budget.max_bytes,
"{UNENFORCEABLE_TOPIC_SIZE_WARN}"
);
}
shard.request_clean_partition(
namespace,
now,
message_expiry,
budget.map(|budget| budget.max_bytes),
);
}
}
/// A topic's enforced per-partition budget, next to the share it was derived
/// from. The two differ exactly when the configured cap is too small to be
/// enforceable, which is what the cleaner discloses once per topic.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct PartitionSizeBudget {
/// Cap on this partition's SEALED bytes.
max_bytes: u64,
/// `max_topic_size / partition_count`, before the floor.
configured_share: u64,
/// The topic-wide cap the share came from, with `ServerDefault` already
/// resolved to the node default. The sentinel's own `as_bytes_u64()` is 0,
/// so this is what a caller has to report.
resolved_cap: u64,
}
/// Per-partition byte budget for a topic, or `None` for "no cap".
///
/// The cluster has no single owner of a topic-wide total, so each partition
/// keeps an equal share.
///
/// That share is raised to `sealed_segment_ceiling`, the largest a single
/// SEALED segment can be. Admission rejects a cap below one segment TOPIC-wide,
/// so an accepted cap can still divide into a share smaller than one segment,
/// and a share below what one sealed segment reaches retains no history at all:
/// the oldest sealed segment is over budget the moment it seals, every pass,
/// forever. The floor makes the cap mean "keep at least the newest sealed
/// segment", which is the least an enforceable retention policy can say. The
/// raise is disclosed by the caller, since the enforced value then diverges
/// from the one `GetTopic` echoes.
///
/// The ceiling is `segment_size` plus the CONFIGURED bus frame cap, not a
/// compile-time constant: rotation fires after the append that crosses the
/// target, so the crossing batch lands whole and a sealed segment runs up to
/// one maximum bus frame past `segment_size`. An operator who raises that knob
/// grows real sealed segments with it.
///
/// `ServerDefault` is resolved against the node default HERE, at enforcement
/// time. Create admission rewrites the sentinel before replication, but an
/// UPDATE to `ServerDefault` leaves it in committed state, and reading that as
/// "no cap" made an updated topic behave differently from an identically
/// configured created one. A node default of unlimited (the shipped config)
/// still yields `None`.
///
/// `ServerDefault` must never reach a sized branch: its `as_bytes_u64()` is 0,
/// which would trim every sealed segment.
fn per_partition_size_budget(
max_topic_size: MaxTopicSize,
default_max_topic_size: u64,
partition_count: usize,
sealed_segment_ceiling: u64,
) -> Option<PartitionSizeBudget> {
let resolved = match max_topic_size {
MaxTopicSize::ServerDefault => MaxTopicSize::from(default_max_topic_size),
sized => sized,
};
match resolved {
MaxTopicSize::Custom(size) => {
let divisor = u64::try_from(partition_count).unwrap_or(1).max(1);
let configured_share = size.as_bytes_u64() / divisor;
Some(PartitionSizeBudget {
max_bytes: configured_share.max(sealed_segment_ceiling),
configured_share,
resolved_cap: size.as_bytes_u64(),
})
}
// `From<u64>` maps 0 back to `ServerDefault`, so a node default of 0
// lands here as "no cap" rather than as a trim-everything budget.
MaxTopicSize::Unlimited | MaxTopicSize::ServerDefault => None,
}
}
#[cfg(test)]
mod tests {
use super::{PartitionSizeBudget, per_partition_size_budget};
use iggy_common::MaxTopicSize;
/// A ceiling low enough that the share is what the assertion is about.
const NO_FLOOR: u64 = 1;
fn budget(max_bytes: u64, configured_share: u64, resolved_cap: u64) -> PartitionSizeBudget {
PartitionSizeBudget {
max_bytes,
configured_share,
resolved_cap,
}
}
#[test]
fn server_default_resolves_to_the_node_default_at_enforcement_time() {
// A topic UPDATED back to `ServerDefault` keeps the sentinel in
// committed state; the cleaner must enforce the node default anyway, or
// it diverges from a topic CREATED with the same setting (whose
// sentinel admission already rewrote).
assert_eq!(
per_partition_size_budget(MaxTopicSize::ServerDefault, 4096, 4, NO_FLOOR),
Some(budget(1024, 1024, 4096)),
"the node default is resolved and split across partitions"
);
// Shipped config: server default is unlimited -> still no cap.
assert_eq!(
per_partition_size_budget(MaxTopicSize::ServerDefault, u64::MAX, 4, NO_FLOOR),
None
);
// A zero node default must read as "no cap", never as a zero budget
// that trims every sealed segment.
assert_eq!(
per_partition_size_budget(MaxTopicSize::ServerDefault, 0, 4, NO_FLOOR),
None
);
}
#[test]
fn explicit_sizes_ignore_the_node_default() {
assert_eq!(
per_partition_size_budget(MaxTopicSize::Custom(4096u64.into()), 64, 2, NO_FLOOR),
Some(budget(2048, 2048, 4096))
);
assert_eq!(
per_partition_size_budget(MaxTopicSize::Unlimited, 64, 2, NO_FLOOR),
None
);
// Zero partitions must not divide by zero.
assert_eq!(
per_partition_size_budget(MaxTopicSize::Custom(4096u64.into()), 64, 0, NO_FLOOR),
Some(budget(4096, 4096, 4096))
);
}
#[test]
fn a_share_below_one_sealed_segment_is_raised_to_it() {
// 8 MiB segments, 1 MiB bus cap: a sealed segment reaches 9 MiB.
let segment_size = 8 * 1024 * 1024;
let ceiling = segment_size + 1024 * 1024;
// The shape admission accepts at the floor: cap == one segment, one
// partition. The bare share (8 MiB) is under what a sealed segment
// reaches, so it would delete each segment as it seals.
assert_eq!(
per_partition_size_budget(MaxTopicSize::Custom(segment_size.into()), 0, 1, ceiling),
Some(budget(ceiling, segment_size, segment_size)),
"an at-floor cap enforces one sealed segment, not zero"
);
// The divisor reintroduces the same shape at any accepted cap: 16 x 8
// MiB passes admission, then splits into 8 MiB per partition.
assert_eq!(
per_partition_size_budget(
MaxTopicSize::Custom((segment_size * 16).into()),
0,
16,
ceiling
),
Some(budget(ceiling, segment_size, segment_size * 16))
);
// A cap with room to spare keeps its share untouched, so the floor
// never loosens a policy that was already enforceable.
let roomy = ceiling * 10;
assert_eq!(
per_partition_size_budget(MaxTopicSize::Custom(roomy.into()), 0, 1, ceiling),
Some(budget(roomy, roomy, roomy))
);
}
}