Repository navigation
fix(streams): resolve the shard against its stream inside the GetRecords read - #543
Conversation
…rds read GetRecords called validate_shard before get_stream_records, which added two catalog queries to every poll on top of the account ownership guard inside the read. get_stream_records now takes the iterator's stream ARN and resolves the shard against the table that ARN names, scoped to the caller's account, in the same query as the records read (SQLite), or in one catalog lookup plus one data query (PostgreSQL, MongoDB), or one catalog lookup plus the shard row and records read issued together (Cassandra). Whether the shard belongs to the stream the ARN's label names is decided in one function, extenddb_storage::util::shard_in_stream, from the table's current label. A shard of another table, another account, or an earlier stream label on the same table gets the stream not-found answer validate_shard gave. The authorization decision is untouched: crates/server does not change. Storage statements per GetRecords call, measured on an idle shard and on a shard with records: SQLite 4 to 1, PostgreSQL 5 to 2, MongoDB 5 to 2, Cassandra 4 to 3 (two sequential round trips instead of four; idle p50 2.1 ms to 1.6 ms over loopback). GetRecords refuses a Limit under 1 with "1 validation error detected: Value at 'limit' failed to satisfy constraint: Member must have value greater than or equal to 1" and over 1000 with "One or more parameter values were invalid: Limit should be less than or equal to 1000" before the read (both measured on the service 2026-10-25; before, 0 reached storage and 1001 was clamped). MongoDB streams the page one record per document, so a page over 16 MB reads instead of answering InternalServerError. Every streams path answers one stream not-found sentence, without a trailing period, as measured on DescribeStream. validate_shard stays for GetShardIterator. A GetRecords token with no stream ARN is answered as expired by the engine, as the authorization layer already does; in a dev-mode build, where authorization does not classify the token, such a token with a real shard id read before and is expired now. Tests: seven SQLite storage tests, three unit tests on shard_in_stream, one engine test on the Limit bounds, storage-level ownership tests on PostgreSQL, Cassandra and MongoDB (same-label other table, same-named other account, an earlier label on the same shards; a 40 by 450 KB page on MongoDB, inserted out of order so the read's sort is what the assertion sees), and three wire tests under tests/python. The MongoDB storage tests run in the backend's CI job against its container. Closes #438
1dc7d5a to
f40cc42
Compare
The data read joined the shard row to its records and put the ORDER BY and LIMIT after the join. The planner does not treat the join output as ordered by the record sequence number, so it fetched every record of the shard after the bound and sorted them before the LIMIT applied. A call cost time in proportion to the unread backlog of the shard instead of the page size, and held a data pool connection for that time. The records now come from a LEFT JOIN LATERAL subquery that carries its own ORDER BY and LIMIT. The planner reads the primary key (shard_id, sequence_number) in order and stops after LIMIT rows. The outer query keeps the result shape: a shard of the named table with no record to read returns one row with a NULL record, and a shard of another table returns no row. The statement text moves to stream_records_sql.rs so the engine and the plan test run the same text. Measured on PostgreSQL 16.15 with the shipped schema and one shard of 100,000 records of about 1 KB, in a table of 299,000 stream records (EXPLAIN ANALYZE, the second of two runs): first page of 1000 join 102.7 ms, 22,580 buffers lateral 0.56 ms, 158 buffers page after sequence 50000 join 53.0 ms lateral 0.85 ms generic plan, first page join 81.0 ms lateral 1.49 ms drain in 101 pages join 5,183 to 5,230 ms lateral 142.6 to 143.6 ms The drain figures are three runs each. The read before the shard check existed took 108.5 to 111.3 ms for the same drain. Three tests join stream_shard_ownership.rs. The paging test reads five pages in turn and expects every record once, in sequence order. An idle shard reads as an empty page, not the not-found error, with and without a sequence bound. The plan test runs EXPLAIN (ANALYZE) on both statements, in the plan chosen for the parameters and in the generic plan, from the start of a shard of five pages and after its second page, and expects the scans of stream_records to visit no more rows than the page holds.
DescribeStream and GetShardIterator answer a stream ARN that names no stream of the caller's account with different sentences. Measured against DynamoDB in us-east-1 on 2026-10-24, with the last three digits of the stream label of an existing table changed, twice with identical answers: DescribeStream Requested resource not found: Stream: <arn> not found GetShardIterator Requested resource not found: Stream does not exist The first has no trailing period. On main every backend answers all three operations with "Stream: <arn> not found." and the period. The shared helper added earlier in this series moved all three to the DescribeStream form, which also changed GetShardIterator and GetRecords. The sentences are now set per operation: DescribeStream, unknown stream ARN: "Stream: <arn> not found", no period. GetShardIterator, unknown stream ARN: "Stream does not exist". GetRecords, unknown stream ARN, or a shard that is not the stream's: "Stream does not exist". GetShardIterator, a shard that is not the stream's: unchanged, "Stream: <arn> not found." with the period. The service answers it with "Shard does not exist" (measured 2026-10-24); that sentence is not part of this change. The GetRecords sentence is not measured. The service's iterator tokens are opaque, so a token that names an unknown stream, or pairs a stream with another stream's shard, cannot be built against it. GetRecords uses the GetShardIterator sentence for both. storage::util gets stream_does_not_exist and shard_not_in_stream beside stream_not_found, each with a unit test on its text. The four backend ownership tests compare a GetRecords answer with the sentence itself, where they compared it with the helper's own output. The engine test maps the new error to a ResourceNotFoundException. tests/python/test_streams.py sends DescribeStream, GetShardIterator and GetRecords to an unknown stream ARN of an existing table and compares the code and the message of each.
The doc on StreamEngine::get_stream_records and the comment in handle_get_records said the shard is resolved in the same round trip as the records. Only SQLite does that. PostgreSQL, MongoDB and Cassandra read the table first. Both texts now say so. A token with no stream ARN is answered by the authorization layer as expired when it carries a timestamp and as an invalid iterator when it does not. The engine answered expired for both, so a developer-mode build, which skips the layer, answered a token with fewer than four fields differently from the other builds. no_stream_arn_error gives the engine the layer's answer. A unit test runs a two-field token, a three-field token, a four-field token and a four-field token with an unparseable timestamp through both the engine and classify_shard_iterator and requires the same class. The comments in request_helpers.rs and tests/test_authz_resource_arn.py said the engine confirms the shard belongs to the stream before reading. They now say the storage read resolves the shard against the stream. docs/dynamodb-limits.md lists the GetRecords limit as enforced and gives the two sentences (measured 2026-10-25): a Limit under 1 answers the constraint sentence for 'limit', and a Limit over 1000 answers "Limit should be less than or equal to 1000". The summary counts, the list of unenforced limits and the GetRecords row of docs/technical-debt.md no longer list it as a gap.
The test that reads an old shard iterator after a disable and an enable had a second branch for a re-enabled stream with a new ARN. No backend gives a re-enabled stream a new ARN yet, so that branch could not run anywhere. It also could not pass: the shard is resolved against the stream the iterator names, so the old iterator answers "stream does not exist" where the branch expected a 200 with none of the new keys. The test now covers the state that exists. The table keeps its ARN, and the old iterators read across the four shards exactly what fresh iterators read. An assertion states that precondition, so a backend that starts issuing a new ARN fails with that message, and a comment names the change that makes the other state reachable. The module docstring says the storage-level tests cover an iterator whose ARN is no longer the table's current stream.
The GetRecords aggregation starts from the shard document, joins the shard's
records, and keeps the shard document when no record follows the bound. The
read dropped every output document without record_data to skip that
shard-only document, so a record document that lacked record_data was dropped
too and read as an idle page. Before the records were joined in the
aggregation, the read answered Internal ("Missing record_data") for it.
The final $project now emits has_record: true when a record was joined and
false for the shard-only document. records_from_docs (stream_record_docs.rs)
skips the documents with has_record false and answers Internal when a
document with has_record true carries no record_data. The conversion moves
into that file, which takes stream_engine.rs from 817 to 814 lines.
Three unit tests cover the shard-only document, joined records in order and
the missing field, and need no server. Two live tests run against MongoDB
7.0.45. One writes a good record and a record document without record_data,
reads the first page, which returns the good record, and expects the next page
to answer Internal. The other reads a shard that never had a record and a
shard past its last record, and expects an empty page for both.
crates/storage-sqlite/src/stream_tests.rs and tests/python/test_stream_shard_ownership.py started with the SPDX line only. The files beside them put "Copyright 2026 ExtendDB contributors" above it, and both files now do the same.
No test failed when the ORDER BY inside the records subquery was removed: a shard written in sequence order returns the right page in physical order too. The new test writes ten records with the highest sequence number first and reads a page of three from the start and after sequence 2, expecting 1, 2, 3 and 3, 4, 5. With the clause removed the first page is 8, 9, 10. The test goes in its own file because stream_shard_ownership.rs is at 587 lines, and the PostgreSQL CI step lists it. Two comments and one list follow the code: a token without a timestamp is invalid (earlier builds did issue such tokens), the shard iterator lifetime and tag rows of the unenforced-limits list are numbered 7 to 9 after the GetRecords row left it.
robinnsc
left a comment
There was a problem hiding this comment.
Approving: nothing here blocks. The ownership check holds up. Forged iterators on the SQLite, PostgreSQL and MongoDB release binaries (another table's shard, another account, an older or near-miss label, a deleted and recreated table, a table with no stream, swapped shards on every iterator type) gave the same answers as main apart from the intended sentence change. Dropping the account, the table name, the shard's table id or the label from the predicate failed a test on each of the four backends. The statement counts also matched what we logged: PostgreSQL 5 to 2 and MongoDB 5 to 2, plus SQLite 4 to 1 from reading the code. A few things in the description:
- The #515 merge notes look out of date.
git merge-tree --write-treeagainst #515's head (5f22dc4) gives 9 conflicted files and 19 hunks. The extra file iscrates/storage-postgres/src/lib.rs, from the LATERAL commit (pub mod stream_records_sqlnext to #515'smod stream_lifecycle). One of the four Cassandra hunks is indescribe_stream, notvalidate_shard. After resolving the textual conflicts it still won't compile: both sides add astream_table_rowmethod onCassandraEnginewith different return types (stream_engine.rs:68here,stream_lifecycle.rs:387there), so one needs renaming. The merged tree would also hold two copies of each not-found sentence (util::*andstream_lineage::*). - On main's MongoDB,
Limit: 0didn't answer a 500.limit(0)means no limit, so the base binary returned the whole shard (3,753 records in our run). The fix is the same, but main's behaviour was worse than described. Similarly, in dev mode main read a token with no stream ARN with only the account check, which this PR now refuses; that might be worth a line. - Main's records statement is still a bit faster per full page in database time than the LATERAL form (see the inline comment), which the plan table doesn't show because it compares against the first commit's join form. Over the network, the saving is the three removed round trips.
- A few counts have moved on: engine 182, storage 134, SQLite 151, MongoDB 125 plus 6 live, and
test_streams.py16. There are also a couple of pasted fragments ("No dependency change.rs` 1024 to 1030 ...", "loop.The stream", "lifecycle.The re-enable") and a bullet that lost its marker in "Not in this PR". - Refusing
Limitover 1000 matches the service, but the botocore model has no maximum, so a client written against ExtendDB alone that sends 1001 or more now gets a ValidationException where main clamped. The Kinesis adapter clamps to 1000 itself. Probably worth a release note.
Separately from this PR: on PostgreSQL a consumer can skip records written by concurrent writers, because the sequence comes from nextval('stream_seq') inside the write transaction (data/tx_helpers.rs:340-344), so a later sequence can commit first and the reader moves past the earlier one. With 8 BatchWriteItem threads and a LATEST reader per shard, 0 to 11 of 8,000 records were skipped on the base binary as well as this branch. The records are in stream_records, so a fresh TRIM_HORIZON read finds them. It's outside #438, so it probably wants its own issue.
| ORDER BY sequence_number LIMIT $3 \ | ||
| ) r ON true \ | ||
| WHERE s.shard_id = $1 AND s.table_id = $2 \ | ||
| ORDER BY r.sequence_number"; |
There was a problem hiding this comment.
This outer ORDER BY adds a Sort over the page the LATERAL subquery already returns in key order. On a 100k-record shard, a page of 1000 took 2.2 ms in EXPLAIN against 1.2 ms without it and about 1.1 ms for main's records statement, or roughly 10.3 ms against 8.7 ms client side. It's invisible behind the ~85 ms the server spends per page, and an idle or Limit-1 poll sorts one row, so this is optional. The ordering is needed for a guarantee, since NextShardIterator comes from the last record, but nothing pins it: removing it from both statements still passes every PostgreSQL test, because the nested loop happens to emit in order. If you want the database time back, one option would be to drop it and sort records by dynamodb.sequence_number in Rust (the values are fixed width), with a test that would fail on an unordered page. Keeping it and adding such a test would also be fine.
| }; | ||
|
|
||
| let limit = input.limit.unwrap_or(1000).min(1000); | ||
| let limit = get_records_limit(input.limit)?; |
There was a problem hiding this comment.
Small test gap: replacing this call with main's unwrap_or(1000).min(1000) still passes the engine tests, because get_records_limit_bounds_carry_the_measured_sentences calls the function directly. Only tests/python/test_stream_shard_ownership.py covers the wiring. A unit test through handle_get_records with Limit: 0 would pin it on the Rust side.
Summary
Since #419,
GetRecordscalledvalidate_shardbeforeget_stream_recordsso an iterator could not pair an allowed stream ARN with another stream's shard. That added two catalog queries to every poll (#438), and stream consumers poll idle shards in a loop.The stream not-found sentences are set per operation; see "Stream not-found sentences" below.Storage statements per
GetRecordscall, counted from each backend's statement log (SQLitesqlxdebug log, PostgreSQLlog_statement = 'all', MongoDB profiler level 2, Cassandra full query log), idle shard and shard with records:mainstream_shards JOIN tables LEFT JOIN stream_records)find, 1 dataaggregate)tables, then the shard row and the records issued together)Cassandra idle
GetRecordsover loopback, 200 calls alternating binaries in the same minute: p50 2.11 to 2.14 ms onmain, 1.55 ms on this branch; p99 3.74 to 5.59 against 2.33 to 2.52. SQLite and MongoDB p50 fell 0.15 ms and 1 ms with p99 not worse.The ownership predicate
Account and table name from the iterator's stream ARN select the table (SQLite: in the join; PostgreSQL:
SELECT table_id, stream_label FROM tables WHERE account_id = $1 AND table_name = $2; MongoDB:find_oneon_id; Cassandra: a primary-key read on(account_id), table_name, wheremainfiltered on the label withALLOW FILTERING). The data read keys the shard ontable_id. Whether the shard belongs to the stream the ARN's label names is decided in one Rust function,extenddb_storage::util::shard_in_stream(table_id, shard_id, table_label, arn_label), todaytable_label == Some(arn_label); a table with no stream owns no shard. A shard of another table, another account, or an earlier label on the same table answers the stream not-found sentencevalidate_shardgave. Zero rows from the data read means the shard is not that table's; one row without a record is the idle shard; the refusal no longer depends on the row count (the shard id is selected alongside the records).The predicate is pinned at storage level on every backend, each test asserting both directions so a lookup that dropped the name or the account cannot pass by returning whichever table row comes first: SQLite (
stream_tests.rs, 7), PostgreSQL (tests/stream_shard_ownership.rs, 3), Cassandra (tests/direct/stream_ownership.rs, 3), MongoDB (stream_records_live_tests.rs, 4). Dropping the account, the table name or the label from the predicate fails the named test on each backend.Limit bounds
GetRecordsnow refusesLimitunder 1 with1 validation error detected: Value at 'limit' failed to satisfy constraint: Member must have value greater than or equal to 1and over 1000 withOne or more parameter values were invalid: Limit should be less than or equal to 1000, both measured on the service with SDK parameter validation off (mainletLimit: 0reach storage, where it answered a 500 on MongoDB, and clamped 1001 to 1000). The check runs in the engine before any storage call; theconstraint(member, rule)helper is written in the same shape and place as #515's so the two merge.MongoDB page size
An earlier form of this change on MongoDB put the page in a
$lookuparray on the shard document, which made a whole page one BSON document under the 16 MB cap: 1000 records of about 16 KB (an 8 KB item underNEW_AND_OLD_IMAGES, the defaultLimit) answered 500 on every call and the consumer could not advance. The aggregation now follows the$lookupwith$unwindand$project, which MongoDB coalesces so the array is never materialized and each record is its own document again, bounded byLimit. Pinned by a test that reads 40 records of about 450 KB from one shard (inserted in descending order so the$sortis what the assertion sees); without the$unwindit fails withBSONObjectTooLarge. The MongoDB storage tests run in themongodb-rustCI job against the job's container (devtools/run-mongodb-tests).Streams after a disable and re-enable
Measured on the service (
GetShardIteratoron S1, disable, re-enable, write,GetRecordswith the S1 iterator): the re-enable mints a new stream ARN; the old stream isDISABLEDwith every shard closed; an iterator issued before the disable reads its own records to the end (NextShardIteratornull) and none of the writes after the re-enable;GetShardIteratoron the old ARN still answers 200. Today no backend clearsstream_labelon disable and the re-enable keeps it, so an old iterator keeps reading the one stream andLatestStreamArndoes not change; PR #515 changes that lifecycle.The re-enable test runs one state; see "Re-enable test" below.Tests
tests/python/test_stream_shard_ownership.py(3): an iterator naming an allowed stream with another stream's shard reads nothing; the disable and re-enable case above;Limit0 and 1001 refused with the sentences above (raw client). On this branch's release binaries: 3 passed on SQLite (dev mode), PostgreSQL, MongoDB and Cassandra, withtest_streams.py15,tests/test_authz_resource_arn.py43 andtests/test_stream_table_reuse.py4 (65 per backend).get_records_limit_bounds_carry_the_measured_sentences), storage 128 (3 onshard_in_stream), SQLite 148 (7 instream_tests), MongoDB 122 plus 4 live, Cassandra directstream_ownership3 andstreams::14.cargo fmt,clippy1.97.0 with-D warningsacross the workspace, 1.88.0 build: clean. No dependency change.rs1024 to 1030 andcrates/storage-cassandra/tests/direct/streams.Merge notes
fix/482-stream-disable-closes-shards): eight files, eighteen hunks.crates/engine/src/streams.rs: two hunks beside the sharedconstrainthelper (which merges), take fix(streams): disabling a stream closes its shards; enabling makes a new one #515's constants. The SQLite, PostgreSQL and MongoDB stream engines: theuseline (keep both imports) and thedescribe_streamandvalidate_shardcatalog reads (take fix(streams): disabling a stream closes its shards; enabling makes a new one #515's bodies);get_stream_recordsmerges on all three. Cassandrastream_engine.rs: theuseline, theget_stream_recordsbody (merged form: this side'sstream_table_row, fix(streams): disabling a stream closes its shards; enabling makes a new one #515'sshard_rangeextended to return the row'stable_idas anOption, the compare, then fix(streams): disabling a stream closes its shards; enabling makes a new one #515's bounded read), and the twovalidate_shardhunks (fix(streams): disabling a stream closes its shards; enabling makes a new one #515'sstream_lineage::validate_shardsurvives).storage-sqlite/src/lib.rsandstorage-cassandra/tests/direct_integration.rs: adjacentmodlines, keep both..github/workflows/integration.yml: one--testlist, keep both. After the mergeshard_in_stream's body becomes fix(streams): disabling a stream closes its shards; enabling makes a new one #515'sparse_shard_idrule (Labelled { label }against the ARN label,Legacyagainst the table's current label); that function and its three unit tests are the one place to change.fix/367-pg-nul-key-schema-names): clean.Not in this PR
stream_labelwhenUpdateTabledisables the stream, and the re-enable keeps it when shards exist (SQLiteupdate_table.rs, PostgreSQLupdate_table.rs, MongoDBtable_engine.rs), soLatestStreamArndoes not change across a disable and re-enable where the service mints a new stream. That is Disabling a stream does not close its shards or change its status #482, fixed by fix(streams): disabling a stream closes its shards; enabling makes a new one #515.GetShardIterator on a shard that is not the named stream's keeps the text
mainhad,Requested resource not found: Stream: <arn> not found.; the service answersShard does not exist(measured 2026-10-24), and that difference is outside this PR.Invalid stream ARN format: <arn>where a non-dev build answersInvalid ShardIteratorfrom the authorization layer; unchanged by this branch.extenddb versionreports the commit of the first build in a target directory (crates/bin/build.rsis not rerun on a worktree HEAD change).Closes #438
PostgreSQL read bound
The PostgreSQL statement that reads a page of records joined the shard row to its records and applied
ORDER BY r.sequence_number LIMIT nafter the join. The outer side of the join is one row, but the planner does not use that to treat the join output as ordered by the record's sequence number. It read every record of the shard after the sequence bound, sorted them, and applied theLIMITto the sorted rows. A call cost time in proportion to the unread backlog of the shard instead of the page size, and held a connection of the data pool for that time.The records now come from a
LEFT JOIN LATERALsubquery that carries its ownORDER BY sequence_number LIMIT n. The planner reads the primary key(shard_id, sequence_number)in order and stops afternrows. The outer query keeps the result shape: an idle shard of the named table returns one row with a NULL record, and a shard of another table returns no row. The statement text is incrates/storage-postgres/src/stream_records_sql.rs, which the engine and the plan test both use.The measurements ran on PostgreSQL 16.15 in a container, with the shipped schema and one shard of 100,000 records of about 1 KB in a table of 299,000 stream records. The statements were run through
psqlwith the text copied from the code, not through the server binary. The plan rows areEXPLAIN (ANALYZE)runs, each repeated once, and the second run is quoted. The drain row is three runs of a loop inside one database session, so it leaves out network and pool acquisition time.LATERALformThe read on
main, which looked the shard up in separate statements before reading its records, drained the same shard in 108.5 to 111.3 ms, measured the same way (three runs). The join form and theLATERALform returned the same pages in the same order for six sequence bounds; an md5 of the page bytes matched for each.Four PostgreSQL storage tests are added, three in
crates/storage-postgres/tests/stream_shard_ownership.rsand one beside it:five_pages_read_every_record_once_in_sequence_orderreads five pages in turn and expects every record once, in sequence order.an_idle_shard_reads_an_empty_page_with_and_without_a_boundexpects an empty page, not the not-found error, from a shard with no record to read.a_page_is_in_sequence_order_when_the_records_were_written_in_reverse(its own file,stream_records_page_order.rs) writes ten records with the highest sequence number first and expects pages of 1, 2, 3 and 3, 4, 5; with the subquery'sORDER BYremoved the first page is 8, 9, 10.a_page_visits_at_most_the_page_size_of_the_shards_recordsrunsEXPLAIN (ANALYZE)on both statements, in the plan chosen for the parameters and in the generic plan, from the start of a shard of five pages and after its second page, and expects the scans ofstream_recordsto visit no more rows than the page holds.Stream not-found sentences
The sentence for a stream that does not exist is set per operation. On
main, every stream not-found sentence on every backend wasRequested resource not found: Stream: <arn> not found., with a trailing period. With this PR the sentences are:Requested resource not found: Stream: <arn> not found(no period)Requested resource not found: Stream does not existRequested resource not found: Stream does not existRequested resource not found: Stream: <arn> not found.(trailing period)main. The service answersRequested resource not found: Shard does not exist(measured 2026-10-24); that difference is outside this PRThree operations change their text compared with
main: DescribeStream loses the trailing period, and GetShardIterator and GetRecords answerStream does not existfor an unknown stream.tests/python/test_streams.py(TestStreamNotFoundSentences) sends DescribeStream, GetShardIterator and GetRecords to an unknown stream ARN of an existing table and compares the code and the message of each.test_streams.pyandtest_stream_shard_ownership.pypass on SQLite, PostgreSQL and MongoDB.File sizes
crates/engine/src/streams.rsgoes from 463 to 597 lines. The PostgreSQL stream engine goes from 617 to 609 lines and the MongoDB stream engine from 810 to 814. Both engines were over 600 lines before this PR and are not split here. Three other files this PR grows were over 600 lines already:crates/storage/src/lib.rs(1,024 to 1,030),crates/storage-postgres/src/lib.rs(877 to 879) andcrates/storage-cassandra/tests/direct/streams.rs(637 to 655). Every file this PR adds is under 600 lines. The largest iscrates/storage-postgres/tests/stream_shard_ownership.rsat 587.Re-enable test
test_iterator_reads_only_while_its_arn_is_the_tables_streamruns one state. After a disable and an enable, every backend keeps the table's stream ARN, so the state with a new ARN cannot be reached. The test asserts that precondition, so a backend that starts issuing a new ARN fails with that message, and a comment names #515 as the change that makes the other state reachable.Other changes in this update
record_dataanswers Internal (Missing record_data), as it does onmain. The new aggregation had dropped such a document as an idle page. The aggregation now marks every joined record, and two live tests pin both halves: the document withoutrecord_data, and the idle shard.Invalid ShardIteratorin the engine, as the authorization layer does. A build without the layer (developer mode) answered such a token as expired.StreamEngine::get_stream_recordsincrates/storage/src/lib.rsand the comment inhandle_get_recordsincrates/engine/src/streams.rsnow say that only SQLite resolves the shard in the same round trip as the records, and that PostgreSQL, MongoDB and Cassandra read the table first. The comments incrates/server/src/request_helpers.rsandtests/test_authz_resource_arn.pynow say that the storage read resolves the shard against the stream, not that the engine checks it before reading.docs/dynamodb-limits.mdlists the GetRecords limit as enforced and gives its two sentences.