An Empty DynamoDB Query Page Can Still Have More Matches
A Query response arrives without an exception. Its Items list is empty, Count is zero, and LastEvaluatedKey still names a position in the partition. The export helper returns an empty list and announces completion. The response did not make that final claim. The client did, by treating returned matches as evidence that traversal had ended.
AWS explicitly documents this combination in the Query API reference: a filtered page can contain no matches while retaining continuation. The engineering question is therefore not whether an empty response is valid. It is whether the application carries enough evidence to distinguish an exhausted result from a page that happened to return nothing.
This playbook uses a three-response baseline: an empty page ending at key 02, a short page returning 03 and ending at 04, and a terminal page returning 05. Nine local cases expose two incorrect stopping rules, qualify continuation, preserve a page-budget boundary, and exercise same-request resume, stalled progress, and an error after partial traversal.
Every response is authored input to Botocore Stubber. No table, AWS account, network request, or live filtering experiment is involved. The acceptance target is a bounded client contract, not a snapshot of a changing database. Keep that scope visible when interpreting both the successful tests and the intentionally wrong clients that also receive a diagnostic PASS.
Define what a complete Query result must mean
Freeze the application question before reading the first page: traverse the synthetic base-table partition pk=T, retain items whose status is READY, and move forward under one unchanged request specification. For the baseline, the independent expected membership is the ordered list ['03','05']. Neither the collector's output nor the number of responses determines that oracle.
Three outcomes must remain distinguishable. A response may be usable. A traversal may be exhausted. Its resulting membership may satisfy the application's requirement. An empty first page establishes only a usable observation here. An exhausted empty traversal can be correct for a no-match profile but still unacceptable to an application that requires records.
Adopt four review decisions. REPAIR rejects a false completion rule. ACCEPT_BOUNDED approves an exhausted, reconciled fixture traversal within its admitted inputs. RETURN_PARTIAL preserves useful work and a continuation when the application page budget ends. HOLD prevents acceptance when progress repeats, a modeled error interrupts the chain, or the saved query contract has changed without review.
This differs from BatchGetItem key reconciliation, where the review starts with individually requested keys. Here, the unit of evidence is an ordered chain of Query responses. Do not import another operation's missing-key classification into an unfinished partition traversal.
Q4 and Q5 deliberately have independent empty-match profiles; they are not failed attempts to retrieve the baseline's 03 and 05. Likewise, Q7 begins at an explicit saved boundary, so its local completion concerns the remaining suffix. Acceptance of a whole resumed export also needs the preceding evidence. Defining these scopes prevents a correct local flag from silently acquiring a broader meaning.
Freeze the request and separate limits from matches
The driver defines BASE once. KeyConditionExpression='#pk = :p' binds the partition through #pk to pk and :p to {'S':'T'}. FilterExpression='#s = :s' binds #s to status and :s to {'S':'READY'}. Both key components use low-level DynamoDB string AttributeValue maps. This is boto3.client('dynamodb').query, not a resource-object loop or a command-line paginator.
ScanIndexForward=True, ConsistentRead=True, and Limit=2 are part of every expected request. There is no IndexName; the named table is only a synthetic identifier. No server is contacted, and the fixture contains no concurrent changes.
The Query limit definition bounds evaluated items, not the number of returned matches. A real Query can also stop at the 1 MB data boundary before reaching Limit; that boundary applies before filtering and is not exercised by these tiny stubs. AWS's filter-expression guidance places filtering after reading and explains why discarding matches does not reduce the read capacity consumed. Keep the partition condition in the key expression; do not move key attributes into the filter as a pagination repair.
Name the three different counts
The response's Count and ScannedCount fields distinguish returned membership from evaluation. Count is the number of returned items on one response. ScannedCount counts evaluated items before filtering. Accumulated matches describe what this collector has retained across successful responses. In this fixture they reconcile as follows; the values are authored test inputs, not measured capacity.
Page | Request start sk | Returned sk | Count | ScannedCount | Next sk |
1 | None | None | 0 | 2 | 02 |
2 | 02 | 03 | 1 | 2 | 04 |
3 | 04 | 05 | 1 | 1 | None |
The totals are two returned matches and five evaluated items. Empty membership on page one does not erase its two-item evaluation or its continuation. Nor does one returned item on page two establish exhaustion. These counts answer different questions even when their values occasionally coincide.
Run the complete nine-case client laboratory
Save the complete source below as dynamodb_query_fixture.py in a writable directory that you own. Use CPython 3.13.5 with boto3 1.43.18 and botocore 1.43.18 to reproduce the recorded environment. Dependencies must already be available locally; a missing package is a setup failure, not a successful empty test run. Execute the file with the following command.
python dynamodb_query_fixture.py
The supplied commissioning record reports PASS_9_CASES for a run starting at 2026-10-15T08:07:47.899251+00:00 and finishing at 2026-10-15T08:07:47.944021+00:00. It identifies CPython 3.13.5, boto3 1.43.18, botocore 1.43.18, and Linux-6.18.44-x86_64-with-glibc2.41. Its checkpoints describe local fixture outcomes, not responses captured from AWS.
A separately reproduced run of the identical source began at 2026-10-15T08:55:34.285494+00:00 and finished at 2026-10-15T08:55:34.510764+00:00. It used the same recorded versions and platform and independently returned PASS_9_CASES. All nine checkpoint outcomes matched the supplied record. These timestamps identify local executions; they are not AWS latency measurements.
The source SHA-256 for both executions is shown below. It covers the exact source bytes, including its final newline. A modified driver needs its own digest and execution record rather than inheriting either PASS.
38eb83470ce74890a54c6f05b9a44f270f74ffb79403747df238b9947cb8b961
The Botocore Stubber reference documents queued responses, expected request parameters, context-manager activation, and the pending-response assertion used here. The living documentation was checked on October 15, 2026; its displayed version, 1.43.108, is not the tested 1.43.18 runtime. Use documentation for the contract and the execution manifest for what actually ran.
All Query calls occur inside an active Stubber context. Dummy credentials and an explicit Region satisfy client construction without requiring remote credentials. Keep those boundaries intact. Do not replace the table name and disable Stubber to turn this file into a production-table experiment.
"""Nine local client-control tests. Stubbed responses, not an AWS service run."""
import copy
import hashlib
import json
import platform
import tempfile
from datetime import datetime, timezone
from pathlib import Path
import boto3
import botocore
from botocore.config import Config
from botocore.exceptions import ClientError
from botocore.stub import Stubber
BASE = dict(TableName='refonte-query-fixture',
KeyConditionExpression='#pk = :p', FilterExpression='#s = :s',
ExpressionAttributeNames={'#pk':'pk', '#s':'status'},
ExpressionAttributeValues={':p':{'S':'T'}, ':s':{'S':'READY'}},
ConsistentRead=True, ScanIndexForward=True, Limit=2)
def key(n):
return {'pk':{'S':'T'}, 'sk':{'S':n}}
def page(ids, scanned, last=None):
result = {'Items':[dict(key(n), status={'S':'READY'}) for n in ids],
'Count':len(ids), 'ScannedCount':scanned}
if last is not None:
result['LastEvaluatedKey'] = key(last)
return result
PAGES = [page([],2,'02'), page(['03'],2,'04'), page(['05'],1)]
def collect(client, limit=10, start=None, flawed=None):
if type(limit) is not int or limit < 1:
raise ValueError('Positive page budget required')
out = {'complete':False, 'reason':'PAGE_BUDGET', 'items':[],
'pages':[], 'cursor':copy.deepcopy(start)}
seen = {json.dumps(start,sort_keys=True)} if start else set()
for _ in range(limit):
params = copy.deepcopy(BASE)
if out['cursor']:
params['ExclusiveStartKey'] = copy.deepcopy(out['cursor'])
try:
response = client.query(**params)
except ClientError as error:
out.update(reason='SERVICE_ERROR', error=error.response['Error']['Code'])
return out
out['pages'].append({'request':params, 'response':copy.deepcopy(response)})
out['items'].extend(copy.deepcopy(response['Items']))
out['cursor'] = copy.deepcopy(response.get('LastEvaluatedKey')) or None
# Deliberate negative controls: falsely announce completion.
if flawed == 'empty' and not response['Items']:
out.update(complete=True, reason='WRONG_EMPTY_STOP'); return out
if flawed == 'short' and response['Count'] < BASE['Limit']:
out.update(complete=True, reason='WRONG_SHORT_STOP'); return out
if out['cursor'] is None:
out.update(complete=True, reason='END'); return out
token = json.dumps(out['cursor'], sort_keys=True)
if token in seen:
out['reason'] = 'REPEATED_CURSOR'; return out
seen.add(token)
return out
def require(ok, message):
if not ok:
raise RuntimeError(message)
def main():
folder = Path(tempfile.mkdtemp(prefix='query-evidence-', dir=Path(__file__).parent))
report = {'status':'FAIL', 'started_utc':datetime.now(timezone.utc).isoformat(),
'python':platform.python_version(), 'platform':platform.platform(),
'boto3':boto3.__version__, 'botocore':botocore.__version__,
'source_sha256':hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
'baseline_matching_keys':['03','05'], 'cases':[]}
client = None
def case(name, responses, expected, **options):
row = {'id':name, 'status':'FAIL'}; report['cases'].append(row)
cursor = copy.deepcopy(options.get('start'))
with Stubber(client) as stub:
for response in responses:
params = copy.deepcopy(BASE)
if cursor:
params['ExclusiveStartKey'] = copy.deepcopy(cursor)
if response == 'ERROR':
stub.add_client_error('query', service_error_code='InternalServerError',
http_status_code=500, expected_params=params)
else:
stub.add_response('query', copy.deepcopy(response), params)
cursor = copy.deepcopy(response.get('LastEvaluatedKey'))
row['result'] = result = collect(client, **options)
stub.assert_no_pending_responses()
got = [result['complete'], result['reason'],
[x['sk']['S'] for x in result['items']], len(result['pages'])]
row.update(expected=expected, observed=got)
require(got == expected, name + ': unexpected result')
row['status'] = 'PASS'
try:
client = boto3.client('dynamodb', region_name='us-east-1',
aws_access_key_id='synthetic', aws_secret_access_key='synthetic',
config=Config(retries={'max_attempts':0}))
case('Q1_empty_stop', PAGES[:1], [True,'WRONG_EMPTY_STOP',[],1], flawed='empty')
case('Q2_short_stop', PAGES[1:2], [True,'WRONG_SHORT_STOP',['03'],1],
start=key('02'), flawed='short')
case('Q3_complete', PAGES, [True,'END',['03','05'],3])
case('Q4_no_later_match', [page([],2,'02'),page([],1)], [True,'END',[],2])
case('Q5_empty_terminal', [page([],0)], [True,'END',[],1])
case('Q6_budget', PAGES[:1], [False,'PAGE_BUDGET',[],1], limit=1)
require(report['cases'][-1]['result']['cursor'] == key('02'), 'Lost continuation')
case('Q7_resume_same_request', PAGES[1:], [True,'END',['03','05'],2], start=key('02'))
case('Q8_repeated_cursor', [page([],2,'02'),page([],2,'02')],
[False,'REPEATED_CURSOR',[],2])
case('Q9_error_after_page', [PAGES[0],'ERROR'], [False,'SERVICE_ERROR',[],1])
require(report['cases'][-1]['result']['cursor'] == key('02'), 'Lost retry boundary')
require(len(report['cases']) == 9, 'Incomplete inventory')
report['status'] = 'PASS_9_CASES'
except Exception as error:
report['error'] = {'type':type(error).__name__, 'message':str(error)}
raise
finally:
if client is not None:
client.close()
report['finished_utc'] = datetime.now(timezone.utc).isoformat()
target = folder / 'results.json'
target.write_text(json.dumps(report, indent=2) + '\n')
print(target)
if name == '__main__':
main()
Read the harness separately from the collector. page() authors response dictionaries; it does not execute FilterExpression. PAGES supplies the empty/key02, 03/key04, and 05/terminal sequence. The expected baseline keys are also recorded explicitly in the report, and each case has a hard-coded expected outcome. The conceptual five-item traversal is represented by page counters and returned items, not by a populated local database.
case() queues exactly the responses relevant to its scenario, constructs each expected request from BASE and the preceding continuation, then calls collect(). The final comparison checks completion, reason, ordered returned keys, and successful page count. Q6 and Q9 additionally assert that key 02 survives as the continuation or retry boundary. The inventory assertion requires nine cases, not merely a nonempty report. The flawed parameter exists only to select Q1 and Q2's deliberately wrong behavior. Remove those branches before adapting the collector for an application; flawed=None selects the repaired path exercised here.
The collector accepts a positive integer page budget. Its explicit type check rejects values such as a boolean, and invalid budgets raise ValueError. That is visible input-validation logic; the nine named cases do not include additional invalid-budget executions. The admitted data shape is intentionally narrow: JSON-serializable string keys and the modeled response fields supplied by this harness.
The driver creates a separate local results directory beside the source and prints the location of results.json after writing it. Preserve the entire directory with its source revision. Inspect the status, versions, digest, case inventory, expected and observed arrays, and retained request/response pairs. Confirm that the file can be parsed and identifies the exact source revision under review.
The outer exception handler records a run-level error and re-raises it after entering the guarded execution. Earlier failures, such as an import failure or inability to create the directory, may leave no results file. Unexpected collector exceptions may prevent a per-case result from being attached. A failure in client.close() or report writing can also prevent a complete manifest. Treat missing or damaged evidence as a failed run, not a successful empty result.
The laboratory qualifies client control flow, not AWS filtering, access configuration, service page boundaries, live capacity, or a concurrency policy. Live service integration remains untested; any future run needs its own approved setup and evidence. That limitation does not erase the local result, but it restricts what the result can support.
The source is not a general-purpose defensive response parser. It directly indexes Items, and the fixture's page() helper constructs matching items with status=READY and Count equal to their number. Nine passing cases therefore do not qualify behavior for missing fields, inconsistent counters, duplicate membership, or unrelated key types. Those would require new admitted profiles and explicit expectations. Retaining response evidence makes such problems inspectable; it does not mean this collector automatically detects every malformed yet model-shaped payload.
Expose the empty-page false completion
Q1_empty_stop begins without ExclusiveStartKey and receives the first baseline response. Its Items list is empty, Count is zero, ScannedCount is two, and its continuation is the complete typed key for pk=T, sk=02. The collector records that page and copies the key before entering the deliberately flawed branch.
With flawed='empty', the condition sees an empty list and sets complete=True with reason WRONG_EMPTY_STOP. The observed checkpoint is therefore internally revealing: no returned keys, one recorded page, an affirmative completion flag, and a still-present cursor. A downstream wrapper that returns only items would hide the evidence that exposes its mistake.
Compare this result with the independent baseline oracle. Both 03 and 05 remain beyond the retained boundary in the authored response chain. The design is REPAIR, even though no modeled service exception occurred. Its defect is not that it received an empty page; its defect is the inference attached to that page.
Do not let HTTP success erase the continuation
The AWS response contract distinguishes a successful response from a terminal traversal. This stubbed positive response does not include a captured HTTP exchange or claim a measured status code. It models the payload shape needed to exercise the client branch. Preserve its continuation before applying any policy that might discard or summarize the response.
Q1's diagnostic PASS means the bad implementation made exactly the bad decision the test expected. The harness queues only the first response for this case, so assert_no_pending_responses() can succeed. It does not establish that the logical partition traversal was exhausted; the later baseline pages were never queued for Q1.
That arrangement makes the control useful without misrepresenting it. The test proves the defect remains observable, while Q3 separately qualifies the repaired path. Removing Q1 because it looks green would remove a concrete demonstration of why green test status and production acceptance are different judgments. The reviewer must read the expected outcome, not just the final status label.
Reject short-page termination as a substitute
Q2_short_stop starts with ExclusiveStartKey=key('02'), so it tests the middle baseline response directly. It receives one match, 03, after two evaluated items, and retains key 04. The request still says Limit=2. The deliberately flawed comparison sees Count < BASE['Limit'] and returns complete=True, reason WRONG_SHORT_STOP, after one successful page.
This is not an improvement on Q1. Replacing an empty-list test with a short-page test merely changes which valid filtered response is misclassified. In the authored chain, the remaining terminal response returns 05. The complete suffix expected from key 02 is therefore ['03','05'], not ['03'].
The comparison mixes different units. Count measures returned matches; the request limit bounds evaluation. In this response the evaluation budget was fully represented by ScannedCount=2, while returned membership was only one. That difference is intentional in the fixture and consistent with the documented filtering order. No application rule permits rewriting it as exhaustion.
Do not repair this control by changing the expected answer to one item, increasing the limit until this fixture fits in one response, or padding Items with invented records. Those changes would remove the symptom without qualifying the stopping predicate. The acceptance rule must survive the authored empty and short pages under the original request.
For the same reason, requesting a larger limit is not a substitute for reviewing continuation. This playbook does not build an endpoint that fills a requested application page size, nor does it optimize read cost. It establishes the narrower prerequisite that a response's membership count must not impersonate the traversal's end marker.
Q2 is another diagnostic PASS with a REPAIR design decision. Preserve its starting key in the evidence so reviewers do not mistake the case for a first-page traversal. Its retained key 04 identifies the exact unconsumed next boundary, making the missing 05 a traceable fixture omission rather than a vague suspicion about row counts.
Follow the returned key through to exhaustion
Q3_complete runs the repaired collector with all three baseline responses queued. The first request contains BASE and no start key. After the empty response it sends the same base request plus key 02. After receiving 03, it sends the same base request plus key 04. Only the terminal response returning 05 removes continuation.
The observed result is complete=True, reason END, ordered keys ['03','05'], three successful pages, and cursor=None. Summing the retained response counters gives Count=2 and ScannedCount=5. These sums describe this authored chain only; they are neither database-wide totals nor live read-capacity measurements.
Require the exact next request
AWS's Query pagination procedure carries the returned key into ExclusiveStartKey. The harness makes that handoff inspectable: each queued expectation contains the table, key expression, filter, attribute maps, traversal direction, consistency flag, and limit. A call to the correct method with an incorrect continuation is not an acceptable substitute.
The collector retains each successful request and a copy of its response before checking termination. Consequently, the final list can be traced to individual pages rather than trusted in isolation. Match 03 belongs to the response requested after 02; match 05 belongs to the response requested after 04. That lineage matters even when a coincidental count would look correct.
The bounded decision is ACCEPT_BOUNDED: the admitted request remained unchanged, expected pages were consumed, the terminal response was observed, and returned membership matched the independent oracle. This is stronger than simply observing no exception but narrower than certifying a production export system. In particular, expected requests and actual requests both use BASE; changing that shared constant still requires review against the frozen application contract, not blind trust in another green run.
Allow an empty terminal result without inventing matches
Q4_no_later_match changes the authored response profile deliberately. It starts with an empty response that evaluated two items and retained key 02. The second response is also empty, evaluates one item, and supplies no continuation. The collector follows the key and ends with no matches, two successful pages, complete=True, and reason END.
Q5_empty_terminal is simpler: its first response has no items, Count=0, ScannedCount=0, and no continuation. The repaired collector finishes after that single page. These are independent no-match controls, not altered versions of the five-item baseline whose oracle contains 03 and 05.
The distinction resolves a common overcorrection. Once a team learns that an empty page can hide later matches, it may start treating every continuation as a promise of future matching data. AWS's continuation guidance does not support that promise. Q4 exercises the correct client behavior when further traversal is necessary but produces no additional matches.
Q5 prevents the opposite mistake: demanding another request after an already terminal empty response. There is no next boundary to follow in this case. Inventing one from a presumed key ordering would no longer be the documented handoff and would violate this fixture's exact request expectations.
Both controls qualify for ACCEPT_BOUNDED under their explicitly empty oracles. An application's separate required-data rule may still reject an empty export. Report that as a policy outcome after complete traversal, not as evidence that pagination failed. The same empty list can therefore accompany very different conclusions depending on continuation and the approved membership requirement.
Keep write-side existence conditions in their separate role. A rule that governs whether a write may proceed does not establish that this read traversed its full response chain. This laboratory performs no writes and does not use a write predicate to decide absence. The useful evidence remains the selected query, every accepted continuation handoff, the terminal response, and the oracle appropriate to the case.
Return a partial result when the page budget expires
Q6_budget invokes collect() with an application page budget of one. The first baseline response contains no matches and retains key 02. The loop uses its permitted iteration, then returns complete=False, reason PAGE_BUDGET, an empty items list, the recorded page, and that pending cursor. This is RETURN_PARTIAL, not an unsuccessful attempt to manufacture a complete empty result.
Keep the two limits visibly separate. BASE['Limit']=2 belongs to each modeled Query request. The collector argument limit=1 bounds application-loop iterations. The code uses a compact name, so call sites and review notes should identify it as the page budget rather than implying a one-item service limit.
A bounded loop may correctly return incomplete
The page cap limits this loop's iterations. It supplies no wall-clock deadline, network timeout, or assurance about arbitrary work inside a call. This experiment has no network activity, and its timestamps must not be repurposed as evidence for those untested properties.
The output envelope is the repair. Preserve partial items even when empty, complete, reason, the pending key, and the successful request/response ledger. Consumers that flatten the envelope into an ordinary list cannot distinguish Q6 from Q5, despite their opposite completeness states. That is an API-boundary defect even if the underlying loop is correct.
The boundary is also exact: the next admitted request must begin from key 02, not from the last returned item, because this page returned none. The explicit Q6 assertion checks that the cursor survives. The rest of the response remains available to explain why the export paused before any matches appeared.
Do not issue ACCEPT_BOUNDED simply because a cap was configured and respected. That proves bounded work, not exhaustion. Conversely, do not discard the partial record merely because it is incomplete. The evidence is sufficient to support a controlled continuation under the same contract, provided the receiving application preserves the relationship between the saved boundary and the work already recorded.
Termination is checked inside each iteration before the budget return. By inspection, a terminal response on the final permitted iteration can return END; merely using every allowed iteration does not force PAGE_BUDGET. Q6 specifically supplies a nonterminal response, so its observed partial result depends on the surviving key, not on the iteration count alone.
Resume only under the same query contract
Q7_resume_same_request supplies key 02 as the collector's starting cursor and queues the remaining two baseline responses. Stubber checks the first resumed request against unchanged BASE plus that key, then checks the next request with key 04. The result contains 03 and 05, two successful pages, complete=True, reason END, and no remaining cursor.
This qualifies a local suffix traversal. Q7 is a separate case initialized with an explicit key; it does not load Q6's saved report or implement a durable resume service. Reviewers can reconcile Q6's one-page prefix with Q7's two-page suffix against the baseline, but the supplied driver does not persist and merge those runs automatically.
For an application resume design, bind the cursor to the table identity, partition selection, filter and expression maps, direction, consistency choice, and the request policy approved for that traversal. This fixture freezes the service limit too. A different request may still be syntactically valid, but it has not been qualified as continuation of the original selection.
A conceptual durable envelope should also identify the helper revision, cursor format, accumulated evidence, and the accepted mutation policy. Those are proposed design requirements, not implemented features of this code. The harness neither authenticates a public cursor nor establishes deployment-safe persistence, and its seen set starts afresh on each invocation.
Treat an unreviewed filter or ordering change as HOLD for the old traversal. Restarting under an approved new specification is a new run with a new meaning. Do not quietly attach its rows to an earlier export and retain the earlier completion narrative.
Keep vector retrieval as a different API outside this cursor contract as well. Shared storage does not make one operation's continuation evidence portable to another retrieval workflow. The useful learning connection is the need to name the actual API and its acceptance boundary, not to import unrelated retrieval or migration tests into this base-table Query laboratory.
Preserve incomplete evidence on errors and stalled progress
Q8_repeated_cursor authors two empty responses that both return key 02. The second request correctly uses 02, but the second response offers the same continuation again. The collector retains both observations, detects the repeated serialized key, and returns complete=False, reason REPEATED_CURSOR, two successful pages, and the repeated cursor. The decision is HOLD.
This is a malformed-progress control authored for the client, not an observation that AWS emitted such a loop. Because the check occurs after recording the response, the ledger preserves the page that exposed the repetition. The copied pages show what the client saw; their counters do not establish unique evaluated items across stalled progress.
The guard recognizes repeated serialized cursors in the admitted string-key representation. It is not a general proof that every distinct key represents valid forward progress. Nor does it deduplicate accumulated items. Preserve that narrow interpretation: Q8 demonstrates a detected repetition, while broader key-order and membership validation would need separate cases rather than an enlarged claim about this one.
Q9_error_after_page instead queues the first baseline page followed by a modeled ClientError with code InternalServerError and HTTP status 500. The collector catches that exception, records reason SERVICE_ERROR and the error code, and returns incomplete. The successful page count remains one; the failed next call is not appended as a successful response.
Keep the last usable boundary on failure
The pending cursor remains key 02, the boundary used to start the failing next request. Q9 explicitly checks its preservation. The result retains the earlier successful request/response pair and the error code, not a full serialized exception or every error-response field. Describe exactly that evidence rather than claiming a complete failure transcript.
The modeled error is injected through Stubber's error queue. It is not a throttle experiment or a test of live SDK retry behavior. SERVICE_ERROR preserves uncertainty; it must not become a complete empty result merely because the first successful page contained no matches.
Other unexpected exceptions are not converted by collect() into this result shape. Failed require() checks raise RuntimeError; setup, request-validation, or unexpected SDK failures can make the outer run fail. Where execution reaches the report-writing path, inspect its recorded exception type and message. Preserve the failure instead of broadening the catch until it returns an apparently successful list. Recovery needs a reviewed boundary and a reason, not silence around the original defect.
Reconcile every page and every test case
Use the matrix as a case inventory, then inspect the retained page ledger. None in the start column means no ExclusiveStartKey; other entries abbreviate its sk component while retaining pk=T in the actual requests. Page counts include only successful responses.
Case | Start sk | Returned IDs | Complete | Reason | Pages | Decision |
Q1_empty_stop | None | [] | True | WRONG_EMPTY_STOP | 1 | REPAIR |
Q2_short_stop | 02 | 03 | True | WRONG_SHORT_STOP | 1 | REPAIR |
Q3_complete | None | 03, 05 | True | END | 3 | ACCEPT_BOUNDED |
Q4_no_later_match | None | [] | True | END | 2 | ACCEPT_BOUNDED |
Q5_empty_terminal | None | [] | True | END | 1 | ACCEPT_BOUNDED |
Q6_budget | None | [] | False | PAGE_BUDGET | 1 | RETURN_PARTIAL |
Q7_resume_same_request | 02 | 03, 05 | True | END | 2 | ACCEPT_BOUNDED |
Q8_repeated_cursor | None | [] | False | REPEATED_CURSOR | 2 | HOLD |
Q9_error_after_page | None | [] | False | SERVICE_ERROR | 1 | HOLD |
Q7's acceptance covers the suffix and requires the prior prefix for whole-export reconciliation. Q1 and Q2 display the intentionally incorrect flags, not endorsed completion claims. In both recorded runs, every row passed its diagnostic expectation, including those unacceptable designs.
For Q3, confirm request starts None, 02, 04; response continuations 02, 04, None; ordered matches 03, 05; and totals of two returned and five evaluated items. Do not replace those independent checks with a row count of two. Duplicate or incorrectly selected rows could satisfy a count without satisfying this manifest.
Require all nine exact IDs, the recorded source digest, and consumed stub queues. Unexpected calls or parameter mismatches invalidate the run rather than extending its coverage. These are API-specific continuation markers, not interchangeable tokens borrowed from a different operation. Reconciliation must use the Query request and response fields actually retained here.
The checkpoint comparison does not assert every retained response field automatically. Final cursor review across the matrix and counter reconciliation supplement the explicit assertions; Q6 and Q9 have dedicated cursor checks. A reviewer should therefore inspect result['pages'] and result['cursor'], not infer those properties solely from the four-element observed array. Keep the distinction between executable assertions and additional evidence review visible when adopting the test.
Keep traversal completion separate from a snapshot
The fixture sets ConsistentRead=True on every request, and the DynamoDB read-consistency documentation permits strongly consistent base-table reads. It describes current committed data and also explains that read-committed isolation does not prevent an item from changing immediately after a read. The documentation does not turn these separate calls into one frozen multi-call view.
Strong reads do not freeze the whole traversal
The practical inference is limited but important: observing every continuation boundary is not evidence that all pages describe the same immutable database state. The first, second, and third calls remain separate observations. A correct continuation loop solves the early-stop defect; it does not, by itself, solve a workload's requirements about changes during traversal.
This laboratory avoids that additional variable entirely. Its response dictionaries are authored before collection, and no table exists for another process to modify. The fixed oracle is justified by fixture design, not by a claim that strong reads force any live partition to match a static list throughout an export.
A data owner must separately approve what membership means for changing data: which records belong to the requested export, what changes are admissible while it is collected, and when a restart is required. Those requirements may be stricter than observing a terminal response. Record them without pretending this code implements them.
A future owned-environment integration test would need its own selection, mutation, and reconciliation plan. No such test has been executed here. These local runs therefore supply no observation of a concurrent writer and no evidence that live pages share a snapshot.
Accordingly, ACCEPT_BOUNDED approves this client behavior under its frozen inputs. It is not an assurance that an arbitrary live export is complete as of one instant. Naming the missing acceptance layer is more useful than weakening the conclusion about the layer the fixture actually tested.
Assign ownership and recover incomplete exports
The client owner is accountable for continuation handoff, bounded iteration, accurate reasons, and retained evidence. The API owner is accountable for exposing partial results without disguising them as ordinary complete lists. The data owner is accountable for required membership, acceptable missingness, and any snapshot requirement. A green collector test cannot substitute for those separate decisions.
When an export has already been labeled complete by an empty-page or short-page rule, preserve its evidence before replacing it. Keep the source revision, original request specification, observed pages, returned keys, and any surviving continuation. Where the wrapper discarded that information, record the gap rather than reconstructing a cursor from guesses about key order.
1. Classify the stopping reason. Treat Q1/Q2 behavior as REPAIR, an intact page-budget boundary as RETURN_PARTIAL, and a service error, stalled cursor, or unreviewed query change as HOLD. Do not let identical empty lists erase those different states.
2. Choose recovery against the retained contract. Resume only from a usable boundary under the approved query identity and mutation policy. Otherwise, plan a new traversal, identify it separately, and preserve the earlier incomplete export for comparison.
3. Reconcile before promotion. Check the full prefix and suffix, or the complete new run, against the application's acceptance criteria. Update the export status only after reviewing terminal evidence and required membership together.
A resumed invocation's local page count is not the total work already performed. Preserve enough run linkage to distinguish a one-page prefix plus two-page suffix from an unrelated two-page traversal. Likewise, rebuilding under a changed filter is not proof that the earlier export was complete. It is a new selection with a new evidentiary history.
The broader connection to endpoint performance and completeness is the contract at the boundary: a resource budget can be reasonable while its output remains partial. This article makes no latency improvement claim. Returning less work quickly is not a success criterion when the response misstates what was covered.
Before promoting a repair, have one reviewer trace Q1 and Q2's false flags, another trace Q6 and Q7's shared boundary, and the responsible data owner approve the real workload's membership policy. That review separates loop correctness, API representation, and data acceptance instead of concentrating all three judgments in a single PASS label.
Develop cloud-client judgment through structured practice
The durable repair is not a larger page size or a more optimistic empty-result message. For this low-level filtered Query contract, never use returned-page size alone as the end predicate. Follow the returned continuation under the fixed query, report partial work when the budget ends, and preserve incomplete evidence when the client cannot establish safe progress.
In this laboratory, Q3 establishes the complete baseline, Q4 and Q5 allow legitimate empty terminal results, and Q7 qualifies the same-request suffix. Q1 and Q2 remain designs to repair. Q6 remains partial, while Q8 and Q9 remain on hold. The distinctions are the result, not administrative labels added after the rows have been returned.
For broader foundations and project practice, explore Refonte Learning's Cloud Development program. Its page lists three months at 12–15 hours per week, with cloud application, CI/CD, and container project work. That is a foundation-oriented learning option, not a promise that this specific DynamoDB or Stubber laboratory is a named curriculum module.
Carry the same discipline into the next client review: name what the API returned, what the application evaluated, what evidence establishes completion, and what remains unresolved. A useful cloud application must preserve those differences even when its first successful response contains no matching items.
