///|
/// One entry of a log directory, as the host adapter found it. The adapter
/// gives the entries; this package does no I/O.
pub(all) struct ListedFile {
/// The entry name, with no directory part.
name : String
/// The size. Only the size of a regular file has an effect.
size : ByteCount
/// True for a regular file. False for a directory, a symbolic link and each
/// other kind of entry: the plan never appends to them and retention never
/// deletes them.
regular : Bool
} derive(Eq, Debug)
///|
pub extend ListedFile with Eq::{equal, not_equal}
///|
pub extend ListedFile with @debug.Debug::{to_repr}
///|
/// The file action for the next record. The adapter does the action, and then
/// tells the state the result with `opened` and `wrote`.
pub(all) enum FilePlan {
/// Write the record to the open active file.
Append(LogFileName)
/// No file is open: open this file to append, and then write the record. At a
/// restart this can be an existing file with `existing_size` bytes; the
/// adapter never truncates it.
Open(LogFileName, existing_size~ : ByteCount)
/// Close the active file, open this file to append, and then write the
/// record. After the open, run retention. When the period changed, this can
/// be an existing file of the new period with `existing_size` bytes.
Roll(LogFileName, existing_size~ : ByteCount)
} derive(Eq, Debug)
///|
pub extend FilePlan with Eq::{equal, not_equal}
///|
pub extend FilePlan with @debug.Debug::{to_repr}
///|
/// The rolling state of one writer of a file set: the active file and its size.
/// The value is immutable: `opened` and `wrote` give a new state. The adapter
/// calls them only after its I/O succeeds, so a failed action does not move the
/// state.
pub struct RollingState {
priv set : FileSet
priv active : LogFileName?
priv size : ByteCount
} derive(Eq, Debug)
///|
pub extend RollingState with Eq::{equal, not_equal}
///|
pub extend RollingState with @debug.Debug::{to_repr}
///|
/// The state of a writer of `set` that has no open file.
pub fn RollingState::new(set : FileSet) -> RollingState {
{ set, active: None, size: ByteCount::zero() }
}
///|
/// The file set of the writer.
pub fn RollingState::file_set(self : RollingState) -> FileSet {
self.set
}
///|
/// The open active file, or `None` before the first open.
pub fn RollingState::active(self : RollingState) -> LogFileName? {
self.active
}
///|
/// The bytes in the active file: its size at the open and each write after it.
pub fn RollingState::size(self : RollingState) -> ByteCount {
self.size
}
///|
/// True when a file with `size` bytes can take a record of `record_bytes`
/// bytes: the file is empty, the policy has no size limit, or the record fits in
/// the limit. An exact fit is allowed.
fn FileSet::has_room(
self : FileSet,
size : ByteCount,
record_bytes : ByteCount,
) -> Bool {
match self.limit() {
None => true
Some(limit) =>
size.bytes == 0L || record_bytes.bytes <= limit.to_int64() - size.bytes
}
}
///|
/// True when the next plan at `now` reads the directory listing: no file is
/// open yet, or a daily policy moves to a later UTC day. Read the clock once and
/// give the same `now` to `plan`.
pub fn RollingState::needs_listing(
self : RollingState,
now : @formatting.LogTimestamp,
) -> Bool {
match self.active {
None => true
Some(active) => active.epoch_day() is Some(day) && now.epoch_day() > day
}
}
///|
/// The file action for a record of `record_bytes` bytes (the UTF-8 bytes of the
/// rendered record, including its LF) at the writer time `now`.
///
/// - With no open file, the plan reads `listing`, the entries of the log
/// directory. It finds the newest owned file of the current period (the UTC
/// day of `now` for a daily policy, all owned files for `Size`). When that is
/// a regular file with room for the record, the plan opens it to append. When
/// it is not, the plan opens the next sequence number. With no owned file, it
/// opens the first name of the period.
/// - With an open file and a later UTC day, a daily policy rolls. It reads
/// `listing` for the files of the new day, as at a restart, so a file that an
/// earlier run left for that day is appended to when it has room, else the
/// next sequence number opens. A clock that moves back does not roll: the
/// writer stays in the day of the active file.
/// - Otherwise the plan does not read `listing`; `needs_listing` tells when it
/// does.
/// - A size limit rolls a nonempty file when the record does not fit. An empty
/// file takes a record larger than the limit, and the next record rolls. The
/// plan never splits a record.
///
/// Raises `SequenceExhausted` when the period has no next name.
pub fn RollingState::plan(
self : RollingState,
now : @formatting.LogTimestamp,
record_bytes : ByteCount,
listing : Array[ListedFile],
) -> FilePlan raise RollingError {
let set = self.set
match self.active {
None => {
let (name, existing_size) = set.period_plan(now, record_bytes, listing)
Open(name, existing_size~)
}
Some(active) =>
if active.epoch_day() is Some(day) && now.epoch_day() > day {
let (name, existing_size) = set.period_plan(now, record_bytes, listing)
Roll(name, existing_size~)
} else if set.has_room(self.size, record_bytes) {
Append(active)
} else {
Roll(set.next_name(active), existing_size=ByteCount::zero())
}
}
}
///|
/// The file to open for the period of `now`, from the directory entries, and
/// its size: the newest owned file of the period when it is regular and has
/// room, else the next name, else the first name of the period.
fn FileSet::period_plan(
self : FileSet,
now : @formatting.LogTimestamp,
record_bytes : ByteCount,
listing : Array[ListedFile],
) -> (LogFileName, ByteCount) raise RollingError {
let today = now.epoch_day()
let mut newest : (LogFileName, ListedFile)? = None
for file in listing {
guard self.parse_name(file.name) is Some(name) else { continue }
guard !self.is_daily() || name.epoch_day() == Some(today) else { continue }
match newest {
Some((known, _)) if known >= name => ()
_ => newest = Some((name, file))
}
}
match newest {
None => (self.first_name(now), ByteCount::zero())
Some((name, file)) =>
if file.regular && self.has_room(file.size, record_bytes) {
(name, file.size)
} else {
(self.next_name(name), ByteCount::zero())
}
}
}
///|
/// The state after the adapter opened `name` to append, with `size` bytes in it
/// after the open.
pub fn RollingState::opened(
self : RollingState,
name : LogFileName,
size : ByteCount,
) -> RollingState {
{ ..self, active: Some(name), size }
}
///|
/// The state after the adapter wrote `bytes` bytes to the active file.
pub fn RollingState::wrote(
self : RollingState,
bytes : ByteCount,
) -> RollingState {
{ ..self, size: { bytes: self.size.bytes + bytes.bytes } }
}
///|
/// The owned files to delete under `retention`, after `active` opened.
/// Only regular entries of `listing` whose names have the complete owned form
/// count, so directories, links, the marker and other names stay. The active
/// file is never in the result. `KeepNewest(n)` keeps the active file and the
/// newest `n - 1` other owned files, by UTC day and then by sequence number, not
/// by modification time. The result is oldest first. `KeepAll` deletes nothing.
pub fn FileSet::retention(
self : FileSet,
retention : Retention,
listing : Array[ListedFile],
active : LogFileName,
) -> Array[LogFileName] {
guard retention is KeepNewest(count) else { return [] }
let owned : Array[LogFileName] = []
for file in listing {
guard file.regular else { continue }
guard self.parse_name(file.name) is Some(name) else { continue }
guard name != active && !owned.contains(name) else { continue }
owned.push(name)
}
owned.sort()
let keep = count.to_int() - 1
let delete = if owned.length() > keep { owned.length() - keep } else { 0 }
owned[0:delete].to_owned()
}