Skip to content

Commit 2e23e9c

Browse files
committed
fix(preview): regenerate previews whose stored file is gone
When a preview row exists in the database but the backing file is missing, preview:generate failed with NotFoundException from LocalPreviewStorage::readPreview. Drop the stale row and generate a new preview instead so occ preview:generate and preview:generate-all can repair as they run. Fixes: #63349 Assisted-by: Grok:grok-4.6 Signed-off-by: Ray Vincent <rayhvincent@gmail.com>
1 parent 2130f55 commit 2e23e9c

8 files changed

Lines changed: 495 additions & 3 deletions

File tree

lib/private/Preview/Generator.php

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,11 @@ public function generatePreviews(File $file, array $specifications, ?string $mim
171171
&& $preview->getHeight() === $height && $preview->getMimetype() === $maxPreview->getMimetype()
172172
&& $preview->getVersion() === $previewVersion && $preview->isCropped() === $crop);
173173

174+
if ($preview !== null && !$this->storageFactory->previewExists($preview)) {
175+
$this->dropStalePreview($previews, $preview, $file);
176+
$preview = null;
177+
}
178+
174179
if ($preview) {
175180
$previewFile = new PreviewFile($preview, $this->storageFactory, $this->previewMapper);
176181
} else {
@@ -316,13 +321,23 @@ public function getNumConcurrentPreviews(string $type): int {
316321
* @param Preview[] $previews
317322
* @throws NotFoundException
318323
*/
319-
private function getMaxPreview(array $previews, File $file, string $mimeType, ?string $version): Preview {
324+
private function getMaxPreview(array &$previews, File $file, string $mimeType, ?string $version): Preview {
320325
// We don't know the max preview size, so we can't use getCachedPreview.
321326
// It might have been generated with a higher resolution than the current value.
322327
foreach ($previews as $preview) {
323-
if ($preview->isMax() && ($version === $preview->getVersion())) {
328+
if (!$preview->isMax() || $version !== $preview->getVersion()) {
329+
continue;
330+
}
331+
332+
if ($this->storageFactory->previewExists($preview)) {
324333
return $preview;
325334
}
335+
336+
// The row outlived its file. Everything below assumes the max preview
337+
// can be read, so drop the row and generate a new one instead of
338+
// failing on this and every later request. It has to go from the
339+
// caller's list too, which is still searched for cached previews.
340+
$this->dropStalePreview($previews, $preview, $file);
326341
}
327342

328343
$maxWidth = $this->config->getSystemValueInt('preview_max_x', 4096);
@@ -335,7 +350,7 @@ private function getMaxPreview(array $previews, File $file, string $mimeType, ?s
335350
// Fetch again, likely two HTTP requests for the same file were done around the same time
336351
[$file->getId() => $previews] = $this->previewMapper->getAvailablePreviews([$file->getId()]);
337352
foreach ($previews as $preview) {
338-
if ($preview->isMax() && ($version === $preview->getVersion())) {
353+
if ($preview->isMax() && ($version === $preview->getVersion()) && $this->storageFactory->previewExists($preview)) {
339354
return $preview;
340355
}
341356
}
@@ -344,6 +359,18 @@ private function getMaxPreview(array $previews, File $file, string $mimeType, ?s
344359
}
345360
}
346361

362+
/**
363+
* @param Preview[] $previews
364+
*/
365+
private function dropStalePreview(array &$previews, Preview $preview, File $file): void {
366+
$this->logger->warning('Preview of file {path} named {name} is missing from storage, regenerating it.', [
367+
'path' => $file->getPath(),
368+
'name' => $preview->getName(),
369+
]);
370+
$this->previewMapper->delete($preview);
371+
$previews = array_filter($previews, fn (Preview $candidate): bool => $candidate !== $preview);
372+
}
373+
347374
/**
348375
* @throws DBException
349376
* @throws NotFoundException

lib/private/Preview/Storage/IPreviewStorage.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,15 @@ public function deletePreview(Preview $preview): void;
4848
*/
4949
public function deleteUnreferencedPreview(Preview $preview): void;
5050

51+
/**
52+
* Whether the stored data of a preview is still present.
53+
*
54+
* A row can outlive its file, for instance after a partial write or when
55+
* the data directory is restored from an older backup. Every consumer of a
56+
* preview assumes it can be read.
57+
*/
58+
public function previewExists(Preview $preview): bool;
59+
5160
/**
5261
* Migration helper
5362
*

lib/private/Preview/Storage/LocalPreviewStorage.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,11 @@ public function deleteUnreferencedPreview(Preview $preview): void {
7676
// race. Deleting it would leave that one with a row but no file.
7777
}
7878

79+
#[Override]
80+
public function previewExists(Preview $preview): bool {
81+
return is_file($this->constructPath($preview));
82+
}
83+
7984
public function getRootFolder(): string {
8085
return $this->config->getSystemValueString('datadirectory', OC::$SERVERROOT . '/data');
8186
}

lib/private/Preview/Storage/ObjectStorePreviewStorage.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,24 @@ public function deleteUnreferencedPreview(Preview $preview): void {
174174
$this->deletePreview($preview);
175175
}
176176

177+
#[Override]
178+
public function previewExists(Preview $preview): bool {
179+
if ($preview->getLocationId() === null) {
180+
// Without a location the bucket cannot be resolved, which is the case
181+
// for rows written before a move to an object store and for the dummy
182+
// previews of the unit tests. Never report those as missing, or they
183+
// would be dropped on every request.
184+
return true;
185+
}
186+
187+
[
188+
'urn' => $urn,
189+
'store' => $store,
190+
] = $this->getObjectStoreInfoForExistingPreview($preview);
191+
192+
return $store->objectExists($urn);
193+
}
194+
177195
public function getUrn(Preview $preview, array $config): string {
178196
if ($preview->getOldFileId()) {
179197
return ($config['arguments']['objectPrefix'] ?? 'urn:oid:') . $preview->getOldFileId();

lib/private/Preview/Storage/StorageFactory.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,11 @@ public function deleteUnreferencedPreview(Preview $preview): void {
4242
$this->getBackend()->deleteUnreferencedPreview($preview);
4343
}
4444

45+
#[Override]
46+
public function previewExists(Preview $preview): bool {
47+
return $this->getBackend()->previewExists($preview);
48+
}
49+
4550
private function getBackend(): IPreviewStorage {
4651
if ($this->backend) {
4752
return $this->backend;

0 commit comments

Comments
 (0)