Skip to content

Log file shows zero entries (no re-scan) when index cache is evicted but file metadata survives #523

Description

@arukompas

Summary

When the log index cache (chunks + index metadata) is evicted or expires while the separate file-metadata cache survives, opening the affected log file shows zero log entries and never re-scans. The file appears empty in the UI even though it has content on disk. This is most visible for older log files that are browsed but not re-scanned for a while (e.g. past daily logs).

Affected version

opcodesio/log-viewer v3.18.0. Reproducible on any cache driver; surfaces in practice when a cache store evicts entries under memory pressure (e.g. Redis maxmemory LRU).

Root cause

LogViewer stores several independent cache keys per file (all under lv:<version>:...):

  • file metadata — GenerateCacheKey::for($file, 'metadata') — holds per-query last_scanned_file_position (written by LogFile::saveMetadata() / addRelatedIndex())
  • index metadata — GenerateCacheKey::for($index, 'metadata') — chunk definitions
  • index chunks — GenerateCacheKey::for($index, "chunk:N") — the actual log positions

The "do I need to scan this file?" decision reads last_scanned_file_position from the file-metadata key:

// src/Readers/IndexedLogReader.php
public function numberOfNewBytes(): int
{
    $lastScannedFilePosition = $this->file->getLastScannedFilePositionForQuery($this->query);

    if (is_null($lastScannedFilePosition)) {
        $lastScannedFilePosition = $this->index()->getLastScannedFilePosition();
    }

    return $this->file->size() - $lastScannedFilePosition;
}

public function requiresScan(): bool
{
    // ...
    return $this->numberOfNewBytes() !== 0;
}

These keys can diverge:

  • Both use a 1-week TTL (CanCacheIndex::cacheTtl() / CanCacheData::cacheTtl()), refreshed only on an actual scan. A file that is browsed but not re-scanned keeps the same anchored expiry.
  • Under Redis maxmemory LRU, the large index chunk blobs of rarely-opened files are evicted first, while the small file-metadata key stays hot (the file-list view reads it on every visit).

Once the file metadata survives but the index is gone:

  1. getLastScannedFilePositionForQuery() returns the full file size → numberOfNewBytes() is 0requiresScan() returns falseno re-scan (LogsController::index() calls $logQuery->scan(), which is a no-op when requiresScan() is false).
  2. The reader iterates an empty index (LogIndex::getChunkDefinitions() is empty, getChunkData() returns null) → zero logs, silently. There is no integrity check between index metadata and chunk data in LogIndex / CanIterateIndex.
  3. It never self-heals, because the stale file metadata keeps reporting the file as fully scanned.

Reproduction

Single-process caveat: KeepsInstances::$_instances caches reader instances, so the in-memory index masks the bug within one process. Reproduce across separate "requests" by clearing instances:

use Illuminate\Support\Facades\Cache;
use Opcodes\LogViewer\LogFile;
use Opcodes\LogViewer\Readers\IndexedLogReader;
use Opcodes\LogViewer\Utils\GenerateCacheKey;

config(['cache.default' => 'array', 'log-viewer.cache_driver' => 'array']);

$path = sys_get_temp_dir().'/laravel-test.log';
file_put_contents($path, str_repeat("[2026-05-20 10:00:00] production.ERROR: boom\n", 5));

$read = function () use ($path) {
    IndexedLogReader::clearInstances();          // simulate a fresh HTTP request
    $file = new LogFile($path);
    $reader = $file->logs();
    $reader->search('');
    $reader->scan();                             // no-op when requiresScan() is false
    return count($reader->reset()->get());
};

echo $read().PHP_EOL;                            // 5  (cold cache, scans)

// Evict ONLY the index keys, keep file metadata (mirrors Redis eviction):
$file  = new LogFile($path);
$index = $file->index('');
Cache::forget(GenerateCacheKey::for($index, 'metadata'));
Cache::forget(GenerateCacheKey::for($index, 'chunk:0'));

echo $read().PHP_EOL;                            // 0  <-- BUG: empty, and requiresScan() stays false

Expected: 5 then 5. Actual: 5 then 0.

Suggested fix

Add a cache-consistency guard at the top of IndexedLogReader::requiresScan(): if the file has content but the index reports nothing scanned (position 0 and zero entries), the index was lost — force a re-scan.

public function requiresScan(): bool
{
    // File metadata and the log index live under separate cache keys and can
    // diverge (independent Redis eviction / expiry). If the file has content
    // but the index reports nothing scanned, the index was lost — force a
    // re-scan instead of serving an empty result.
    if ($this->file->size() > 0
        && $this->index()->getLastScannedFilePosition() === 0
        && $this->index()->count() === 0) {
        return true;
    }

    if (isset($this->mtimeBeforeScan) && ($this->file->mtime() > $this->mtimeBeforeScan || $this->file->mtime() === time())) {
        return $this->numberOfNewBytes() >= LogViewer::lazyScanChunkSize();
    }

    return $this->numberOfNewBytes() !== 0;
}

I verified this makes the reproduction return 5 / 5. It does not misfire on empty files (size() > 0 guard) or files with no matching entries (after a scan the index position sits at EOF, so it is non-zero).

Happy to open a PR with this guard plus a regression test if you'd like.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions