Log rotation in PHP without cron: the first visitor of the month does it

Table of contents (7 sections)

My site measures its visits with a small PHP script, the tracker. On every page view, it appends a line to a text file, visits.tsv (the details are in the article on cookie-free statistics). The site lives on OVH shared hosting: a server shared with other sites. I upload my files over FTP, with a quota of about 100 MB.

On November 9, a deployment stopped on 553 Disk quota exceeded, the FTP server's answer when the quota is full. The visits file weighed 23.7 MB. Worse: while the quota was full, the tracker stopped writing anything, without showing the slightest error. A few hours of visits went nowhere.

The remedy is called log rotation. At the end of the month, you put the file away in a compressed archive (gzip). Then you start again with an empty file. Usually, a scheduled task (a cron job) does this. My hosting offer has none. So the first visitor of each month will do the work, on one condition: they must not notice.

A quota that gives no warning

First I wanted to know how much room was left. The FTP server answers no quota command, so I added up the sizes with du from lftp. To see whether the limit really blocked anything, I uploaded a dummy 100 MB file (deleted afterwards). It went through. I concluded, a bit too fast, that the quota was much bigger.

I was wrong. The next upload, the 6.5 MB MP3 of a clip I wanted to put online, got a 553. My explanation, which remains a deduction: the server only checks the quota when a file is opened. As long as you are under the limit at that moment, a huge file goes through, even if it overshoots. The next file is refused.

The folder ended at 106 MB, above the limit: going over blocks nothing at the time. It held the 23.7 MB visits file (every month since February), PDFs, images and audio. The tracker appends its line with file_put_contents and does not check its return value: the write failed and nobody knew. After the cleanup (visits compressed, the book PDFs moved to GitHub, MP3 re-encoded, development files removed), the folder dropped under 80 MB. It will grow again every month with the visits. Hence the rotation: put the past month away in a compressed archive.

No cron: the first visitor of the month does it

A cron job is a task the server launches at a fixed time, with no visit needed. The OVHcloud documentation describes these scheduled tasks, but my offer does not include them.

Yet a visit is already a PHP script running. It only has to ask itself: "was the last line written in the previous month?". The answer is in the last modification date of visits.tsv (filemtime). In the code, $live is that visits.tsv and $pending is visits-pending.tsv, the file "waiting" for compression. The excerpts are shortened: the lock, described below, is not shown.

$live_is_old_month = is_file($live)
    && date('Y-m', filemtime($live)) < date('Y-m');
if (!$live_is_old_month && !is_file($pending)) {
    return;   // nothing to do on almost every hit
}

// Atomic: the visitor's own hit then lands in a fresh visits.tsv
if ($live_is_old_month && !is_file($pending)) {
    rename($live, $pending);
}

The file of the past month changes name in a single move. The visitor's line recreates a fresh visits.tsv. What remains is compressing visits-pending.tsv into visits-YYYY-MM.tsv.gz. The is_file($pending) condition covers another case: a failed compression. The pending file then still exists, and a later visit resumes the work (details below).

On the statistics side, nothing breaks. The tool reads the compressed archives first, in the order of their names, then the pending file, then the current file.

The first visitor must not pay

Compressing a month of visits is not instant. There are three places to run the gzip:

  • in the request, before sending the page;
  • in a shutdown function, registered with register_shutdown_function: PHP runs it when the script is finished;
  • in that same function, but after fastcgi_finish_request, which asks the server to send the page right away.

I measured these three cases, plus the absence of rotation, on the OVH server. The data is October's: 60,828 lines, or 4.7 MB. The test script was disposable: deployed, used, then deleted. Each row of the table is the median of 15 requests, meaning the middle time once the 15 are sorted. They were all run on the same day, on a single server.

The time includes the network: a few milliseconds of difference cannot be told apart from noise. My test measures the gzip alone, not the full rotation function, which also rereads the archive to verify it. The differences are computed from the rounded medians.

VariantFull page receivedOverhead
No rotation (baseline)155 ms 
Gzip in the request216 ms+61 ms
register_shutdown_function alone205 ms+50 ms
With fastcgi_finish_request158 ms+3 ms

