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:
getLastScannedFilePositionForQuery() returns the full file size → numberOfNewBytes() is 0 → requiresScan() returns false → no re-scan (LogsController::index() calls $logQuery->scan(), which is a no-op when requiresScan() is false).
- 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.
- 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.
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-viewerv3.18.0. Reproducible on any cache driver; surfaces in practice when a cache store evicts entries under memory pressure (e.g. RedismaxmemoryLRU).Root cause
LogViewer stores several independent cache keys per file (all under
lv:<version>:...):GenerateCacheKey::for($file, 'metadata')— holds per-querylast_scanned_file_position(written byLogFile::saveMetadata()/addRelatedIndex())GenerateCacheKey::for($index, 'metadata')— chunk definitionsGenerateCacheKey::for($index, "chunk:N")— the actual log positionsThe "do I need to scan this file?" decision reads
last_scanned_file_positionfrom the file-metadata key:These keys can diverge:
CanCacheIndex::cacheTtl()/CanCacheData::cacheTtl()), refreshed only on an actual scan. A file that is browsed but not re-scanned keeps the same anchored expiry.maxmemoryLRU, 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:
getLastScannedFilePositionForQuery()returns the full file size →numberOfNewBytes()is0→requiresScan()returnsfalse→ no re-scan (LogsController::index()calls$logQuery->scan(), which is a no-op whenrequiresScan()is false).LogIndex::getChunkDefinitions()is empty,getChunkData()returnsnull) → zero logs, silently. There is no integrity check between index metadata and chunk data inLogIndex/CanIterateIndex.Reproduction
Single-process caveat:
KeepsInstances::$_instancescaches reader instances, so the in-memory index masks the bug within one process. Reproduce across separate "requests" by clearing instances:Expected:
5then5. Actual:5then0.Suggested fix
Add a cache-consistency guard at the top of
IndexedLogReader::requiresScan(): if the file has content but the index reports nothing scanned (position0and zero entries), the index was lost — force a re-scan.I verified this makes the reproduction return
5/5. It does not misfire on empty files (size() > 0guard) 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.