Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 58
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
Uh oh!
There was an error while loading. Please reload this page.
Changes from all commits
26b1e64a87f3ecbe6253cc625c37000d66a5ea4b49ed567e1aa0f99888c9ebf329ce388c2bbd0f3417c0b08a62fcc06a4afd8c063fcbc0f0b1f4ca30e4f9360ce734f52c33edFile filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
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.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.