Repository navigation
fix(documents): hold an increase or decrease to the column's own range in the write itself - #1037
Merged
Merged
Conversation
…e in the write itself increaseDocumentAttribute() and decreaseDocumentAttribute() checked only the bound passed in the call, so a column whose format holds it to 0..10 was incremented from 5 by 6 to 11, decremented below 0, and accepted an explicit maximum above its own. The tighter of the call's bound and the Range the column's format validator is now bounds the change, both in the check against the value read and in the conditional write. Adapters return false when that condition left the row unchanged, and the change is refused with Exception\Limit instead of reporting a value that was never stored, so concurrent changes near the bound cannot carry a counter past it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two documents created in the same millisecond share $createdAt, so a distinct read of k and $createdAt returned three rows instead of four on fast runs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Contributor
|
Warning Review limit reachedYou've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. Next included review available in 33 minutes. View limit details
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Staging bug: increment and decrement ignored the column's own
min/max. Columnbis declared with max 10 (Appwrite'sintRangeformat):5by6returned200and stored11.-3.maxabove the column's max (max: 20) was accepted as the limit.Root cause
Database::increaseDocumentAttribute()anddecreaseDocumentAttribute()(Trait/Documents.php) checked the new value only against the bound passed in the call and the type's range. A column's declared range lives in its format: Appwrite'sintRange,bigintRangeandfloatRangeregister aUtopia\Validator\RangewithStructure::addFormat(), andStructureapplies it to document writes, which is whyupdateDocument(b: 11)was already refused. The counter path never consulted it.A second, related gap: every adapter writes the change with the bound as an atomic condition (
WHERE b <= max - value, a Mongo$expr, or a locked compare in Memory/Redis). But when that condition excluded the row, the adapter still returnedtrue, andDatabasereturned the value it had computed. So a concurrent change that carried the counter to the bound first went unreported: the write did nothing, and the caller was told it succeeded.Fix
Databasederives the column's bounds from its format validator when it is aRange, so it is the same validatorStructureenforces on writes. It then appliesmin(column max, call max)on increase andmax(column min, call min)on decrease. Integer columns floor or ceil the column bound as they already did the call's. Infinite (unset) bounds are ignored. The tighter bound is used both for the check against the value read and for the conditional write the adapter runs, so the bound holds atomically in the engine and the check is not just read-then-write.Adapter::increaseDocumentAttribute()now returnsfalsewhen its bound condition left the row unchanged. SQL returnsrowCount() > 0, Mongo the matched count, and Memory and Redisfalseat their bound checks.DatabaseturnsfalseintoException\Limit(Attribute value exceeds maximum limit: 10/... minimum limit: 0), so the caller never sees a value that was not stored.Poolforwards it unchanged. Callers that do not pass a bound are unaffected.SQL before and after for
increase b by 6on a0..10column at5(MySQL; Postgres differs only in quoting):UPDATE … SET b = COALESCE(b, 0) + ?, _updatedAt = ? WHERE _uid = ?binds[6, now, 'row']Limit: … maximum limit: 10UPDATE … WHERE _uid = ? AND (b <= ? OR b IS NULL)binds[…, 4]Exception\LimitThe hot path is unchanged: one format-validator construction per call, only on a column with a format, and no extra statement.
Regression tests
DocumentTests::testIncreaseAndDecreaseKeepACounterWithinItsColumnRange. It covers: the exact repro (5 + 6 on a0..10column); a call max above the column max (20) and below it (8); a decrease below the column min, and with a call min below it (−10) and above it (2); 64-bit integer and big integer columns atPHP_INT_MAX − 1/PHP_INT_MIN + 1, one past each edge; a double column at−1.5..2.5. Every refusal leaves the stored value as it was, and changes landing exactly on each edge are accepted.an increase past the column maximum was acceptedDocumentTests::testAnIncreaseTheStoredValueAlreadyPassedTheBoundOfChangesNothing. Two increments that both read9race to the max of 10 through the adapter's atomic condition: the first returnstrueand stores 10, the second returnsfalseand stores nothing. The test repeats this for a decrease to the min, and for doubles.the second of two increases read at 9 passes the maximum(truereturned)CounterRangeRaceTest(unit, 8 cases). Another increment lands between the read and the write, as a second connection would, on Memory and on an engine without transactions (as standalone MongoDB is). An increase past the column max, a decrease past a double column's min, and an increase past an explicit call max are refused withLimit, and the stored value is the concurrent one, not one past the bound. An increase that still fits is applied.MemoryWritePathsTest::testIncrementChangesNothingAndSaysSo…andRedisAdapterPathsTest::testIncrementGuardsOfTheAdapter: pinned the old "silenttrue" contract and now expectfalseRun locally against Docker: the full MySQL, MariaDB, Postgres, SQLite, MongoDB, Memory and Redis suites, the full unit suite, Pint and PHPStan.
Behaviour change to note: an adapter's
increaseDocumentAttribute()now returnsfalseinstead oftruewhen its bound excluded the row. Appwrite and Cloud reach it only throughDatabaseandPool.API repro (Appwrite)
Integer column
bcreated withmin: 0, max: 10, on a row withb = 5:PATCH /v1/tablesdb/{db}/tables/{t}/rows/{rowId}/b/increment{"value": 6}→400 column_limit_exceeded(was200and stored11). The same request with{"value": 6, "max": 20}→400 column_limit_exceeded. With{"value": 5}→200,b = 10.PATCH /v1/tablesdb/{db}/tables/{t}/rows/{rowId}/b/decrement{"value": 8}→400 column_limit_exceeded(was200and stored-3). With{"value": 5}→200,b = 0.min: -1.5, max: 2.5) and a bigint column at its edges.Appwrite's increment/decrement endpoint builds the message from the request's
max/min, which is empty when only the column bound applies: "has reached the maximum value of ". That needs a server-side message tweak.🤖 Generated with Claude Code