Skip to content

adapter: wrap DynamoDB query/scan/transactGetItems in LeaseRead#952

Open
bootjp wants to merge 1 commit into
mainfrom
feat/lease-read-dynamodb-handlers
Open

adapter: wrap DynamoDB query/scan/transactGetItems in LeaseRead#952
bootjp wants to merge 1 commit into
mainfrom
feat/lease-read-dynamodb-handlers

Conversation

@bootjp

@bootjp bootjp commented Jun 10, 2026

Copy link
Copy Markdown
Owner

Summary

Part of #557. Wraps the remaining DynamoDB read handlers in a quorum-freshness LeaseRead check, following the pattern PR #549 established for getItem. Previously these handlers read local LastCommitTS/snapshot state with no quorum check at all, so a partitioned-but-not-yet-stepped-down stale leader could serve stale reads. Each handler now performs one lease check (cheap atomic-load fast path within the lease window; one LinearizableRead on a lease miss, after which the lease is refreshed for the rest of the window) before resolving its read timestamp.

The Redis slice of #557 is deliberately left for a follow-up — this PR touches only adapter/dynamodb.go.

Per-handler placement

  • query — keyless LeaseRead(ctx) (leaseReadKeyless helper) before queryItems samples readTS. A Query scans a key range that can span shards in a multi-group deployment, and the owning shard can't be resolved in the handler without duplicating prepareReadSchema/resolveQueryCondition, so the keyless check (which anchors the cluster freshness bound) is the correct, cheaper call.
  • scan — keyless LeaseRead(ctx) (same helper) before scanItems samples readTS. A Scan reads the whole table and spans every shard holding its items, so keyless is the right choice (matches the issue's guidance for keyless/multi-shard ops).
  • transactGetItems — per-key LeaseReadForKey, deduplicated by item key, run in leaseCheckTransactGetItems before nextTxnReadTS() resolves the single snapshot timestamp. Item keys are resolved at a tentative schema timestamp purely to route the lease check; the single-snapshot-ts semantics (one readTS shared by all items) are unchanged. Malformed items are skipped in the pre-pass so the existing validation/error mapping still surfaces downstream identically. Within the lease window, same-shard keys hit the cheap fast path, so per-key checks are effectively free.

Timeout decision

Every wrapped handler runs the lease check under a bounded context.WithTimeout(r.Context(), dynamoLeaseReadTimeout) with defer cancel() — the exact constant (5s) and structure getItem uses after PR #549 — so a stalled Raft cannot hang a handler indefinitely when the client never cancels.

Note on batchGetItem

The issue lists batchGetItem in scope, but there is no BatchGetItem handler in this codebase (no batchGetItemTarget, no registration, no implementation). There was nothing to wrap; the multi-key precedent it asked about is implemented in transactGetItems (per-key LeaseReadForKey, deduped). If BatchGetItem is added later, it should reuse the same leaseCheckTransactGetItems-style per-key dedup pattern.

Behavior change

Adds a quorum-freshness check to query/scan/transactGetItems. Read results and error mapping are otherwise identical; lease-read failure surfaces as the same 500 InternalServerError (dynamoErrInternal) that getItem produces.

Risk

Low. The only new failure mode is a 500 when the lease check fails (quorum loss), which is strictly more correct than serving a stale read. No write paths, FSM, or snapshot semantics touched. The added per-request LeaseRead is amortized by the lease window.

Test evidence

  • go test -race -run TestDynamoDB ./adapter/ → ok (15s)
  • Targeted: TestDynamoDB_Query_LeaseRead, TestDynamoDB_Scan_LeaseRead, TestDynamoDB_TransactGetItems_LeaseRead (each asserts (a) success with a healthy lease and (b) lease failure → 500 InternalServerError, mirroring getItem's error class) plus existing TestDynamoDB_TransactGetItems* → ok with -race.
  • golangci-lint --config=.golangci.yaml run ./adapter/... → 0 issues.
  • go vet ./adapter/, gofmt -l → clean.
  • Full go test -race ./adapter/ exceeds the default 10-minute package timeout (the suite spans Redis/S3/SQS/gRPC/etc., unrelated to this change); it passes under an extended -timeout.

New tests inject lease-read failures via a failLeaseReads toggle added to the existing testCoordinatorWrapper, and drive the failure path over raw HTTP so the AWS SDK does not retry the 500.

Self-review

  1. Data loss — None. Read-only handlers; no propose/apply, FSM, snapshot, TTL, or compaction paths touched. No return nil-after-error introduced; lease failure returns an explicit 500.
  2. Concurrency / distributed failures — Lease check is bounded by dynamoLeaseReadTimeout with defer cancel(), so a leader change / partition can't hang a handler. transactGetItems keeps its single shared readTS; the lease pre-pass runs before the timestamp is sampled, so a leadership flip mid-pass only causes a lease miss (→ LinearizableRead) or a 500, never a torn snapshot. Ran go test -race on the dynamo suite.
  3. Performance — One LeaseRead per request, amortized by the lease window (atomic-load fast path; one LinearizableRead per lease miss, then refreshed). transactGetItems dedups by item key and same-shard keys hit the fast path, so per-key checks add negligible cost; no extra Raft round-trips per HLC tick. The tentative schema load in the transact pre-pass is a cheap, cached Pebble read.
  4. Data consistencyreadTS is sampled after the lease confirmation in every handler, so any commit that landed before confirmation is visible; this tightens the freshness bound without bypassing the leader-issued read pipeline. transactGetItems single-snapshot-ts semantics are preserved.
  5. Test coverage — Added a success + lease-failure test for each wrapped handler, reusing the existing testCoordinatorWrapper/createNode harness and mirroring getItem's error class on failure.

@coderabbitai

coderabbitai Bot commented Jun 10, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@bootjp, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 38 minutes and 35 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 58679ce8-88ce-4976-aa0f-a4d4388bb70a

📥 Commits

Reviewing files that changed from the base of the PR and between ed82233 and 528d631.

📒 Files selected for processing (2)
  • adapter/dynamodb.go
  • adapter/dynamodb_test.go
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/lease-read-dynamodb-handlers

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 and usage tips.

@bootjp

bootjp commented Jun 10, 2026

Copy link
Copy Markdown
Owner Author

@claude review

@claude

claude Bot commented Jun 10, 2026

Copy link
Copy Markdown

Claude finished @bootjp's task in 3m 53s —— View job


Code Review: adapter: wrap DynamoDB query/scan/transactGetItems in LeaseRead

Overall: The implementation is correct, well-structured, and follows the getItem pattern faithfully. readTS is always sampled after lease confirmation, which is the critical ordering invariant. I have a few minor concerns and one notable edge case worth documenting.


Checklist

  • Read adapter/dynamodb.go changes
  • Read adapter/dynamodb_test.go changes
  • Review implementation correctness
  • Review concurrency/distributed failure safety
  • Review performance implications
  • Review test coverage
  • Post findings

Findings

1. leaseCheckTransactGetItems silently skips the quorum check when all items fail key resolution

File: adapter/dynamodb.go:4315

for _, item := range in.TransactItems {
    itemKey, ok := d.transactGetItemKey(r.Context(), item, schemaCache, tentativeTS)
    if !ok {
        continue  // item skipped
    }
    ...
}
return true  // returns true even if NO lease check was ever performed

If every item in in.TransactItems fails transactGetItemKey (e.g., all reference tables that don't exist at tentativeTS), the function returns true and transactGetItems proceeds to sample readTS and call buildTransactGetItemsResponses — without any quorum check. In practice buildTransactGetItemsResponses would error on those same items, so no actual store read escapes unguarded. But the scenario where tentativeTS doesn't see a table that was created very recently (and is visible at readTS) can let a real read bypass the lease check entirely.

This is low severity — table creation is rare, the window is tiny (just the lease check latency), and the new table's data was written very recently (so the leader can't be very stale). But it's an unacknowledged gap worth a comment or a guard.

Suggestion: Add a comment or, if stricter correctness is desired, fall back to a keyless lease check when seenKeys is empty at the end of the loop. Fix this →


2. Schema resolution in transactGetItemKey uses r.Context(), not leaseCtx

File: adapter/dynamodb.go:4322

itemKey, ok := d.transactGetItemKey(r.Context(), item, schemaCache, tentativeTS)

leaseCtx has a dynamoLeaseReadTimeout deadline; r.Context() does not (unless the client sets one). Schema loads are fast (Pebble + schemaCache memoisation), so this is fine in practice, but it means the total wall time of leaseCheckTransactGetItems isn't strictly bounded by dynamoLeaseReadTimeout. A first-miss schema read that stalls could eat into — or silently exceed — the lease budget.

Suggestion: Pass leaseCtx to transactGetItemKey for full timeout coverage. Fix this →


3. Acknowledged edge case: tentativeTS key routing vs readTS reads (no action needed)

The tentativeTSreadTS gap for shard routing in leaseCheckTransactGetItems is correctly acknowledged in the PR description ("a slight pre-lease stale schema is acceptable") and in the code comment. Table migrations are infrequent and the window is small. No change needed, but a one-liner comment in leaseCheckTransactGetItems referencing this trade-off (similar to getItem's re-resolution comment) would help future readers.


4. Test: no coverage for the "all items fail key resolution" case

File: adapter/dynamodb_test.go

TestDynamoDB_TransactGetItems_LeaseRead covers healthy lease and lease-failure paths, but there's no test asserting behaviour when all transactGetItemKey calls return false. If the fallback from finding #1 is added, it would need a test too.


What looks correct

  • Ordering invariant preserved: readTS is sampled inside queryItems/scanItems (via prepareReadSchemaresolveDynamoReadTS) and via nextTxnReadTS() in transactGetItems, all of which run after the lease confirmation. This is the critical guarantee.
  • Bounded timeout: All three handlers wrap the lease check in context.WithTimeout(r.Context(), dynamoLeaseReadTimeout) with defer leaseCancel(). Matches getItem.
  • Error mapping: writeDynamoError(w, http.StatusInternalServerError, dynamoErrInternal, ...) in all failure paths — consistent with getItem.
  • leaseReadKeyless extraction: Clean, avoids duplication across query and scan.
  • Deduplication in leaseCheckTransactGetItems: seenKeys correctly avoids redundant LeaseReadForKey calls for duplicate item keys within a transaction.
  • postDynamoRaw helper: Driving the wire directly to avoid SDK retry obfuscation on 5xx is the right approach for these tests.
  • wrapDynamoCoordinator: Clean t.Cleanup restores the original coordinator. atomic.Bool toggle is race-safe.

Summary

Items #1 and #2 are worth addressing before merge; #1 is the more substantive correctness gap (even though it's low-severity in practice). The rest of the implementation is solid and the test pattern mirrors getItem correctly.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces quorum-freshness lease checks for multi-shard read operations (Query, Scan) and multi-item transactions (TransactGetItems) in the DynamoDB adapter, along with corresponding unit tests. The reviewer identified a critical performance bottleneck in leaseCheckTransactGetItems where lease checks are executed sequentially for each unique key. This sequential execution could easily exceed the timeout limit for large transactions, and the reviewer provided a concurrent implementation using goroutines to resolve this issue.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread adapter/dynamodb.go
Comment on lines +4315 to +4336
func (d *DynamoDBServer) leaseCheckTransactGetItems(w http.ResponseWriter, r *http.Request, in transactGetItemsInput) bool {
tentativeTS := snapshotTS(d.coordinator.Clock(), d.store)
schemaCache := make(map[string]*dynamoTableSchema)
seenKeys := make(map[string]struct{}, len(in.TransactItems))
leaseCtx, leaseCancel := context.WithTimeout(r.Context(), dynamoLeaseReadTimeout)
defer leaseCancel()
for _, item := range in.TransactItems {
itemKey, ok := d.transactGetItemKey(r.Context(), item, schemaCache, tentativeTS)
if !ok {
continue
}
if _, dup := seenKeys[string(itemKey)]; dup {
continue
}
seenKeys[string(itemKey)] = struct{}{}
if _, err := kv.LeaseReadForKeyThrough(d.coordinator, leaseCtx, itemKey); err != nil {
writeDynamoError(w, http.StatusInternalServerError, dynamoErrInternal, err.Error())
return false
}
}
return true
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Executing kv.LeaseReadForKeyThrough sequentially for each unique key in TransactGetItems introduces a significant performance bottleneck. If a transaction contains many items (up to transactGetItemsMaxItems = 100) and a lease is missed or the coordinator falls back to LinearizableRead (e.g., if leases are unsupported or disabled), these reads will execute sequentially. This results in up to 100 sequential Raft round-trips, which will easily exceed the dynamoLeaseReadTimeout (5s) and cause client requests to fail with a 500 InternalServerError.

To prevent this $O(N)$ sequential latency bottleneck, resolve and deduplicate the keys first, then execute the lease checks concurrently using a sync.WaitGroup and a buffered error channel.

func (d *DynamoDBServer) leaseCheckTransactGetItems(w http.ResponseWriter, r *http.Request, in transactGetItemsInput) bool {
	tentativeTS := snapshotTS(d.coordinator.Clock(), d.store)
	schemaCache := make(map[string]*dynamoTableSchema)
	seenKeys := make(map[string]struct{}, len(in.TransactItems))

	var uniqueKeys [][]byte
	for _, item := range in.TransactItems {
		itemKey, ok := d.transactGetItemKey(r.Context(), item, schemaCache, tentativeTS)
		if !ok {
			continue
		}
		if _, dup := seenKeys[string(itemKey)]; dup {
			continue
		}
		seenKeys[string(itemKey)] = struct{}{}
		uniqueKeys = append(uniqueKeys, itemKey)
	}

	if len(uniqueKeys) == 0 {
		return true
	}

	leaseCtx, leaseCancel := context.WithTimeout(r.Context(), dynamoLeaseReadTimeout)
	defer leaseCancel()

	var wg sync.WaitGroup
	errChan := make(chan error, len(uniqueKeys))

	for _, key := range uniqueKeys {
		wg.Add(1)
		go func(itemKey []byte) {
			defer wg.Done()
			if _, err := kv.LeaseReadForKeyThrough(d.coordinator, leaseCtx, itemKey); err != nil {
				errChan <- err
			}
		}(key)
	}

	wg.Wait()
	close(errChan)

	if err := <-errChan; err != nil {
		writeDynamoError(w, http.StatusInternalServerError, dynamoErrInternal, err.Error())
		return false
	}
	return true
}

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 528d63148f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread adapter/dynamodb.go
func (d *DynamoDBServer) leaseReadKeyless(w http.ResponseWriter, r *http.Request) bool {
leaseCtx, leaseCancel := context.WithTimeout(r.Context(), dynamoLeaseReadTimeout)
defer leaseCancel()
if _, err := kv.LeaseReadThrough(d.coordinator, leaseCtx); err != nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Route Query lease checks by the queried key

When DynamoDB is backed by ShardedCoordinator, this keyless LeaseRead only checks the default shard (kv/sharded_coordinator.go:1517-1524), not the shard that owns the Query's partition-key prefix. For a Query whose hash key routes to a non-default shard, an unavailable default group now returns InternalServerError before queryItems can read from the healthy owning shard; conversely this check still does not establish freshness for the target shard. Resolve the query key/prefix first and use LeaseReadForKey for that shard (or otherwise check the actual range owners) instead of the default-group lease.

Useful? React with 👍 / 👎.

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