Repository navigation
feat(sqs): add dead-letter redrive ops for full 23/23 coverage - #94
Conversation
Implement the four remaining SQS operations, all in the dead-letter redrive family: - ListDeadLetterSourceQueues - StartMessageMoveTask - ListMessageMoveTasks - CancelMessageMoveTask The store gains a move-task registry and a source-queue lookup that reuse the existing RedrivePolicy fields (MaxReceiveCount / DeadLetterTargetArn). Redrive runs synchronously in the in-memory store and drains one queue lock at a time to stay deadlock-free with the source->DLQ path in ReceiveMessage. Wire field casing matches the botocore SQS JSON model (queueUrls is lowercase for ListDeadLetterSourceQueues; the rest PascalCase). Adds store unit tests and boto3 compat tests, and corrects the stale SQS docs (batch/DLQ/FIFO/message-attributes were wrongly listed as unsupported).
Reviewer's GuideImplements full SQS dead-letter redrive support (ListDeadLetterSourceQueues, StartMessageMoveTask, ListMessageMoveTasks, CancelMessageMoveTask) across JSON and Query protocols, backed by an in-memory move-task registry and updated docs/tests for 23/23 operation coverage. File-Level Changes
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
There was a problem hiding this comment.
Hey - I've found 2 issues, and left some high level feedback:
- Consider pruning entries from
moveTasks(e.g., on cancel or after listing) or adding an upper bound, since the map is currently append-only and can grow without limit over a long-running process. - In
StartMessageMoveTask, implicit destination selection for multi-source DLQs always picks the first referencing queue; if that differs from AWS behavior, you might want to either document this more prominently or enforce an explicitDestinationArnwhen multiple sources exist.
Prompt for AI Agents
Please address the comments from this code review:
## Overall Comments
- Consider pruning entries from `moveTasks` (e.g., on cancel or after listing) or adding an upper bound, since the map is currently append-only and can grow without limit over a long-running process.
- In `StartMessageMoveTask`, implicit destination selection for multi-source DLQs always picks the first referencing queue; if that differs from AWS behavior, you might want to either document this more prominently or enforce an explicit `DestinationArn` when multiple sources exist.
## Individual Comments
### Comment 1
<location path="tests/compatibility/test_sqs.py" line_range="218" />
<code_context>
+ return src, dlq, dlq_arn
+
+
+def test_list_dead_letter_source_queues(sqs_client):
+ src, dlq, _ = _redrive_pair(sqs_client, "dlq-src", "dlq-target")
+ resp = sqs_client.list_dead_letter_source_queues(QueueUrl=dlq)
</code_context>
<issue_to_address>
**suggestion (testing):** Add compat tests for queues with no sources and for NonExistentQueue behavior in list_dead_letter_source_queues
Currently only the "DLQ has at least one source" path is covered here; two key behaviors are still only tested at the store level:
1. For a queue that exists but is not configured as a DLQ (no RedrivePolicy references), `list_dead_letter_source_queues` should return an empty `queueUrls` list.
2. For a non-existent queue URL, `list_dead_letter_source_queues` should raise a `ClientError` with `error_code == "AWS.SimpleQueueService.NonExistentQueue"`.
Please add compat tests for these cases so the JSON handler behavior and error mapping are verified under these edge conditions.
Suggested implementation:
```python
def test_list_dead_letter_source_queues(sqs_client):
src, dlq, _ = _redrive_pair(sqs_client, "dlq-src", "dlq-target")
resp = sqs_client.list_dead_letter_source_queues(QueueUrl=dlq)
urls = resp["queueUrls"]
assert any("dlq-src" in u for u in urls)
def test_list_dead_letter_source_queues_for_non_dlq_returns_empty_list(sqs_client):
# Create a queue that is not configured as a DLQ (no RedrivePolicy references).
queue_url = sqs_client.create_queue(QueueName="no-dlq")["QueueUrl"]
resp = sqs_client.list_dead_letter_source_queues(QueueUrl=queue_url)
# For a queue that exists but is not configured as a DLQ, AWS returns an empty list.
assert resp["queueUrls"] == []
def test_list_dead_letter_source_queues_nonexistent_queue_raises_client_error(sqs_client):
# For a non-existent queue URL, AWS should raise a NonExistentQueue ClientError.
non_existent_queue_url = (
"https://sqs.us-east-1.amazonaws.com/123456789012/non-existent-queue"
)
with pytest.raises(ClientError) as exc:
sqs_client.list_dead_letter_source_queues(QueueUrl=non_existent_queue_url)
error = exc.value.response.get("Error", {})
assert error.get("Code") == "AWS.SimpleQueueService.NonExistentQueue"
def test_message_move_task_flow(sqs_client):
src, dlq, dlq_arn = _redrive_pair(sqs_client, "mmt-src", "mmt-dlq")
sqs_client.send_message(QueueUrl=src, MessageBody="redrive-me")
# Exceed maxReceiveCount so the message is moved to the DLQ.
sqs_client.receive_message(QueueUrl=src, VisibilityTimeout=0)
```
1. Ensure `pytest` is imported at the top of `tests/compatibility/test_sqs.py`, e.g. `import pytest`, if it is not already present.
2. Ensure `ClientError` is imported, e.g. `from botocore.exceptions import ClientError`, if it is not already present.
3. If the test suite uses a specific region or account ID pattern for queue URLs, adjust `non_existent_queue_url` to match those conventions while still pointing to a non-existent queue.
</issue_to_address>
### Comment 2
<location path="tests/compatibility/test_sqs.py" line_range="225" />
<code_context>
+ assert any("dlq-src" in u for u in urls)
+
+
+def test_message_move_task_flow(sqs_client):
+ src, dlq, dlq_arn = _redrive_pair(sqs_client, "mmt-src", "mmt-dlq")
+
</code_context>
<issue_to_address>
**suggestion (testing):** Broaden message move task compat coverage: explicit destination, unsupported source, missing params, and unknown task handle
Current flow coverage only exercises basic redrive and cancel behavior; several important client-visible cases remain untested:
1. Add a test for `DestinationArn` routing (two source queues sharing a DLQ, then `start_message_move_task(SourceArn=dlq_arn, DestinationArn=src2_arn)` and assert the message lands on the second source).
2. Add a test for `SourceArn` pointing to a non-DLQ queue, asserting `AWS.SimpleQueueService.UnsupportedOperation` via `ClientError`.
3. Add tests that omit required parameters (`SourceArn` for start/list, `TaskHandle` for cancel) and assert `MissingParameter` errors.
4. Add a cancel test with an unknown `TaskHandle` and assert `ResourceNotFoundException`.
These will lock in routing and error-handling semantics for the new redrive APIs.
Suggested implementation:
```python
def test_list_dead_letter_source_queues(sqs_client):
src, dlq, _ = _redrive_pair(sqs_client, "dlq-src", "dlq-target")
resp = sqs_client.list_dead_letter_source_queues(QueueUrl=dlq)
urls = resp["queueUrls"]
assert any("dlq-src" in u for u in urls)
def test_message_move_task_destination_arn_routing(sqs_client):
# Two source queues share a DLQ. Start a move task from the DLQ to the second source
# and assert the message lands on the second source.
src1, dlq, dlq_arn = _redrive_pair(sqs_client, "mmt-src1", "mmt-dlq")
# Create a second source queue wired to the same DLQ.
src2 = sqs_client.create_queue(
QueueName="mmt-src2",
Attributes={
"RedrivePolicy": (
'{"deadLetterTargetArn":"%s","maxReceiveCount":"1"}' % dlq_arn
)
},
)["QueueUrl"]
src2_arn = sqs_client.get_queue_attributes(
QueueUrl=src2, AttributeNames=["QueueArn"]
)["Attributes"]["QueueArn"]
# Seed the DLQ directly with a message.
sqs_client.send_message(QueueUrl=dlq, MessageBody="dest-arn-routing")
# Start message move task from DLQ to the second source.
resp = sqs_client.start_message_move_task(
SourceArn=dlq_arn,
DestinationArn=src2_arn,
)
assert "TaskHandle" in resp
# The message should be moved from DLQ to src2.
msgs = sqs_client.receive_message(QueueUrl=src2, MaxNumberOfMessages=10)
assert len(msgs.get("Messages", [])) == 1
assert msgs["Messages"][0]["Body"] == "dest-arn-routing"
# DLQ should now be empty.
dlq_msgs = sqs_client.receive_message(QueueUrl=dlq, MaxNumberOfMessages=10)
assert "Messages" not in dlq_msgs
def test_message_move_task_unsupported_source_arn(sqs_client):
# SourceArn must be a DLQ; pointing to a non-DLQ queue should raise UnsupportedOperation.
# Create a regular queue (no RedrivePolicy).
src_url = sqs_client.create_queue(QueueName="mmt-unsupported-src")["QueueUrl"]
src_arn = sqs_client.get_queue_attributes(
QueueUrl=src_url, AttributeNames=["QueueArn"]
)["Attributes"]["QueueArn"]
with pytest.raises(ClientError) as excinfo:
sqs_client.start_message_move_task(SourceArn=src_arn)
err = excinfo.value.response["Error"]
assert err["Code"] == "AWS.SimpleQueueService.UnsupportedOperation"
def test_start_message_move_task_missing_source_arn(sqs_client):
# Missing required SourceArn should surface as MissingParameter.
dlq_url = sqs_client.create_queue(QueueName="mmt-missing-srcarn-dlq")["QueueUrl"]
dlq_arn = sqs_client.get_queue_attributes(
QueueUrl=dlq_url, AttributeNames=["QueueArn"]
)["Attributes"]["QueueArn"]
with pytest.raises(ClientError) as excinfo:
# Intentionally omit SourceArn.
sqs_client.start_message_move_task(DestinationArn=dlq_arn)
err = excinfo.value.response["Error"]
assert err["Code"] == "MissingParameter"
def test_list_message_move_tasks_missing_source_arn(sqs_client):
# SourceArn is required for list_message_move_tasks.
with pytest.raises(ClientError) as excinfo:
sqs_client.list_message_move_tasks()
err = excinfo.value.response["Error"]
assert err["Code"] == "MissingParameter"
def test_cancel_message_move_task_missing_task_handle(sqs_client):
# TaskHandle is required for cancel_message_move_task.
with pytest.raises(ClientError) as excinfo:
sqs_client.cancel_message_move_task(TaskHandle="")
err = excinfo.value.response["Error"]
assert err["Code"] == "MissingParameter"
def test_cancel_message_move_task_unknown_task_handle(sqs_client):
# Cancel with an unknown TaskHandle should raise ResourceNotFoundException.
with pytest.raises(ClientError) as excinfo:
sqs_client.cancel_message_move_task(TaskHandle="arn:aws:sqs:unknown-task-handle")
err = excinfo.value.response["Error"]
assert err["Code"] == "ResourceNotFoundException"
import pytest
from botocore.exceptions import ClientError
```
These tests assume:
1. `list_message_move_tasks`, `start_message_move_task`, and `cancel_message_move_task` are available on `sqs_client` and behave like the AWS SQS APIs, including error codes (`MissingParameter`, `AWS.SimpleQueueService.UnsupportedOperation`, `ResourceNotFoundException`).
2. The backend allows sending messages directly to the DLQ queue URL (standard SQS behavior).
If your existing test suite already defines helpers around message move tasks (e.g., polling for completion or seeding DLQs), you may want to:
- Reuse those helpers instead of the direct `send_message`/`receive_message` calls here.
- Adjust the exact assertion on missing-parameter behavior if your client raises a local parameter validation error before making the AWS call (e.g., `ParamValidationError` instead of `ClientError`).
</issue_to_address>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
…icit Address code review on the redrive ops: - StartMessageMoveTask now keeps only the latest task per source, so the moveTasks map can't grow without bound. - When a DLQ has multiple source queues and no DestinationArn is given, redrive is rejected (ErrAmbiguousDest -> InvalidParameterValue) instead of silently routing to the first source; single-source still defaults. Expand tests: store tests for the multi-source ambiguity, explicit-dest routing, and the bounded registry; compat tests for empty/non-existent list_dead_letter_source_queues, destination routing, ambiguous dest, unsupported (non-DLQ) source, and cancel missing/unknown handle. Note that omitting a required member (SourceArn) raises botocore's ParamValidationError client-side, so that path is asserted as such.
|
@sourcery-ai resolve |
|
@sourcery-ai review |
There was a problem hiding this comment.
Hey - I've found 2 issues, and left some high level feedback:
- The comment on
sourceQueuesForDLQsays the caller must holds.mu, butListDeadLetterSourceQueuescalls it underRLock; consider tightening the wording or consistently using the same lock mode to avoid confusion for future maintainers. - In
StartMessageMoveTask, the error message forErrAmbiguousDest(“when the source queue has multiple dead-letter source queues”) is a bit misleading given the logic is about multiple source queues for a DLQ; consider rephrasing to clearly describe the DLQ/multi‑source scenario.
Prompt for AI Agents
Please address the comments from this code review:
## Overall Comments
- The comment on `sourceQueuesForDLQ` says the caller must hold `s.mu`, but `ListDeadLetterSourceQueues` calls it under `RLock`; consider tightening the wording or consistently using the same lock mode to avoid confusion for future maintainers.
- In `StartMessageMoveTask`, the error message for `ErrAmbiguousDest` (“when the source queue has multiple dead-letter source queues”) is a bit misleading given the logic is about multiple source queues for a DLQ; consider rephrasing to clearly describe the DLQ/multi‑source scenario.
## Individual Comments
### Comment 1
<location path="tests/compatibility/test_sqs.py" line_range="266-268" />
<code_context>
+ )
+
+
+def test_message_move_task_flow(sqs_client):
+ src, dlq, dlq_arn = _redrive_pair(sqs_client, "mmt-src", "mmt-dlq")
+
+ sqs_client.send_message(QueueUrl=src, MessageBody="redrive-me")
+ # Exceed maxReceiveCount so the message is moved to the DLQ.
+ sqs_client.receive_message(QueueUrl=src, VisibilityTimeout=0)
+ sqs_client.receive_message(QueueUrl=src, VisibilityTimeout=0)
+
+ handle = sqs_client.start_message_move_task(SourceArn=dlq_arn)["TaskHandle"]
+ assert handle
+
+ tasks = sqs_client.list_message_move_tasks(SourceArn=dlq_arn)["Results"]
+ assert len(tasks) >= 1
+ assert tasks[0]["SourceArn"] == dlq_arn
+
+ cancel = sqs_client.cancel_message_move_task(TaskHandle=handle)
+ assert "ApproximateNumberOfMessagesMoved" in cancel
+
</code_context>
<issue_to_address>
**suggestion (testing):** Add compat tests for list_message_move_tasks error handling and MaxResults semantics
Currently we only test the successful ListMessageMoveTasks flow. Please also cover:
- Calling list_message_move_tasks without SourceArn and asserting the "MissingParameter" error code.
- Creating more than 10 tasks for a SourceArn and verifying that Results length matches the MaxResults semantics (defaulting to 10 when MaxResults <= 0 and respecting the upper cap when provided).
These cases ensure the client-visible contract for error handling and pagination is exercised by the compat suite.
```suggestion
cancel = sqs_client.cancel_message_move_task(TaskHandle=handle)
assert "ApproximateNumberOfMessagesMoved" in cancel
def test_list_message_move_tasks_missing_source_arn(sqs_client):
with pytest.raises(ClientError) as exc:
sqs_client.list_message_move_tasks()
assert exc.value.response["Error"]["Code"] == "MissingParameter"
def test_list_message_move_tasks_max_results_semantics(sqs_client):
src, dlq, dlq_arn = _redrive_pair(sqs_client, "mmt-src-max", "mmt-dlq-max")
# Create more than 10 tasks for the same SourceArn
for i in range(15):
sqs_client.send_message(QueueUrl=src, MessageBody=f"msg-{i}")
# Exceed maxReceiveCount so the message is moved to the DLQ.
sqs_client.receive_message(QueueUrl=src, VisibilityTimeout=0)
sqs_client.receive_message(QueueUrl=src, VisibilityTimeout=0)
sqs_client.start_message_move_task(SourceArn=dlq_arn)
# Default MaxResults (and non-positive values) should return 10
default_results = sqs_client.list_message_move_tasks(SourceArn=dlq_arn)["Results"]
assert len(default_results) == 10
zero_results = sqs_client.list_message_move_tasks(
SourceArn=dlq_arn, MaxResults=0
)["Results"]
assert len(zero_results) == 10
negative_results = sqs_client.list_message_move_tasks(
SourceArn=dlq_arn, MaxResults=-5
)["Results"]
assert len(negative_results) == 10
# Positive MaxResults should be honored but capped at the service upper bound (10)
seven_results = sqs_client.list_message_move_tasks(
SourceArn=dlq_arn, MaxResults=7
)["Results"]
assert len(seven_results) == 7
capped_results = sqs_client.list_message_move_tasks(
SourceArn=dlq_arn, MaxResults=25
)["Results"]
assert len(capped_results) == 10
```
</issue_to_address>
### Comment 2
<location path="tests/compatibility/test_sqs.py" line_range="234-238" />
<code_context>
+ assert any("dlq-src" in u for u in urls)
+
+
+def test_list_dead_letter_source_queues_no_sources(sqs_client):
+ # A queue that exists but is not used as a DLQ returns an empty list.
+ url = sqs_client.create_queue(QueueName="dlsq-no-sources")["QueueUrl"]
+ resp = sqs_client.list_dead_letter_source_queues(QueueUrl=url)
+ assert resp["queueUrls"] == []
+
+
</code_context>
<issue_to_address>
**suggestion (testing):** Consider a compat test for missing QueueUrl in ListDeadLetterSourceQueues
The JSON handler explicitly validates a missing `QueueUrl` and returns a `MissingParameter` error, but current compat tests only cover cases where `QueueUrl` is provided. Please add a test that calls `sqs_client.list_dead_letter_source_queues()` without `QueueUrl` and asserts the `MissingParameter` error code to cover this behavior and protect against regressions in parameter validation.
Suggested implementation:
```python
def test_list_dead_letter_source_queues(sqs_client):
src, dlq, _ = _redrive_pair(sqs_client, "dlq-src", "dlq-target")
resp = sqs_client.list_dead_letter_source_queues(QueueUrl=dlq)
urls = resp["queueUrls"]
assert any("dlq-src" in u for u in urls)
def test_list_dead_letter_source_queues_no_sources(sqs_client):
# A queue that exists but is not used as a DLQ returns an empty list.
url = sqs_client.create_queue(QueueName="dlsq-no-sources")["QueueUrl"]
resp = sqs_client.list_dead_letter_source_queues(QueueUrl=url)
assert resp["queueUrls"] == []
def test_list_dead_letter_source_queues_missing_queue_url(sqs_client):
# Missing required QueueUrl should raise a MissingParameter error
with pytest.raises(ClientError) as exc:
sqs_client.list_dead_letter_source_queues()
err = exc.value.response["Error"]
assert err["Code"] == "MissingParameter"
import pytest
from botocore.exceptions import ClientError, ParamValidationError
```
If the placement of `import pytest` and `ClientError`/`ParamValidationError` differs in your actual file (e.g., all imports are at the top), adjust the test insertion accordingly so that the new `test_list_dead_letter_source_queues_missing_queue_url` remains alongside the other ListDeadLetterSourceQueues compat tests and continues to use the existing imports.
</issue_to_address>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
… tests - Retain the most recent 10 move-task records per source (was: one per source) so ListMessageMoveTasks pagination works while the registry stays bounded; clamp MaxResults to [1, 10] like the real API. - Clarify the sourceQueuesForDLQ lock comment (caller holds s.mu read or write) and reword the ambiguous-destination error to describe the DLQ/multi-source scenario. - Add compat tests: ListDeadLetterSourceQueues missing QueueUrl, ListMessageMoveTasks required SourceArn and MaxResults semantics (default/0/negative -> 10, in-range honored, over-cap clamped). Required members (SourceArn/QueueUrl) are validated client-side by botocore (ParamValidationError); the empty-string cases exercise the server-side MissingParameter path.
|
@sourcery-ai resolve |
- Redrive only available DLQ messages; leave in-flight (received, undeleted) ones so a live consumer's receipt handle stays valid (no duplicate processing) - ListMessageMoveTasks returns value copies taken under the lock, fixing a data race on moveTask.Status vs CancelMessageMoveTask - Wire the four redrive ops into the Query (form) dispatcher with XML handlers, not just the JSON one — Query clients no longer get HTTP 400 - Order move tasks by a monotonic seq (not second-granular StartedUnix), so prune/list are deterministic and never drop the just-created task - CancelMessageMoveTask rejects non-RUNNING tasks (ResourceNotFoundException); synchronous redrive is always COMPLETED, matching AWS - ListMessageMoveTasks MaxResults defaults to 1 (AWS default), not the cap - Emit StartedTimestamp in epoch milliseconds (AWS Long), not seconds - Restore docs note that queue-level DelaySeconds/VisibilityTimeout are not enforced - Hold s.mu only to resolve queue pointers; run drain/fill under per-queue locks - Extract sortedTasksForSource to dedupe prune/list filter+sort
|
@sourcery-ai review |
There was a problem hiding this comment.
Hey - I've found 1 issue
Prompt for AI Agents
Please address the comments from this code review:
## Individual Comments
### Comment 1
<location path="internal/services/sqs/store_test.go" line_range="281-290" />
<code_context>
+func TestQueueStore_MessageMoveTaskRegistryBounded(t *testing.T) {
</code_context>
<issue_to_address>
**suggestion (testing):** Strengthen registry-bounding test to assert that the newest tasks are retained and older ones pruned
This currently only checks the cap and `ListMessageMoveTasks` max-results behavior, not *which* tasks are kept. Since ordering is `seq`-based, please also assert that early task handles are pruned and that the most recently started tasks are returned first. One way: capture the first few handles before the loop, then after the loop assert they’re absent from `store.moveTasks` and that `ListMessageMoveTasks` returns the last `maxTasksPerSource` handles in reverse insertion order to better guard future pruning/ordering changes.
</issue_to_address>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
| func TestQueueStore_MessageMoveTaskRegistryBounded(t *testing.T) { | ||
| store := NewQueueStore(0) | ||
| require.NoError(t, store.CreateQueue("bound-src", testAccount)) | ||
| require.NoError(t, store.CreateQueue("bound-dlq", testAccount)) | ||
|
|
||
| dlqArn := fmt.Sprintf("arn:aws:sqs:us-east-1:%s:bound-dlq", testAccount) | ||
| require.NoError(t, store.SetQueueAttributes("bound-src", testAccount, map[string]string{ | ||
| "RedrivePolicy": `{"maxReceiveCount":1,"deadLetterTargetArn":"` + dlqArn + `"}`, | ||
| })) | ||
|
|
There was a problem hiding this comment.
suggestion (testing): Strengthen registry-bounding test to assert that the newest tasks are retained and older ones pruned
This currently only checks the cap and ListMessageMoveTasks max-results behavior, not which tasks are kept. Since ordering is seq-based, please also assert that early task handles are pruned and that the most recently started tasks are returned first. One way: capture the first few handles before the loop, then after the loop assert they’re absent from store.moveTasks and that ListMessageMoveTasks returns the last maxTasksPerSource handles in reverse insertion order to better guard future pruning/ordering changes.
Strengthen TestQueueStore_MessageMoveTaskRegistryBounded to verify which tasks survive pruning (oldest removed, newest maxTasksPerSource kept) and that ListMessageMoveTasks returns them newest-first — guarding the monotonic seq ordering when tasks share a StartedMillis.
Summary
Closes the last operation gap on SQS by implementing the four dead-letter redrive operations, bringing SQS to 23/23 operation coverage (roadmap Phase 1 — "deepen coverage on the 6 core services").
ListDeadLetterSourceQueuesStartMessageMoveTaskListMessageMoveTasksCancelMessageMoveTaskDetails
internal/services/sqs/store.go): adds a move-task registry +sourceQueuesForDLQlookup, reusing the existingRedrivePolicyfields (MaxReceiveCount/DeadLetterTargetArn). Redrive runs synchronously in the in-memory store and drains one queue lock at a time to stay deadlock-free with the source→DLQ path inReceiveMessage.internal/services/sqs/provider.go): 4 JSON handlers + dispatch cases. Wire field casing matches the botocore SQS JSON model (queueUrlsis lowercase forListDeadLetterSourceQueues; the rest PascalCase).docs/services/sqs.md(batch / DLQ / FIFO / message-attributes were wrongly listed as unsupported) and bumps the Tier-1 count indocs/services-matrix.md.Deliberately out of scope
AddPermission/RemovePermissionsemantics — they remain 200-OK stubs.RUNNINGstatus andMaxNumberOfMessagesPerSecondis not throttled (documented as a known limitation).Testing
go test -race ./internal/services/sqs/...— pass (2 new store tests)pytest -k sqs— 21 passed (2 new compat tests + existing suite, incl. the EventBridge→SQS dispatch test)