Skip to content

Commit 103ec17

Browse files
whummerclaude
andauthored
Add Step Functions state machine for async processing; various fixes in setup (#6)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 9565fad commit 103ec17

9 files changed

Lines changed: 1126 additions & 270 deletions

File tree

.devcontainer/Dockerfile.github

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ ENV EXTRA_CORS_ALLOWED_ORIGINS='*'
1717
ENV DISABLE_CUSTOM_CORS_APIGATEWAY=1
1818
ENV LOCALSTACK_APPINSPECTOR_ENABLE=1
1919
ENV LOCALSTACK_APPINSPECTOR_DEV_ENABLE=1
20+
ENV LOCALSTACK_DEBUG=1
2021

2122
# Workshop token URL — set by organizer before each event.
2223
# The setup script fetches the actual token from this endpoint.

01-serverless-app/lambdas/order_handler/handler.py

Lines changed: 46 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,22 @@
11
import json
22
import os
33
import uuid
4+
from datetime import datetime, timezone
5+
from decimal import Decimal
46
import boto3
57

6-
dynamodb = boto3.resource("dynamodb", endpoint_url=os.environ.get("AWS_ENDPOINT_URL"))
7-
sqs = boto3.client("sqs", endpoint_url=os.environ.get("AWS_ENDPOINT_URL"))
8+
dynamodb = boto3.resource("dynamodb")
9+
sqs = boto3.client("sqs")
10+
11+
TABLE_NAME = os.environ["ORDERS_TABLE"]
12+
PRODUCTS_TABLE = os.environ["PRODUCTS_TABLE"]
13+
QUEUE_URL = os.environ["ORDERS_QUEUE_URL"]
14+
DLQ_URL = os.environ.get("ORDERS_DLQ_URL", "")
15+
16+
class DecimalEncoder(json.JSONEncoder):
17+
def default(self, o):
18+
return int(o) if isinstance(o, Decimal) else super().default(o)
819

9-
TABLE_NAME = os.environ["ORDERS_TABLE"]
10-
QUEUE_URL = os.environ["ORDERS_QUEUE_URL"]
1120

1221
CORS_HEADERS = {
1322
"Access-Control-Allow-Origin": "*",
@@ -18,10 +27,17 @@
1827

1928
def handler(event, context):
2029
method = event.get("httpMethod", "")
30+
path = event.get("path", "")
2131

2232
if method == "OPTIONS":
2333
return {"statusCode": 200, "headers": CORS_HEADERS, "body": ""}
2434

35+
if method == "POST" and path.endswith("/replay"):
36+
return replay_dlq()
37+
38+
if method == "GET" and "/products" in path:
39+
return list_products()
40+
2541
if method == "GET":
2642
return list_orders()
2743

@@ -31,26 +47,50 @@ def handler(event, context):
3147
return {"statusCode": 405, "headers": CORS_HEADERS, "body": "Method Not Allowed"}
3248

3349

50+
def replay_dlq():
51+
resp = sqs.receive_message(QueueUrl=DLQ_URL, MaxNumberOfMessages=10)
52+
messages = resp.get("Messages", [])
53+
for msg in messages:
54+
sqs.send_message(QueueUrl=QUEUE_URL, MessageBody=msg["Body"])
55+
sqs.delete_message(QueueUrl=DLQ_URL, ReceiptHandle=msg["ReceiptHandle"])
56+
return {
57+
"statusCode": 200,
58+
"headers": {**CORS_HEADERS, "Content-Type": "application/json"},
59+
"body": json.dumps({"replayed": len(messages)}),
60+
}
61+
62+
63+
def list_products():
64+
table = dynamodb.Table(PRODUCTS_TABLE)
65+
items = sorted(table.scan().get("Items", []), key=lambda x: x.get("name", ""))
66+
return {
67+
"statusCode": 200,
68+
"headers": {**CORS_HEADERS, "Content-Type": "application/json"},
69+
"body": json.dumps(items, cls=DecimalEncoder),
70+
}
71+
72+
3473
def list_orders():
3574
table = dynamodb.Table(TABLE_NAME)
3675
result = table.scan()
3776
items = sorted(result.get("Items", []), key=lambda x: x.get("order_id", ""))
3877
return {
3978
"statusCode": 200,
4079
"headers": {**CORS_HEADERS, "Content-Type": "application/json"},
41-
"body": json.dumps(items),
80+
"body": json.dumps(items, cls=DecimalEncoder),
4281
}
4382

4483

4584
def create_order(event):
4685
body = json.loads(event.get("body") or "{}")
47-
order_id = str(uuid.uuid4())
86+
order_id = uuid.uuid4().hex[:12]
4887

4988
order = {
5089
"order_id": order_id,
5190
"item": body.get("item", "unknown"),
5291
"quantity": int(body.get("quantity", 1)),
5392
"status": "pending",
93+
"created_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
5494
}
5595

5696
table = dynamodb.Table(TABLE_NAME)
Lines changed: 91 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,101 @@
11
import json
22
import os
3+
import time
4+
import uuid
5+
from datetime import datetime, timezone
36
import boto3
47

5-
dynamodb = boto3.resource("dynamodb", endpoint_url=os.environ.get("AWS_ENDPOINT_URL"))
6-
s3 = boto3.client("s3", endpoint_url=os.environ.get("AWS_ENDPOINT_URL"))
8+
dynamodb = boto3.resource("dynamodb")
9+
s3 = boto3.client("s3")
10+
sfn = boto3.client("stepfunctions")
711

812
TABLE_NAME = os.environ["ORDERS_TABLE"]
913
RECEIPTS_BUCKET = os.environ["RECEIPTS_BUCKET"]
14+
STATE_MACHINE_ARN = os.environ["STATE_MACHINE_ARN"]
15+
16+
TERMINAL_STATUSES = {"fulfilled", "failed"}
17+
18+
19+
def now():
20+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
21+
22+
23+
def set_status(order_id, status):
24+
ts_key = {
25+
"validating": "validating_at",
26+
"payment_processing": "payment_at",
27+
"fulfilled": "fulfilled_at",
28+
"failed": "failed_at",
29+
}.get(status)
30+
31+
expression = "SET #s = :s"
32+
names = {"#s": "status"}
33+
values = {":s": status}
34+
35+
if ts_key:
36+
expression += ", #ts = :ts"
37+
names["#ts"] = ts_key
38+
values[":ts"] = now()
39+
40+
dynamodb.Table(TABLE_NAME).update_item(
41+
Key={"order_id": order_id},
42+
UpdateExpression=expression,
43+
ExpressionAttributeNames=names,
44+
ExpressionAttributeValues=values,
45+
)
1046

1147

1248
def handler(event, context):
13-
for record in event["Records"]:
14-
order = json.loads(record["body"])
15-
order_id = order["order_id"]
16-
17-
# Update order status in DynamoDB
18-
table = dynamodb.Table(TABLE_NAME)
19-
table.update_item(
20-
Key={"order_id": order_id},
21-
UpdateExpression="SET #s = :s",
22-
ExpressionAttributeNames={"#s": "status"},
23-
ExpressionAttributeValues={":s": "processed"},
24-
)
25-
26-
# Store receipt in S3
27-
receipt = {
28-
"order_id": order_id,
29-
"item": order.get("item"),
30-
"quantity": order.get("quantity"),
31-
"status": "processed",
32-
}
33-
s3.put_object(
34-
Bucket=RECEIPTS_BUCKET,
35-
Key=f"receipts/{order_id}.json",
36-
Body=json.dumps(receipt),
37-
ContentType="application/json",
38-
)
49+
# Triggered by SQS: start a state machine execution per order
50+
if "Records" in event:
51+
for record in event["Records"]:
52+
order = json.loads(record["body"])
53+
set_status(order["order_id"], "validating") # fails fast if DDB is faulted → SQS retry → DLQ
54+
sfn.start_execution(
55+
stateMachineArn=STATE_MACHINE_ARN,
56+
name=f"order-{order['order_id']}-{uuid.uuid4().hex[:8]}",
57+
input=json.dumps({"order": order}),
58+
)
59+
return
60+
61+
# Invoked by Step Functions
62+
step = event["step"]
63+
order = event["order"]
64+
65+
if step == "validate": return validate(order)
66+
if step == "process_payment": return process_payment(order)
67+
if step == "fulfill": return fulfill(order)
68+
if step == "handle_failure": return handle_failure(order)
69+
70+
raise ValueError(f"Unknown step: {step}")
71+
72+
73+
def validate(order):
74+
time.sleep(2)
75+
set_status(order["order_id"], "validating")
76+
return order
77+
78+
79+
def process_payment(order):
80+
time.sleep(3)
81+
set_status(order["order_id"], "payment_processing")
82+
return order
83+
84+
85+
def fulfill(order):
86+
time.sleep(2)
87+
set_status(order["order_id"], "fulfilled")
88+
receipt = {k: order[k] for k in ("order_id", "item", "quantity")}
89+
receipt["status"] = "fulfilled"
90+
s3.put_object(
91+
Bucket=RECEIPTS_BUCKET,
92+
Key=f"receipts/{order['order_id']}.json",
93+
Body=json.dumps(receipt),
94+
ContentType="application/json",
95+
)
96+
return order
97+
98+
99+
def handle_failure(order):
100+
set_status(order["order_id"], "failed")
101+
return order

0 commit comments

Comments
 (0)