Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(appender): size-based rotation #2497

Open
wants to merge 6 commits into
base: master
Choose a base branch
from
Open

Conversation

CBenoit
Copy link
Contributor

@CBenoit CBenoit commented Mar 4, 2023

This patch adds size-based rotation to tracing-appender.

Closes #1940
Closes #858

cc @hawkw @IniterWorker @CfirTsabari

Motivation

In a constrained environment, we should be able to contain the logging size and period.

Solution

Maximum bytes per log file may be specified in the Rotation struct.
Written bytes are counted and when the specified maximum number of bytes is exceeded, log file is rolled over.

@CBenoit CBenoit requested a review from a team as a code owner March 4, 2023 02:28
@CBenoit CBenoit force-pushed the master branch 3 times, most recently from ca239d7 to 216ece8 Compare March 4, 2023 02:34
@CBenoit
Copy link
Contributor Author

CBenoit commented Mar 4, 2023

CI is failing with:

error: failed to parse manifest at `/home/runner/.cargo/registry/src/gh.neting.cc-1ecc6299db9ec823/opentelemetry-0.18.0/Cargo.toml`

Caused by:
  feature `edition2021` is required

  this Cargo does not support nightly features, but if you
  switch to nightly channel you can add
  `cargo-features = ["edition2021"]` to enable this feature

error: process didn't exit successfully: `/home/runner/.rustup/toolchains/1.53.0-x86_64-unknown-linux-gnu/bin/cargo hack check --feature-powerset --no-dev-deps` (exit status: 1)

I believe this is unrelated to the changes in this PR.

@CBenoit CBenoit changed the title feat(tracing-appender): size-based rotation feat(appender): size-based rotation Mar 4, 2023
@IniterWorker
Copy link

This patch adds size-based rotation as an option. I think it is good enough to have a truncated log between files. The CI problems don't come for this patch.

Thank you @CBenoit for this contribution,

pub struct RollingWriter<'a>(RwLockReadGuard<'a, File>);
pub struct RollingWriter<'a> {
inner: RwLockReadGuard<'a, File>,
current_size: Arc<AtomicU64>,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@CBenoit, thank you very much for the feature. RollingWriter borrows RollingFileAppender, looks like there might be current_size: &'a AtomicU64 instead of Arc. It allows also us to avoid Arc in Inner.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You’re probably right, I’ll try that!

}

