Repository navigation
fix(mongodb): retry an unconditional PutItem that conflicts on a GSI table #487
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
base: main
Are you sure you want to change the base?
Changes from all commits
c3f8bc8
db082ab
544103c
7692c47
b013ec6
b0c45f4
a15e401
f7d9114
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -146,6 +146,7 @@ The same on all four backends. | |
| | Provisioned throughput | Token bucket per table/partition | Token bucket per table/partition, matching DynamoDB's burst and refill behavior | | ||
| | On-demand capacity | Automatic scaling | Fixed initial burst capacity (4000 WCU / 12000 RCU), no auto-scaling | | ||
| | Throttling | Always on; throttles requests that exceed provisioned/burst capacity. No setting to disable | Configurable via `throttling_enabled` runtime setting (default: `true`) | | ||
| | Sustained contention on one item (MongoDB backend) | Applies concurrent single-item writes on one key one after another and answers 200 to each. Measured to every call at 32 and 64 writers putting one key of a table with a GSI. A `PutItem` is answered 400 `TransactionConflictException` only while an in-flight `TransactWriteItems` holds the item (33 of 80 calls measured), and a `TransactWriteItems` that loses to other transactions on one key is answered 400 `TransactionCanceledException` with a `TransactionConflict` reason (107 of 160 calls measured) | A write on a table with a secondary index writes the item and its index entries in one transaction, so concurrent writes on one key abort each other and are retried. One request's retry loop stops after 10 seconds (`CONTENTION_DEADLINE`) or 1,000 attempts, whichever comes first, and the request is then shed instead of waiting out the queue of writers on that key: `PutItem` answers 503 `ServiceUnavailable` on either bound, which the SDK retry modes retry and which succeeds once the contention has passed. `DeleteItem` and `UpdateItem` answer 503 on the deadline and 400 `TransactionConflictException` when their 1,000 attempts run out first. `TransactWriteItems` answers `TransactionCanceledException` with a `TransactionConflict` reason per item on either bound, which is the answer the service gives. Measured with one GSI table and all writers on one key: at 64 writers all 1,280 puts committed and none were shed, with the longest call 9.7 s; at 256 writers 1,262 of 5,120 committed and 3,858 were shed, with the longest call 11.7 s. Without the deadline the same 256-writer shape left calls waiting 99.5 s, so a client sees a shed request where it used to see a request outlive its own read timeout. The other three backends keep writes on one key waiting rather than shedding them, and the deadline does not apply to them | | ||
|
Collaborator
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. "at 64 writers all 1,280 puts committed and none were shed" didn't hold on a moderately loaded host here: 106 and 149 of 1,280 were shed at load averages 6.8 and 10.5. The description already says the shed count depends on load. Might be worth saying the same in this user-facing row, or keeping only the 256-writer bound. |
||
|
|
||
| ## Operations Not Implemented | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,151 @@ | ||
| # Copyright 2026 ExtendDB contributors | ||
| # SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| """Many unconditional PutItem calls on one key of a table with a GSI. | ||
|
|
||
| Amazon DynamoDB applies the puts one after another and answers 200 to each | ||
| one; the last writer wins, and the index holds the final item's entry only. | ||
| That is what the service answered to every call of this shape, up to 64 | ||
| writers on one key. | ||
|
|
||
| On the MongoDB backend a put on a table with an index writes the item and its | ||
| index entries in one transaction, so concurrent puts on one key abort each | ||
| other and are retried. The retry loop stops after ten seconds, so under load | ||
| ExtendDB may answer 503 ServiceUnavailable where the service answers 200, and | ||
| a client with the SDK default retry mode retries it. What this test holds the | ||
| backend to is the contract the retry loop gives: every call answers 200 or 503 | ||
| ServiceUnavailable, never 400 TransactionConflictException and never 500; the | ||
| stored item is one writer's item, whole; and the index holds exactly one entry | ||
| for it. These tests fail on a server that reports a lost race to the client as | ||
| a 400 or a 500. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import os | ||
| import threading | ||
| import time | ||
| import uuid | ||
|
|
||
| import boto3 | ||
| import pytest | ||
| from botocore.config import Config | ||
| from botocore.exceptions import ClientError | ||
|
|
||
| from conftest import scoped_table | ||
|
|
||
| WRITERS = 32 | ||
| PUTS_PER_WRITER = 63 | ||
| # The only failure a contended put may answer. The server sheds a put that | ||
| # has been losing to other writers for CONTENTION_DEADLINE (10 s). | ||
| SHED = "503 ServiceUnavailable" | ||
|
|
||
|
|
||
| @pytest.fixture(scope="module") | ||
| def raw_client(endpoint_url): | ||
| """A client that never retries, so every failure is seen.""" | ||
| kwargs: dict = { | ||
| "service_name": "dynamodb", | ||
| "region_name": os.environ.get("AWS_DEFAULT_REGION", "us-east-1"), | ||
| "config": Config( | ||
| retries={"total_max_attempts": 1, "mode": "standard"}, | ||
| max_pool_connections=WRITERS * 2, | ||
| ), | ||
| } | ||
| if endpoint_url: | ||
| kwargs["endpoint_url"] = endpoint_url | ||
| if endpoint_url.startswith("https://"): | ||
| kwargs["verify"] = False | ||
| return boto3.client(**kwargs) | ||
|
Collaborator
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. nit: this module-scoped client is shared by all 32 writer threads. A client per thread would avoid the occasional TLS error a shared botocore client gives under threads. Here that would fail the test rather than pass it, so it's a flake risk, not a false pass. |
||
|
|
||
|
|
||
| @pytest.fixture(scope="module") | ||
| def gsi_table(dynamodb_client): | ||
| with scoped_table( | ||
| dynamodb_client, | ||
| attribute_definitions=[ | ||
| {"AttributeName": "pk", "AttributeType": "S"}, | ||
| {"AttributeName": "gk", "AttributeType": "S"}, | ||
| ], | ||
| GlobalSecondaryIndexes=[ | ||
| { | ||
| "IndexName": "gk-index", | ||
| "KeySchema": [{"AttributeName": "gk", "KeyType": "HASH"}], | ||
| "Projection": {"ProjectionType": "ALL"}, | ||
| } | ||
| ], | ||
| ) as name: | ||
| yield name | ||
|
|
||
|
|
||
| def _hammer(client, table: str, pk: str) -> list[str]: | ||
| """WRITERS threads each put PUTS_PER_WRITER items on `pk`; return the failures.""" | ||
| start = threading.Barrier(WRITERS, timeout=30) | ||
| failures: list[str] = [] | ||
| errors: list[BaseException] = [] | ||
| lock = threading.Lock() | ||
|
|
||
| def run(i: int): | ||
| try: | ||
| start.wait() | ||
| for n in range(PUTS_PER_WRITER): | ||
| item = {"pk": {"S": pk}, "gk": {"S": f"g-{pk}-{i}-{n}"}, "w": {"S": f"w{i}-{n}"}} | ||
| try: | ||
| client.put_item(TableName=table, Item=item) | ||
| except ClientError as e: | ||
| code = f"{e.response['ResponseMetadata']['HTTPStatusCode']} {e.response['Error']['Code']}" | ||
| with lock: | ||
| failures.append(code) | ||
| except BaseException as e: # noqa: BLE001 - surfaced below | ||
| errors.append(e) | ||
|
|
||
| threads = [threading.Thread(target=run, args=(i,)) for i in range(WRITERS)] | ||
| for t in threads: | ||
| t.start() | ||
| for t in threads: | ||
| t.join() | ||
| if errors: | ||
| raise errors[0] | ||
| return failures | ||
|
|
||
|
|
||
| def _gsi_entries(client, table: str, pk: str) -> list[str]: | ||
| out: list[str] = [] | ||
| kwargs = {"TableName": table, "IndexName": "gk-index"} | ||
| while True: | ||
| resp = client.scan(**kwargs) | ||
| out.extend(i["gk"]["S"] for i in resp["Items"] if i["pk"]["S"] == pk) | ||
| if "LastEvaluatedKey" not in resp: | ||
| return sorted(out) | ||
| kwargs["ExclusiveStartKey"] = resp["LastEvaluatedKey"] | ||
|
|
||
|
|
||
| def test_unconditional_puts_on_one_key_of_a_gsi_table_commit_or_are_shed( | ||
| dynamodb_client, raw_client, gsi_table | ||
| ): | ||
| pk = f"hot-{uuid.uuid4().hex[:8]}" | ||
| failures = _hammer(raw_client, gsi_table, pk) | ||
| total = WRITERS * PUTS_PER_WRITER | ||
| # How many of the contended puts were shed. The count depends on the load | ||
| # the server is under, so it is reported rather than asserted; what is | ||
| # asserted is that a shed put is the only failure a caller can get. | ||
| print(f"{len(failures)} of {total} puts were shed, {total - len(failures)} committed") | ||
| assert set(failures) <= {SHED}, ( | ||
|
Collaborator
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. This only checks the error class, so any build that answers 503 at the first conflict passes as long as one put commits. That includes the old budget of 50 with the new give-up. The storage tests do pin the budget (the deadline tests and the 256-writer test fail with 50), so it's not unguarded, but a loose floor here, say at least half committed, would give the wire test some teeth without the 0-shed flakiness the description mentions. Similarly, |
||
| f"{len(failures)} of {total} puts failed with something other than a shed put: " | ||
| f"{sorted(set(failures))}" | ||
| ) | ||
| final = dynamodb_client.get_item( | ||
| TableName=gsi_table, Key={"pk": {"S": pk}}, ConsistentRead=True | ||
| )["Item"] | ||
| # The stored item is one writer's item, whole: its `gk` is the one that | ||
| # writer sent with that `w`, not another put's. | ||
| writer, _, nth = final["w"]["S"][1:].partition("-") | ||
| assert 0 <= int(writer) < WRITERS, final | ||
| assert 0 <= int(nth) < PUTS_PER_WRITER, final | ||
| assert final["gk"]["S"] == f"g-{pk}-{writer}-{nth}", final | ||
| # The GSI is eventually consistent on Amazon DynamoDB, so poll. | ||
| want = [final["gk"]["S"]] | ||
| deadline = time.monotonic() + 30 | ||
| while (got := _gsi_entries(dynamodb_client, gsi_table, pk)) != want and time.monotonic() < deadline: | ||
| time.sleep(0.5) | ||
| assert got == want, f"GSI entries for {pk}: want {want}, got {got}" | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
git merge-treeagainst current main is clean, butcargo check -p extenddb-storage-mongodb --testson the merged tree fails withE0063: missing field contention_deadlineatstream_records_live_tests.rs:46. That literal came in with #543 after this branch merged main. It's the same kind of break as the #384 one the description mentions, and #431 on its own also fails against main (itswith_scratch_engineliteral). A single test constructor forMongoEnginemight save the next round of these.