The surprise is the middle row. register_shutdown_function runs a function when the script is finished. You would think the visitor is served by then. But under php-fpm, the connection stays open until that function returns. php-fpm is the manager that launches the PHP processes behind the web server. The shutdown function gains only 11 ms out of 61, a gap that the noise in my measurements is enough to explain: in other words, nothing.

What cuts the link is fastcgi_finish_request(). It sends the response, closes the connection with the visitor, and lets the script carry on. My hosting runs PHP 8.2.35 on php-fpm, where it exists.

Timeline of a request: without the trick, the visitor waits for the page then the gzip, 216 ms in all; with fastcgi_finish_request, they receive the page at 158 ms and the gzip carries on without them Gzip in the request With finish_request page 155 ms gzip visitor served at 216 ms page 158 ms background gzip visitor served at 158 ms
The green bars are to scale. In the first row, the "page" part reuses the measurement without rotation and the gzip is added to it. Behind the scenes, the width of the dashed box means nothing: nobody waits for the gzip.

So the solution keeps the shutdown function and calls fastcgi_finish_request() inside it: the response goes out first, the gzip comes after. This is not true async, where the work would run in another process. The PHP process that serves the page, the worker, stays busy until the gzip ends. A server has a limited number of workers, but this one works once a month. Here is the shutdown function, shortened. $lock is the lock described below. ignore_user_abort stops PHP from quitting if the visitor closes the page, and set_time_limit(120) gives it two minutes.

register_shutdown_function(function () use ($lock, $lock_file, $logs_dir) {
    ignore_user_abort(true);   // the visitor is gone, keep going
    set_time_limit(120);
    fastcgi_finish_request();  // response sent, connection closed
    if (!visit_log_archive_pending($logs_dir)) {
        touch($lock_file);     // failed: stamp the time, retry later
    }
    flock($lock, LOCK_UN);
});

What can go wrong

A job started by a random visitor must assume everything breaks: the visitor leaves, two visitors arrive together, the disk is full. Each case has its defense.

  • The script is interrupted in the middle of the gzip (server restart, time limit exceeded). The rename has already happened, so nothing is lost: visits-pending.tsv stays readable by the statistics tool. The gzip is first written to a temporary file, visits-archive.tmp, renamed to the final archive only once the archive is verified. A later visit resumes the work and overwrites a temporary file left half-written.
  • Two visitors at the same time. A flock lock is placed on a small file named .rotate.lock, and only one process at a time can hold it. It is non-blocking (LOCK_NB): the second visitor leaves right away, without waiting.
  • The gzip fails, for example for lack of space. Before deleting the pending file, the code rereads the archive and compares the number of lines. A failure leaves everything in place. That same .rotate.lock then serves as a clock: the code updates its date (touch) to mark the failure. Nobody retries for an hour, instead of one attempt per visit.

The gzip runs in the shutdown function, which only executes at the end of a PHP script. So a test cannot start the rotation and then read the result in the same script. It runs each rotation in a small separate PHP script and waits for that script to end. The 18 checks cover the current month, a normal rotation and a site left two months without visits. They also cover a lock already taken and a quota failure. That failure is simulated by creating a directory named visits-archive.tmp: the gzip can no longer create its file.

My take: where it stops

My take: on shared hosting without cron, the first visitor is a good solution under three conditions. The work stays short, it can be replayed without damage, and it can fail without destroying anything. A 4.7 MB gzip meets all three. A database export of several minutes does not.

I did not measure the case of a very busy site. A worker kept busy behind the scenes is one fewer worker for incoming requests in the meantime. My site is small and the rotation only runs once a month, so I looked for no effect. On a big site, you would have to check before copying the method.

Conclusion

It all started with a silent failure: the tracker was writing into the void and nobody knew. The rotation is built the other way around. It renames before compressing and verifies before deleting. When it fails, it leaves a trace. A visits-pending.tsv or a visits-archive.tmp still lying around on the server hours later signals a failed rotation.

Still, it has never run on my real visits files. The tests are green, the measurement is done, but the only real trial is the first visitor on December 1, 2026. I will take a look at the folder that day.

Sources

Documentation consulted on November 9, 2026:

Comments (0)