Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions src/Database/Adapter.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,18 @@ abstract class Adapter

protected int $inTransaction = 0;

/**
* Document cache keys purged while a transaction was open, keyed by
* "collectionId:id" for de-duplication. The Database flushes these once
* the outermost transaction commits so a reader that re-cached the
* pre-commit version in the meantime gets invalidated. Lives on the
* adapter because transaction depth and cache are shared here across
* Database wrappers (e.g. Mirror shares its source's adapter and cache).
*
* @var array<string, array{0: string, 1: string}>
*/
protected array $purgeAfterCommit = [];

protected bool $alterLocks = false;

protected bool $skipDuplicates = false;
Expand Down Expand Up @@ -394,6 +406,32 @@ public function inTransaction(): bool
return $this->inTransaction > 0;
}

/**
* Queue a document cache key to be purged again once the outermost
* transaction commits.
*
* @param string $collectionId
* @param string $id
* @return void
*/
public function queueCachePurge(string $collectionId, string $id): void
{
$this->purgeAfterCommit[$collectionId . ':' . $id] = [$collectionId, $id];
}

/**
* Return and clear the queued post-commit cache purges.
*
* @return array<string, array{0: string, 1: string}>
*/
public function dequeueCachePurges(): array
{
$purges = $this->purgeAfterCommit;
$this->purgeAfterCommit = [];

return $purges;
}

/**
* Run a callback with skipDuplicates enabled.
* Duplicate key errors during createDocuments() will be silently skipped
Expand Down
1 change: 1 addition & 0 deletions src/Database/Adapter/SQL.php
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ public function reconnect(): void
{
$this->getPDO()->reconnect();
$this->inTransaction = 0;
$this->purgeAfterCommit = [];
}

/**
Expand Down
38 changes: 32 additions & 6 deletions src/Database/Database.php
Original file line number Diff line number Diff line change
Expand Up @@ -1555,7 +1555,29 @@ public function getAdapter(): Adapter
*/
public function withTransaction(callable $callback): mixed
{
return $this->adapter->withTransaction($callback);
try {
$result = $this->adapter->withTransaction($callback);
} catch (\Throwable $e) {
// Only the outermost frame owns the queue; nested frames leave it
// for the parent. On a full rollback nothing committed, so drop the
// queued purges rather than evicting cache for changes that reverted.
if (!$this->adapter->inTransaction()) {
$this->adapter->dequeueCachePurges();
}
throw $e;
}

// The rows are now committed and visible. Re-purge every key touched
// during the transaction so a reader that cached a pre-commit version
// in the meantime is invalidated. The queue lives on the (shared)
// adapter, so this covers writes routed through wrapper databases too.
if (!$this->adapter->inTransaction()) {
foreach ($this->adapter->dequeueCachePurges() as [$collectionId, $id]) {
$this->purgeCachedDocumentInternal($collectionId, $id);
}
}

return $result;
}

/**
Expand Down Expand Up @@ -6397,9 +6419,6 @@ public function updateDocument(string $collection, string $id, Document $documen
return $document;
}

// Purge again after commit so readers cannot re-cache the pre-commit version
$this->purgeCachedDocumentInternal($collection->getId(), $id);

if (!$this->inBatchRelationshipPopulation && $this->resolveRelationships) {
$documents = $this->silent(fn () => $this->populateDocumentsRelationships([$document], $collection, $this->relationshipFetchDepth));
$document = $documents[0];
Expand Down Expand Up @@ -7786,8 +7805,6 @@ public function deleteDocument(string $collection, string $id): bool
});

if ($deleted) {
// Purge again after commit so readers cannot re-cache the pre-commit version
$this->purgeCachedDocumentInternal($collection->getId(), $id);
$this->trigger(self::EVENT_DOCUMENT_DELETE, $document);
}

Expand Down Expand Up @@ -8390,6 +8407,15 @@ protected function purgeCachedDocumentInternal(string $collectionId, ?string $id
$this->cache->purge($collectionKey, $documentKey);
$this->cache->purge($documentKey);

// A purge inside an open transaction targets rows that are not yet
// committed. A concurrent reader can re-populate the cache with the
// pre-commit version before this transaction commits, leaving stale
// data behind. Queue the key to purge again once the outermost
// transaction commits and the new rows are visible to everyone.
if ($this->adapter->inTransaction()) {
$this->adapter->queueCachePurge($collectionId, $id);
}

return true;
}

Expand Down
Loading