Skip to content

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

@arukompas

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
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions