-
Notifications
You must be signed in to change notification settings - Fork 55
fix: bypass cache for locking document reads #885
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
26b1e64
a87f3ec
be6253c
c625c37
000d66a
5ea4b49
ed567e1
aa0f998
88c9ebf
329ce38
8c2bbd0
f3417c0
b08a62f
cc06a4a
fd8c063
fcbc0f0
b1f4ca3
0e4f936
0ce734f
52c33ed
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -377,6 +377,15 @@ class Database | |
| */ | ||
| protected static array $filters = []; | ||
|
|
||
| /** | ||
| * Lazy-derived list of caller-facing meta keys (`$id`, `$updatedAt`, …) | ||
| * sourced from INTERNAL_ATTRIBUTES. Cached because rebuilding it on every | ||
| * updateDocument() call is wasted work — the constant doesn't change. | ||
| * | ||
| * @var list<string>|null | ||
| */ | ||
| private static ?array $internalMetaKeys = null; | ||
|
|
||
| /** | ||
| * @var array<string, array{encode: callable, decode: callable, signature: string}> | ||
| */ | ||
|
|
@@ -4840,11 +4849,13 @@ public function getDocument(string $collection, string $id, array $queries = [], | |
| $selections | ||
| ); | ||
|
|
||
| try { | ||
| $cached = $this->cache->load($documentKey, self::TTL, $hashKey); | ||
| } catch (Exception $e) { | ||
| Console::warning('Warning: Failed to get document from cache: ' . $e->getMessage()); | ||
| $cached = null; | ||
| $cached = null; | ||
| if (!$forUpdate) { | ||
| try { | ||
| $cached = $this->cache->load($documentKey, self::TTL, $hashKey); | ||
| } catch (Exception $e) { | ||
| Console::warning('Warning: Failed to get document from cache: ' . $e->getMessage()); | ||
| } | ||
| } | ||
|
|
||
| if ($cached) { | ||
|
|
@@ -4917,7 +4928,7 @@ public function getDocument(string $collection, string $id, array $queries = [], | |
| ); | ||
|
|
||
| // Don't save to cache if it's part of a relationship | ||
| if (empty($relationships)) { | ||
| if (!$forUpdate && empty($relationships)) { | ||
| try { | ||
| $this->cache->save($documentKey, $document->getArrayCopy(), $hashKey); | ||
| $this->cache->save($collectionKey, 'empty', $documentKey); | ||
|
|
@@ -6138,8 +6149,16 @@ public function updateDocument(string $collection, string $id, Document $documen | |
|
|
||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [WARNING] Follow-up commit adds an inline comment at the capture site documenting the invariant. |
||
| $collection = $this->silent(fn () => $this->getCollection($collection)); | ||
| $newUpdatedAt = $document->getUpdatedAt(); | ||
| $document = $this->withTransaction(function () use ($collection, $id, $document, $newUpdatedAt) { | ||
| $isNoop = false; | ||
| $silentNoop = false; | ||
| $document = $this->withTransaction(function () use ($collection, $id, $document, $newUpdatedAt, &$isNoop, &$silentNoop) { | ||
| $time = DateTime::now(); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [WARNING] |
||
| // Reset on every attempt: withTransaction may retry on non-domain | ||
| // failures, and a previous attempt's flags must not bleed into a | ||
| // retry that ends up writing for real. | ||
| $isNoop = false; | ||
| $silentNoop = false; | ||
| $skipAdapterUpdate = false; | ||
| $old = $this->authorization->skip(fn () => $this->silent( | ||
| fn () => $this->getDocument($collection->getId(), $id, forUpdate: true) | ||
| )); | ||
|
|
@@ -6159,8 +6178,9 @@ public function updateDocument(string $collection, string $id, Document $documen | |
| $skipPermissionsUpdate = ($originalPermissions === $currentPermissions); | ||
| } | ||
| $createdAt = $document->getCreatedAt(); | ||
| $rawInput = $document->getArrayCopy(); | ||
|
|
||
| $document = \array_merge($old->getArrayCopy(), $document->getArrayCopy()); | ||
| $document = \array_merge($old->getArrayCopy(), $rawInput); | ||
| $document['$collection'] = $old->getAttribute('$collection'); // Make sure user doesn't switch collection ID | ||
| $document['$createdAt'] = ($createdAt === null || !$this->preserveDates) ? $old->getCreatedAt() : $createdAt; | ||
|
|
||
|
|
@@ -6181,6 +6201,8 @@ public function updateDocument(string $collection, string $id, Document $documen | |
|
|
||
| if ($collection->getId() !== self::METADATA) { | ||
| $documentSecurity = $collection->getAttribute('documentSecurity', false); | ||
| $updatedAtChanged = false; | ||
| $floatAttributes = null; | ||
|
|
||
| foreach ($relationships as $relationship) { | ||
| $relationships[$relationship->getAttribute('key')] = $relationship; | ||
|
|
@@ -6272,14 +6294,94 @@ public function updateDocument(string $collection, string $id, Document $documen | |
| continue; | ||
| } | ||
|
|
||
| if ($key === '$updatedAt') { | ||
| if ($value !== $old->getAttribute($key)) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [CRITICAL] Fixed in the follow-up commit by (a) tightening the predicate (
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [WARNING] Correctness finding C2 — The diff loop iterates the merged document, which always carries Fix: gate detection on |
||
| $updatedAtChanged = true; | ||
| } | ||
|
|
||
| continue; | ||
| } | ||
|
|
||
| $oldValue = $old->getAttribute($key); | ||
|
|
||
| if ($value !== $oldValue) { | ||
| // VAR_FLOAT: tolerate scalar type drift (e.g. JSON round-trip | ||
| // dropping trailing zeros so 5.0 comes back as int 5) by comparing | ||
| // as floats. Build the float-attribute map lazily — only on the | ||
| // first numeric-mismatch we encounter — so collections with no | ||
| // VAR_FLOAT attributes never pay the up-front scan cost. | ||
| if (\is_numeric($value) && \is_numeric($oldValue) && (float)$value === (float)$oldValue) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [WARNING] Security finding S4 — The documented contract is "int↔float drift only (JSON round-trip dropping trailing zeros)". Fix: tighten to |
||
| if ($floatAttributes === null) { | ||
| $floatAttributes = []; | ||
| foreach ($attributes as $attribute) { | ||
| if ($attribute->getAttribute('type') === self::VAR_FLOAT) { | ||
| $floatAttributes[$attribute->getAttribute('key')] = true; | ||
| } | ||
| } | ||
| } | ||
| if (isset($floatAttributes[$key])) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [CRITICAL] Correctness finding C1 — float-noop fell through to a wasted adapter write (FIXED in 46b2ec48). When the diff loop short-circuited on int↔float drift (e.g. Fix: in the READ-only |
||
| continue; | ||
| } | ||
| } | ||
|
|
||
| $shouldUpdate = true; | ||
| break; | ||
| } | ||
| } | ||
|
|
||
| $onlyUpdatedAtChanged = !$shouldUpdate && $updatedAtChanged; | ||
| $updatedAtIsValid = false; | ||
| $inputIsBareUpdatedAt = false; | ||
|
|
||
| if ($onlyUpdatedAtChanged) { | ||
| $updatedAt = $document->getAttribute('$updatedAt'); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [SUGGESTION] Widened |
||
| $updatedAtIsValid = \is_null($updatedAt) || $updatedAt instanceof \DateTime; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [CRITICAL] This gate is trivially bypassable — Fixed in the follow-up commit by introducing
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [CRITICAL] Security:
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [CORRECTNESS]
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [SECURITY-SUGGESTION] |
||
|
|
||
| // Strict ISO-shape check: \DateTime() accepts "now", "yesterday", | ||
| // "+1 year", "@0" etc. — none of which a real cached timestamp | ||
| // would carry. Constrain to formatDb ("Y-m-d H:i:s.v") and | ||
| // formatTz ("Y-m-d\TH:i:s.vP") prefixes so the tolerance branch | ||
| // can't be triggered by relative or symbolic time expressions. | ||
| if (!$updatedAtIsValid && \is_string($updatedAt) && $updatedAt !== '') { | ||
| $updatedAtIsValid = \preg_match('/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}/', $updatedAt) === 1; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [MAINT-SUGGESTION] The ISO-shape regex duplicates
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [WARNING] Security/maintainability findings S3 + M3 — ISO regex accepted garbage (FIXED in 46b2ec48 + 59a4160b). The regex Fix: extracted |
||
| } | ||
|
|
||
| // "Bare" = caller supplied no real attribute keys, only system | ||
| // metadata. A "real" key here must be both (a) not an internal | ||
| // meta key AND (b) actually defined on the collection schema — | ||
| // a junk/typo key like {garbageKey: null} must not be enough to | ||
| // flip the input to "non-bare", otherwise a read-only caller could | ||
| // unlock the stale-cache tolerance branch (and the event it fires) | ||
| // just by appending an unknown null-valued key. | ||
| // | ||
| // Single-pass isset lookups over $rawInput keys: prior versions | ||
| // used array_map + array_intersect + array_diff, which allocated | ||
| // two intermediate arrays and did O(N*M) value scans per call. | ||
| $schemaKeyLookup = []; | ||
| foreach ($attributes as $attr) { | ||
| $schemaKeyLookup[$attr->getAttribute('key')] = true; | ||
| } | ||
| $metaKeyLookup = \array_fill_keys(self::internalMetaKeys(), true); | ||
| $inputIsBareUpdatedAt = true; | ||
| foreach ($rawInput as $key => $_) { | ||
| if (isset($schemaKeyLookup[$key]) && !isset($metaKeyLookup[$key])) { | ||
| $inputIsBareUpdatedAt = false; | ||
| break; | ||
| } | ||
| } | ||
|
|
||
| if (!$inputIsBareUpdatedAt && \is_null($updatedAt)) { | ||
| // Caller nulled $updatedAt while resubmitting otherwise unchanged | ||
| // fields → treat as a silent no-op (don't touch storage). | ||
| $skipAdapterUpdate = true; | ||
| $document = $old; | ||
| } else { | ||
| // Caller explicitly set $updatedAt (or supplied only that key) → | ||
| // honor as a real update — requires UPDATE perm. | ||
| $shouldUpdate = true; | ||
| } | ||
|
Comment on lines
+6373
to
+6382
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When To close it: after |
||
| } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. test 6351 |
||
|
|
||
| $updatePermissions = [ | ||
| ...$collection->getUpdate(), | ||
| ...($documentSecurity ? $old->getUpdate() : []) | ||
|
|
@@ -6292,7 +6394,29 @@ public function updateDocument(string $collection, string $id, Document $documen | |
|
|
||
| if ($shouldUpdate) { | ||
| if (!$this->authorization->isValid(new Input(self::PERMISSION_UPDATE, $updatePermissions))) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [WARNING] Load-bearing 4-AND security check inline. Extracted to a named |
||
| throw new AuthorizationException($this->authorization->getDescription()); | ||
| // Stale-cache tolerance: a caller without UPDATE perm who has READ | ||
| // perm and resubmitted a stale $updatedAt (alongside otherwise | ||
| // unchanged fields) likely didn't intend to write — treat as a | ||
| // silent no-op instead of an authorization error. Bare | ||
| // {$updatedAt: ...} is an explicit timestamp write and still | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [CORRECTNESS] When this tolerance branch fires, the caller's |
||
| // requires UPDATE perm. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [WARNING — Readability] Hoisted the four-AND conditional into a named |
||
| $canTolerateStaleCache = $onlyUpdatedAtChanged | ||
| && !$inputIsBareUpdatedAt | ||
| && $updatedAtIsValid | ||
| && $this->authorization->isValid(new Input(self::PERMISSION_READ, $readPermissions)); | ||
|
|
||
| if ($canTolerateStaleCache) { | ||
| $shouldUpdate = false; | ||
| $skipAdapterUpdate = true; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [CRITICAL] When the tolerance branch above flips Fixed in the follow-up commit: the tolerance branch now |
||
| // Auth-rejected → tolerated: the caller lacked UPDATE, | ||
| // so don't emit EVENT_DOCUMENT_UPDATE downstream. Firing | ||
| // the event here would let a read-only caller probe doc | ||
| // existence and inject spurious audit-log entries. | ||
| $silentNoop = true; | ||
| $document = $old; | ||
| } else { | ||
| throw new AuthorizationException($this->authorization->getDescription()); | ||
| } | ||
| } | ||
| } else { | ||
| if (!$this->authorization->isValid(new Input(self::PERMISSION_READ, $readPermissions))) { | ||
|
|
@@ -6301,16 +6425,31 @@ public function updateDocument(string $collection, string $id, Document $documen | |
| } | ||
| } | ||
|
|
||
| if ($shouldUpdate) { | ||
| $document->setAttribute('$updatedAt', ($newUpdatedAt === null || !$this->preserveDates) ? $time : $newUpdatedAt); | ||
| } | ||
|
|
||
| // Check if document was updated after the request timestamp | ||
| // Optimistic-concurrency check runs before the no-op short-circuit: | ||
| // a caller that set $this->timestamp asserted "I last saw this doc | ||
| // at T; reject if mutated since." Letting a no-op silently return | ||
| // $old when storage was concurrently modified past T would violate | ||
| // that contract — main's pre-PR code ran this check on every call, | ||
| // including no-ops, because every call still hit the adapter. | ||
| $oldUpdatedAt = new \DateTime($old->getUpdatedAt()); | ||
| if (!is_null($this->timestamp) && $oldUpdatedAt > $this->timestamp) { | ||
| throw new ConflictException('Document was updated after the request timestamp'); | ||
| } | ||
|
|
||
| if ($skipAdapterUpdate) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [CRITICAL — fixed] This no-op early |
||
| // No-op: storage is unchanged, so re-running encode/structure-validation/ | ||
| // casting against $old (which is already decoded + relationships-populated | ||
| // by getDocument) would re-apply filters and corrupt the return shape. | ||
| // The caller expects the same shape as a real update would return; $old | ||
| // already matches that shape. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [CRITICAL — Security/Correctness] Without this early-return, the no-op stale-cache path still fired |
||
| $isNoop = true; | ||
| return $old; | ||
| } | ||
|
|
||
| if ($shouldUpdate) { | ||
| $document->setAttribute('$updatedAt', ($newUpdatedAt === null || !$this->preserveDates) ? $time : $newUpdatedAt); | ||
| } | ||
|
|
||
| $document = $this->encode($collection, $document); | ||
|
|
||
| if ($this->validate) { | ||
|
|
@@ -6365,6 +6504,20 @@ public function updateDocument(string $collection, string $id, Document $documen | |
| return $document; | ||
| } | ||
|
|
||
| if ($isNoop) { | ||
| // $old was returned directly from getDocument(), which already populated | ||
| // relationships, decoded filters, and applied the custom document type. | ||
| // Re-running those would corrupt the shape; skip post-processing. The | ||
| // caller still invoked updateDocument(), so by default emit the event so | ||
| // audit listeners and change-stream consumers see the attempt — except | ||
| // on the stale-cache tolerance path, where the caller lacked UPDATE perm | ||
| // and the event would leak doc existence / forge audit entries. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [CRITICAL] Security finding S1 — silent-noop audit gap (FIXED in 46b2ec48). The pre-fix path returned Fix: emit |
||
| if (!$silentNoop) { | ||
| $this->trigger(self::EVENT_DOCUMENT_UPDATE, $document); | ||
| } | ||
| return $document; | ||
| } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [WARNING — security/observability] Suppressing |
||
|
|
||
| if (!$this->inBatchRelationshipPopulation && $this->resolveRelationships) { | ||
| $documents = $this->silent(fn () => $this->populateDocumentsRelationships([$document], $collection, $this->relationshipFetchDepth)); | ||
| $document = $documents[0]; | ||
|
|
@@ -9460,6 +9613,14 @@ public function getCacheKeys(string $collectionId, ?string $documentId = null, a | |
| ]; | ||
| } | ||
|
|
||
| /** | ||
| * @return list<string> | ||
| */ | ||
| private static function internalMetaKeys(): array | ||
| { | ||
| return self::$internalMetaKeys ??= \array_column(self::INTERNAL_ATTRIBUTES, '$id'); | ||
| } | ||
|
|
||
| private static function computeCallableSignature(callable $callable): string | ||
| { | ||
| if (\is_string($callable)) { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6146,14 +6146,14 @@ public function testSingleDocumentDateOperations(): void | |
| $originalCreatedAt4 = $doc4->getAttribute('$createdAt'); | ||
| $originalUpdatedAt4 = $doc4->getAttribute('$updatedAt'); | ||
|
|
||
| sleep(1); // Ensure $updatedAt differs when adapter timestamp precision is seconds | ||
|
|
||
| $doc4->setAttribute('$updatedAt', null); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [WARNING] Maintainability finding M8 — e2e coverage gap (FIXED in 27cba2be). Added |
||
| $doc4->setAttribute('$createdAt', null); | ||
| $updatedDoc4 = $database->updateDocument($collection, 'doc4', document: $doc4); | ||
|
|
||
| // No content changed and dates were nulled, so the update is a no-op | ||
| // and both timestamps are preserved. | ||
| $this->assertEquals($originalCreatedAt4, $updatedDoc4->getAttribute('$createdAt')); | ||
| $this->assertNotEquals($originalUpdatedAt4, $updatedDoc4->getAttribute('$updatedAt')); | ||
| $this->assertEquals($originalUpdatedAt4, $updatedDoc4->getAttribute('$updatedAt')); | ||
|
|
||
| // Test 5: Update only updatedAt | ||
| $updatedDoc4->setAttribute('$updatedAt', $updateDate); | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.