skip to content

In PHP, many concurrent requests append records to one audit log file; why can records interleave or tear, and how do you make each append safe?

level: seniorimportance: should knowfreq 40%

answer

  1. one record, one write call
  2. FILE_APPEND | LOCK_EX
  3. 'a' mode writes at the end
  4. every writer must lock, flock() is advisory
  5. fsync() for durability, since 8.1

basics

~20 s

Records tear when one record is written in several fwrite() calls, when writers seek to the end themselves, or when some writers skip the lock. Build the whole record first, then file_put_contents($log, $record, FILE_APPEND | LOCK_EX), or fopen() 'a', flock() LOCK_EX, one fwrite(), release.

solid answer

~50 s

Each PHP request is its own process or thread, so nothing in PHP serialises writes to one file. Records interleave when a record is written in pieces — a header `fwrite()`, then a body `fwrite()` — and another request's write lands in between. They are lost or overwritten when a writer opens with `'w'` (truncating) or with `'r+'` plus `fseek($h, 0, SEEK_END)`, because the end it computed is stale by the time it writes. The fix: format the whole record, newline included, into one string, then append it once with `file_put_contents($log, $record, FILE_APPEND | LOCK_EX)`, which opens in append mode, takes an exclusive `flock()`, writes and closes. A long-lived worker can keep one `'a'` handle and wrap each `fwrite()` in `LOCK_EX` / `LOCK_UN`. The lock is advisory, so every writer must use it. As of PHP 8.5, `fsync()` (added in 8.1) forces the bytes to disk when losing the last records in a crash is unacceptable.

go deeper

for a junior

Recall FILE_APPEND | LOCK_EX for file_put_contents(), and that mode 'a' always writes at the end of the file.

for a middle

Explain why a record split across fwrite() calls can interleave, why seeking to the end yourself races, and why flock() only binds writers that use it.

for a senior

Show you build each record in memory, append it in one locked write, check for short writes, release the lock in finally, and choose an fsync policy deliberately.

for a principal

Weigh a shared local file against a database table or a log pipeline once write rates, multiple hosts or retention rules make one locked file a bottleneck.

## The setup An audit log is a single file that many requests append to: one line per event, never rewritten. Under PHP-FPM or any other multi-process server, each request runs in its own worker, and the workers share **no** memory. Nothing in PHP coordinates their writes to the same file, so the file system and your code have to. ## How records get damaged | Symptom | Cause | |---|---| | Two records mixed on one line | one record written with several `fwrite()` calls; another worker's write landed between them | | Records overwritten | writer opened with `'r+'` or `'c'` and did `fseek($h, 0, SEEK_END)` itself; another worker extended the file between the seek and the write | | Log suddenly empty or shortened | a writer opened with `'w'`, which truncates at open time | | Partial last record after a crash | the process died mid-write, or the data was still in the operating system's cache | | Occasional tears despite "locking" | one code path or a second tool writes without calling `flock()` | Two facts sit underneath: - **Mode `'a'` puts each write at the current end of the file.** The manual states that in `'a'` mode writes are always appended and `fseek()` has no effect. On a local file system the kernel positions each append itself, which removes the stale-end race. It does not stitch together a record you split across several calls, and network file systems may not honour it the same way. - **`flock()` is advisory.** An exclusive lock only excludes other writers that also ask for one. ## The safe append, step by step 1. **Build the whole record first**, newline included: `$record = json_encode($event, JSON_THROW_ON_ERROR) . "\n";` 2. **Append it in one call under an exclusive lock**: `file_put_contents($log, $record, FILE_APPEND | LOCK_EX)`. In php-src this opens the file in append mode, takes `LOCK_EX`, writes, and closes the stream, which releases the lock. 3. **Check the result**: it returns the number of bytes written or `false`; a `false` should become an exception or a fallback, because an audit gap is itself an incident. For a long-running CLI worker that writes thousands of events, reopening the file per event wastes system calls. Keep one handle: ```php <?php declare(strict_types=1); final class AuditLog { /** @var resource */ private $h; public function __construct(string $path) { $h = fopen($path, 'a'); if ($h === false) { throw new RuntimeException("cannot open {$path}"); } $this->h = $h; } public function append(array $event): void { $record = json_encode($event, JSON_THROW_ON_ERROR) . "\n"; if (!flock($this->h, LOCK_EX)) { throw new RuntimeException('cannot lock audit log'); } try { if (fwrite($this->h, $record) !== strlen($record)) { throw new RuntimeException('short write to audit log'); } fflush($this->h); } finally { flock($this->h, LOCK_UN); } } } ``` The `finally` guarantees the lock is released even when the write fails, and the length check catches a short write such as a full disk. ## Durability: written is not yet on disk A successful `fwrite()` means the operating system accepted the bytes, not that they reached the disk. For an audit trail where a power cut must not drop the last records: - `fflush($h)` pushes anything PHP's stream layer still holds to the operating system. - `fsync($h)`, available since PHP 8.1, asks the operating system to write the file's data and metadata to storage; `fdatasync($h)` does the same for the data only. - Both cost a disk round-trip per call, so batch: fsync every N records or every few hundred milliseconds when the audit requirement allows it. ## Trade-offs and alternatives - **Lock contention**: every writer queues on the same lock. With short records the hold time is tiny, but under very high write rates the file becomes a serialisation point. - **Rotation**: rotating a file that a worker holds open with `'a'` leaves the worker writing to the renamed file until it reopens its path. - **Other sinks**: writing structured records to a database table or to the process's standard error and letting the platform collect them removes the shared-file problem entirely; the file approach is right when a local, append-only artefact is the requirement.

  • Why is fseek($h, 0, SEEK_END) followed by fwrite() on an 'r+' handle unsafe for a shared log?
    The end offset is read at `fseek()` time. If another worker appends between that seek and your `fwrite()`, you write at the old end and overwrite its record. Mode `'a'` avoids this because each write goes to the current end at write time, and `LOCK_EX` around the write excludes other cooperating writers entirely.
  • Does FILE_APPEND without LOCK_EX already make appends safe?
    Partly. Append mode makes each write land at the current end, so records are not overwritten, and a short record written in one call is usually intact on a local file system. It gives no guarantee for large records, split writes or network file systems, and it does not coordinate with readers or other code that takes the lock. Adding `LOCK_EX` makes the rule explicit and cheap.
  • What does fsync() add over fflush() in PHP 8.5?
    `fflush()` hands buffered bytes to the operating system; they may still sit in its page cache and vanish in a power loss. `fsync()`, added in PHP 8.1, asks the operating system to commit the file's data and metadata to storage before returning, and `fdatasync()` commits the data only. Both are slow, so audit writers usually fsync per batch rather than per record.

An audit log under LOCK_EX is a guest book with one pen on a chain: whoever holds the pen writes a full entry, then hands it on. Without the pen, two guests writing at once can mix their sentences on the same line.

saying these in an interview costs you the question

  • Each PHP request runs one after another, so appends cannot collide.
  • Writing a record with several fwrite() calls is safe inside mode 'a'.
  • Opening with 'r+' and seeking to the end is as safe as mode 'a'.
  • flock() also blocks writers that never call flock().
  • fwrite() returning success means the record is safely on disk.