/// Provides a daily and size-based rotation.
pub const fn daily_with_max_bytes(number_of_bytes: u64) -> Self {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi! I think <minutely|hourly>_with_max_bytes functions will be useful.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, I’ll add this.

@CBenoit
Copy link
Contributor Author

CBenoit commented Sep 5, 2023

I rebased on master and addressed the comments

Comment on lines 550 to 571
/// Provides a minutely and size-based rotation.
pub const fn minutely_with_max_bytes(number_of_bytes: u64) -> Self {
Self {
timed: Timed::Minutely,
max_bytes: Some(number_of_bytes),
}
}

/// Provides a hourly and size-based rotation.
pub const fn hourly_with_max_bytes(number_of_bytes: u64) -> Self {
Self {
timed: Timed::Hourly,
max_bytes: Some(number_of_bytes),
}
}

/// Provides a daily and size-based rotation.
pub const fn daily_with_max_bytes(number_of_bytes: u64) -> Self {
Self {
timed: Timed::Daily,
max_bytes: Some(number_of_bytes),
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

to reduce the total number of functions in the API, what do you think about just having a .with_max_bytes function taking an existing Rotation and adding a byte limit? so,

Suggested change
/// Provides a minutely and size-based rotation.
pub const fn minutely_with_max_bytes(number_of_bytes: u64) -> Self {
Self {
timed: Timed::Minutely,
max_bytes: Some(number_of_bytes),
}
}
/// Provides a hourly and size-based rotation.
pub const fn hourly_with_max_bytes(number_of_bytes: u64) -> Self {
Self {
timed: Timed::Hourly,
max_bytes: Some(number_of_bytes),
}
}
/// Provides a daily and size-based rotation.
pub const fn daily_with_max_bytes(number_of_bytes: u64) -> Self {
Self {
timed: Timed::Daily,
max_bytes: Some(number_of_bytes),
}
/// Adds a maximum size limit to an existing `Rotation`.
///
/// The new `Rotation` will rotate the log file when the log file reaches
/// the specified size limit, or when the rotation period elapses, whichever
/// occurs first.
pub const fn with_max_bytes(self, number_of_bytes: u64) -> Self {
Self {
max_bytes: Some(number_of_bytes),
..self
}
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This sounds good to me!

@CBenoit
Copy link
Contributor Author

CBenoit commented Sep 6, 2023

In my last commit, I addressed an issue I had with this patch: the overly specific date formatting was causing a new file to be created on start, always. So instead, I now insert an index into the file name allowing rotation when the max size is reached without conflicting with an already existing file whose name happened to be the same because of the formatting. Also, when the most recent file does not exceed the max size, we simply reuse it instead of creating yet another file.

Here is how it looks like when I run my program repeatedly the same day:
image
If there is a "max files" limit (10 in my case), older files are properly removed like so:
image
And on the next day, the index is back to 0:
image

Note that even when the timed rotation is set to "never", the date is still inserted using the daily date format anyway.
I did that to avoid the index from growing forever until we overflow the u64 (admittedly, I don’t think this will happen anytime soon).
In this case, the rotation will only happen when the maximum size is reached.

@CBenoit
Copy link
Contributor Author

CBenoit commented Sep 6, 2023

I addressed all comments made so far in 9efcaec and 3c86bd4. I did not click on "resolve conversion" myself so the commenter can easily double check.


#[derive(Debug)]
struct Inner {
log_directory: PathBuf,
log_filename_prefix: Option<String>,
log_filename_suffix: Option<String>,
log_filename_index: Option<AtomicU64>,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not entirely clear to me as to why log_filename_index and current_size are atomics nor am I entirely sure if they are correct. I would naively prefer to wrap these two fields in a single struct that is, in turn, wrapped in a RwLock, but I'll let Eliza comment on this.

In any case, an explanation as to why atomics are used for this would be really helpful.

Copy link
Contributor Author

@CBenoit CBenoit Sep 6, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is fair, wrapping these in a RwLock is definitely less error-prone.

Here I used atomics for the same reason next_date is atomic: RollingFileAppender is implementing MakeWriter, and we need to borrow fields using a shared reference to the RollingFileAppender. Ideally we don’t want to pay too high synchronization costs, and there is a RwLock<File> that is already relied upon when swapping the file.

current_size is used whenever data is written to the file in the Write implementation of either RollingFileAppender or RollingWriter<'a>, and fetch_add operations are performed, so it’s basically a counter.

    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
        let written = (&*self.inner).write(buf)?;
        self.current_size.fetch_add(written, Ordering::SeqCst);
        Ok(written)
    }

current_size is reset to 0 when refresh_writer is called:

    fn refresh_writer(&self, now: OffsetDateTime, file: &mut File) {
        let filename = self.join_date(&now);

        if let Some(max_files) = self.max_files {
            self.prune_old_logs(max_files);
        }

        match create_writer(&self.log_directory, &filename) {
            Ok(new_file) => {
                if let Err(err) = file.flush() {
                    eprintln!("Couldn't flush previous writer: {}", err);
                }
                *file = new_file;
                self.current_size.store(0, Ordering::Release);
            }
            Err(err) => eprintln!("Couldn't create writer for logs: {}", err),
        }
    }

This function requires an exclusive reference to the file, and we can either obtain this exclusive reference by calling write on &RwLock<File>, or get_mut or &mut RwLock<File>. We are guaranteed to not be writing in the file at the same time because of that.

log_filename_index is pretty similar to next_date, and is actually updated only when next_date was updated by the current thread, in advance_date_and_index (previously named advance_date):

    fn advance_date_and_index(&self, now: OffsetDateTime, current: usize) -> bool {
        let next_date = self
            .rotation
            .next_date(&now)
            .map(|date| date.unix_timestamp() as usize)
            .unwrap_or(0);
        let next_date_updated = self
            .next_date
            .compare_exchange(current, next_date, Ordering::AcqRel, Ordering::Acquire)
            .is_ok();

        match &self.log_filename_index {
            Some(index) if next_date_updated => {
                if current == next_date {
                    index.fetch_add(1, Ordering::SeqCst);
                } else {
                    index.store(0, Ordering::Release);
                }
            }
            _ => {}
        }

        next_date_updated
    }

All of this is put together in the make_writer implementation:

    fn make_writer(&'a self) -> Self::Writer {
        let now = self.now();

        // Should we try to roll over the log file?
        if let Some(current_time) = self.state.should_rollover(now) {
            // Did we get the right to lock the file? If not, another thread
            // did it and we can just make a writer.
            if self.state.advance_date_and_index(now, current_time) {
                self.state.refresh_writer(now, &mut self.writer.write());
            }
        }

        RollingWriter {
            inner: self.writer.read(),
            current_size: &self.state.current_size,
        }
    }

advance_date_and_index will return true if this thread was able to swap next_date with the new date, and false if another thread did it faster. The thread which was able to swap the value will take the exclusive write access using RwLock::write, and the other threads will wait for the file to be actually swapped at the RwLock::read call. If a thread is able to call RwLock::read faster, then it’s okay, we’ll simply write a few more lines in the old file before the rotation can happen.

In the io::Write implementation for RollingFileAppender, things are even more straightforward because we have an exclusive reference, and it must be the case that no other thread is accessing the atomics or the RwLock:

    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
        let now = self.now();
        let writer = self.writer.get_mut();
        if let Some(current_time) = self.state.should_rollover(now) {
            let did_cas = self.state.advance_date_and_index(now, current_time);
            debug_assert!(did_cas, "if we have &mut access to the appender, no other thread can have advanced the timestamp...");
            self.state.refresh_writer(now, writer);
        }

        let written = writer.write(buf)?;

        self.state.current_size.fetch_add(
            u64::try_from(written).expect("usize to u64 conversion"),
            Ordering::SeqCst,
        );

        Ok(written)
    }

If it wasn’t for the MakeWriter, no atomic and no RwLock would be required at all.

I hope it clarifies things. Do you want me to document that in the code directly?

@CBenoit
Copy link
Contributor Author

CBenoit commented Sep 6, 2023

I cleaned a few last things. I want to add additional tests before merging, but otherwise I’m waiting for further reviews.

@fridrik01
Copy link

This would be really nice to get merged, I was looking for this feature and was surprised I couldn't rotate logs based on size.

@cfilipescu
Copy link

Is there anything I can do to help get this pull request merged?

@CBenoit
Copy link
Contributor Author

CBenoit commented Feb 13, 2024

Is there anything I can do to help get this pull request merged?

To help me, you could add some additional tests (send a PR on my fork), but I don’t know if we can do much until one of the maintainers find enough time to review again.

@cfilipescu
Copy link

Is there anything I can do to help get this pull request merged?

To help me, you could add some additional tests (send a PR on my fork), but I don’t know if we can do much until one of the maintainer find enough time to review again.

I can help with tests. Any particular test cases that are missing, or should I just be creative?

@CBenoit
Copy link
Contributor Author

CBenoit commented Feb 13, 2024

I can help with tests. Any particular test cases that are missing, or should I just be creative?

Thank you!

There are two functions that I would like covered more thoroughly because they are critical for this feature:

  • filter_log_file
  • find_filename_index

Here are three test cases I would like to have:

  • Are we finding the proper filename index even when we have logs over several days (or hours if the rotation is hourly)? For example, on the next day, we should start from 0 again.
  • Are we properly ignoring files which are not using the proper formatting? For the daily rotation with a size_based_rotation prefix and log suffix, size_based_rotation.1970-01-01.3.log should be accounted for when finding the next index, but files such as size_based_rotation.1970-01-01-10.3.log and hello.txt should be ignored and do not cause any error.
  • Are we properly increasing the index even when the first file is deleted? (e.g.: size_based_rotation.1970-01-01.3.log exists but not size_based_rotation.1970-01-01.0.log, the next index should not be 0 but 4)

I already tested these properties in practice, and it appears to work as expected, but having a few automated tests would be much welcomed for future maintenance.

If you can think of more, it’s welcomed too.

This patch adds size-based rotation to tracing-appender.

In a constraint environment, we should be able to contain the logging
size and period.

Related issues:
- tokio-rs#1940
- tokio-rs#858
Follow the date format induced by the selected `Timed`. An index
is inserted into the file name so we can rotate when the max size is
reached. Also, when the most recent file does not exceed the max size,
we simply reuse it instead of creating yet another file.
@CBenoit
Copy link
Contributor Author

CBenoit commented Feb 13, 2024

I rebased on top of master.

@yonip23
Copy link

yonip23 commented May 1, 2024

This would be really nice to get merged, I was looking for this feature and was surprised I couldn't rotate logs based on size.

+1, i'm looking forward for this feature as well. Thank you for this @CBenoit and everyone involved!

@adrianbenavides
Copy link

adrianbenavides commented Jul 7, 2024

Hi @CBenoit , I opened this PR #3026 trying to do a similar thing, then I realized your PR was doing a better job and I closed mine to concentrate the attention on this one.

While I was reviewing your implementation I found an important difference about how we expect to rotate files by size. In your implementation, there is a certain degree of complexity around renaming files and keeping track of an index for same-frequency log files.

As a user, what I would expect is to read the {current-date}.log file as the current log file, and then {current-date}.{n}.log as the previous, archived, log files. This is the behavior I've seen in other systems like logrotate and also in some rust libraries like file_rotate. With this behavior, we could remove a big portion of the code of the filter_log_file and find_filename_index functions, avoiding a lot of system calls to read directories to figure out the next index + the files metadata. What do you think? I can help with this and also with the tests, just let me know and I'll open a PR towards your fork!

@CBenoit
Copy link
Contributor Author

CBenoit commented Jul 9, 2024

Hi @adrianbenavides!

As a user, what I would expect is to read the {current-date}.log file as the current log file, and then {current-date}.{n}.log as the previous, archived, log files. This is the behavior I've seen in other systems like logrotate and also in some rust libraries like file_rotate. With this behavior, we could remove a big portion of the code of the filter_log_file and find_filename_index functions, avoiding a lot of system calls to read directories to figure out the next index + the files metadata. What do you think?

Indeed, this sounds reasonable to me!

I can help with this and also with the tests, just let me know and I'll open a PR towards your fork!

That sounds great! I’ll be glad to merge your work :)

@adrianbenavides
Copy link

@CBenoit here's the PR with the proposed changes: CBenoit#1

@GlennOlsson
Copy link

Hi all! Is there an estimation when this might be included in the library and supported? I see the PR has been open for quite some time but that it's also very active. If there is any way I can help, lmk. Would hope to use this asap

Thanks

@CBenoit
Copy link
Contributor Author

CBenoit commented Jul 16, 2024

Hi all! Is there an estimation when this might be included in the library and supported? I see the PR has been open for quite some time but that it's also very active. If there is any way I can help, lmk. Would hope to use this asap

@GlennOlsson

As a simple contributor, I can’t really give you any ETA. However, I can tell you that I haven’t received any updates from the tokio/tracing maintainers since last year, so I don’t think it’s a priority for the project at the moment. Also, it is very likely that the maintainers already have their plates full with other projects, as I haven’t seen many PRs merged recently.

As for the help, currently @adrianbenavides is simplifying the code and adding the remaining tests I wanted. That’s pretty much all I can see at the moment.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Allow tracing-appender sized log and rotation RotatingFileAppender