Skip to content

fix(documents): hold an increase or decrease to the column's own range in the write itself - #1037

Merged
abnegate merged 2 commits into
mainfrom
fix/counter-column-bounds
Oct 16, 2026
Merged

abnegate merged 2 commits into
mainfrom
fix/counter-column-bounds

Conversation

@abnegate

Copy link
Copy Markdown
Member

What

Staging bug: increment and decrement ignored the column's own min/max. Column b is declared with max 10 (Appwrite's intRange format):

  • Incrementing 5 by 6 returned 200 and stored 11.
  • Decrementing below the column's min 0 stored -3.
  • An explicit max above the column's max (max: 20) was accepted as the limit.

Root cause

Database::increaseDocumentAttribute() and decreaseDocumentAttribute() (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's intRange, bigintRange and floatRange register a Utopia\Validator\Range with Structure::addFormat(), and Structure applies it to document writes, which is why updateDocument(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 returned true, and Database returned 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

  • Database derives the column's bounds from its format validator when it is a Range, so it is the same validator Structure enforces on writes. It then applies min(column max, call max) on increase and max(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 returns false when its bound condition left the row unchanged. SQL returns rowCount() > 0, Mongo the matched count, and Memory and Redis false at their bound checks. Database turns false into Exception\Limit (Attribute value exceeds maximum limit: 10 / ... minimum limit: 0), so the caller never sees a value that was not stored. Pool forwards it unchanged. Callers that do not pass a bound are unaffected.

SQL before and after for increase b by 6 on a 0..10 column at 5 (MySQL; Postgres differs only in quoting):

Statement and binds Result
origin/main UPDATE … SET b = COALESCE(b, 0) + ?, _updatedAt = ? WHERE _uid = ? binds [6, now, 'row'] row → 11, returns 200
this PR not reached: refused by the check against the locked read with Limit: … maximum limit: 10 no statement
this PR, a concurrent race UPDATE … WHERE _uid = ? AND (b <= ? OR b IS NULL) binds […, 4] 0 rows → Exception\Limit

The hot path is unchanged: one format-validator construction per call, only on a column with a format, and no extra statement.

Regression tests

Test Adapters origin/main (8.3.4) this PR
DocumentTests::testIncreaseAndDecreaseKeepACounterWithinItsColumnRange. It covers: the exact repro (5 + 6 on a 0..10 column); 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 at PHP_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. MySQL, MariaDB, Postgres, SQLite, MongoDB, Memory, Redis fail: an increase past the column maximum was accepted pass
DocumentTests::testAnIncreaseTheStoredValueAlreadyPassedTheBoundOfChangesNothing. Two increments that both read 9 race to the max of 10 through the adapter's atomic condition: the first returns true and stores 10, the second returns false and stores nothing. The test repeats this for a decrease to the min, and for doubles. MySQL, MariaDB, Postgres, SQLite, MongoDB, Memory, Redis fail: the second of two increases read at 9 passes the maximum (true returned) pass
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 with Limit, and the stored value is the concurrent one, not one past the bound. An increase that still fits is applied. Memory, no-transaction Memory 6/8 fail (accepted) pass
MemoryWritePathsTest::testIncrementChangesNothingAndSaysSo… and RedisAdapterPathsTest::testIncrementGuardsOfTheAdapter: pinned the old "silent true" contract and now expect false Memory, Redis assert old contract pass

Run 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 returns false instead of true when its bound excluded the row. Appwrite and Cloud reach it only through Database and Pool.

API repro (Appwrite)

Integer column b created with min: 0, max: 10, on a row with b = 5:

  • PATCH /v1/tablesdb/{db}/tables/{t}/rows/{rowId}/b/increment {"value": 6} → 400 column_limit_exceeded (was 200 and stored 11). 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 (was 200 and stored -3). With {"value": 5} → 200, b = 0.
  • The same holds for a float column (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

abnegate and others added 2 commits October 16, 2026 21:52
…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>
@coderabbitai

coderabbitai Bot commented Oct 16, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

You'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.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration
  • Configuration used: Repository: utopia-php/database/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: db5effc1-32fe-47f3-b06e-b1a263b8cfcf
📥 Commits

Reviewing files that changed from the base of the PR and between 7154053 and a8a31c1.

📒 Files selected for processing: 11
  • src/Database/Adapter.php
  • src/Database/Adapter/Memory.php
  • src/Database/Adapter/Mongo.php
  • src/Database/Adapter/Redis.php
  • src/Database/Adapter/SQL.php
  • src/Database/Trait/Documents.php
  • tests/e2e/Adapter/Scopes/DocumentTests.php
  • tests/unit/Adapter/MemoryWritePathsTest.php
  • tests/unit/Adapter/RedisAdapterPathsTest.php
  • tests/unit/Documents/CounterRangeRaceTest.php
  • tests/unit/Documents/Interleaving/Interleaves.php
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@abnegate
abnegate merged commit 5fd5eaf into main Oct 16, 2026
24 checks passed
@abnegate
abnegate deleted the fix/counter-column-bounds branch October 16, 2026 09:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant