Skip to content

Commit b6de800

Browse files
committed
add: fixup docs
1 parent c77f36a commit b6de800

2 files changed

Lines changed: 54 additions & 26 deletions

File tree

frontend/docs/pages/cookbooks/workflow-aws-s3.mdx

Lines changed: 54 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,29 @@ import FanoutDiagram from "@/components/FanoutDiagram";
77

88
# Processing Amazon S3 Objects at Scale
99

10-
Amazon S3 provides a robust, predictable way to store and retrieve large blobs of data. It has several properties that make it an ideal backend for processing massive datasets at scale. Namely, it provides strong [read-after-write consistency](https://docs.aws.amazon.com/AmazonS3/latest/userguide/Welcome.html#ConsistencyModel) for `PUT` and `DELETE` requests. This guarantees that when we retrieve or modify data within a bucket, we are always working with the most up-to-date state.
10+
Amazon S3 is a reliable, boring distributed object store with strong [read-after-write consistency](https://docs.aws.amazon.com/AmazonS3/latest/userguide/Welcome.html#ConsistencyModel) guarantees, which makes it an ideal backend for processing massive datasets at scale. Coordinating workloads that actually leverage that scale, while not running up a comically large cloud bill, is non-trivial.
1111

12-
Moreover, S3's data consistency model pairs nicely with Hatchet's [concurrency control](/v1/concurrency) features and dynamic spawning of [child tasks in parallel](/v1/child-spawning#fan-out-spawning-many-children-in-parallel), allowing us to aggressively poll and process S3 objects while sidestepping data races.
12+
This guide walks through building an _embarrassingly parallel_ Hatchet pipeline for ingesting and processing hundreds of thousands of images stored across Amazon S3. We'll be making extensive use of Hatchet's [concurrency control](/v1/concurrency) features, paired with aggressive fan-outs via [dynamic child spawning](/v1/child-spawning#fan-out-spawning-many-children-in-parallel), to parallelize the heck out of our data processing workflow while ensuring fair allocation of resources and idempotent processing of objects.
1313

14-
This example illustrates how to build an _embarrassingly parallel_ Hatchet pipeline for ingesting and processing hundreds of thousands of images stored across Amazon S3. The constraints for the system are as follows:
14+
<figure style={{ margin: "2rem auto", maxWidth: "400px", textAlign: "center" }}>
15+
<img
16+
src="/s3-workflow/spider-man-workers.png"
17+
alt="Three workers all pointing at each other, each claiming to have processed the same S3 object"
18+
style={{ width: "100%", height: "auto", borderRadius: "8px" }}
19+
/>
20+
<figcaption style={{ marginTop: "0.75rem", fontSize: "0.875rem", fontStyle: "italic", opacity: 0.7 }}>
21+
Without concurrency control, nothing stops multiple workers all grabbing, and processing, the same object.
22+
</figcaption>
23+
</figure>
24+
25+
### Scenario
26+
27+
For the purposes of this guide, we'll constrain the system as follows:
1528

1629
- Each bucket corresponds to a separate class of objects.
1730
- Bucket sizes are unevenly distributed.
18-
- Objects are ephemeral. Once successfully processed, the object should be deleted.
19-
- More data can be added or removed dynamically at any point.
31+
- Objects are ephemeral. Once successfully processed, an object should be deleted.
32+
- Data can be added or removed at any point, with a sizable chunk being front-loaded at startup.
2033

2134
## Setup
2235

@@ -44,6 +57,10 @@ _Note: `<your-bucket-prefix>` should be scoped at runtime to the specific bucket
4457
</Tabs.Tab>
4558
</UniversalTabs>
4659

60+
<Callout type="info">
61+
For the purposes of this example, we recommend using `localstack/localstack:4.14`.
62+
</Callout>
63+
4764
</Steps>
4865

4966
### Define the Models
@@ -63,16 +80,14 @@ Notice that we only have models defined for our input data, as the task naturall
6380

6481
## Workflows
6582

66-
To ensure maximum parallelism, we treat the processing of each object within each bucket as an independent unit of work.
67-
68-
The execution sequence is:
83+
To ensure maximum parallelism, we treat the processing of each object within each bucket as an independent unit of work. Concretely, the execution sequence is:
6984

7085
1. Fetch buckets from S3.
7186
2. Fetch objects within each bucket.
7287
3. Process each object.
7388
4. Delete each object.
7489

75-
We use a double fan-out pattern here to ensure that we can distribute the list operations and the processing operations across a horizontally scaled fleet of workers without hitting bottlenecks.
90+
We implement this as a _double fan-out_: one fan-out across buckets to spawn per-bucket polling tasks, and a second fan-out within each polling task to spawn one processing task per object. This lets us distribute both the listing and processing work across a horizontally scaled fleet of workers without any single worker becoming a bottleneck.
7691

7792
<FanoutDiagram
7893
parentLabel="S3::ListBuckets"
@@ -97,9 +112,9 @@ We use a double fan-out pattern here to ensure that we can distribute the list o
97112

98113
We start by fetching all relevant buckets from S3. For simplicity, this pipeline forgoes [S3 Event Notifications](https://docs.aws.amazon.com/AmazonS3/latest/userguide/EventNotifications.html) in favor of periodic scanning via a [cron trigger](/v1/cron-runs).
99114

100-
As the root of the pipeline, this task iterates through buckets serially and fans out a child workflow per bucket. Since it produces input for downstream parallelism rather than executing parallel work itself, concurrent execution offers no benefit at this stage.
115+
As the root of the pipeline, this task paginates across all buckets in our region, fanning out a child workflow per bucket. Since we're just iterating across mini-batches/pages of buckets, there's no meaningful work here to parallelize.
101116

102-
We must guard against overlapping executions. If the task is throttled by the S3 API and the cron fires again before it completes, newer runs should be rejected until the in-flight run finishes.
117+
However, our strategy should still guard against _overlapping executions_ of the same cron job. If S3 throttles us mid-scan and a run takes longer than the cron interval, the next tick should be rejected until the in-flight run finishes, rather than piling on more concurrent listing calls and making the throttling worse.
103118

104119
<FanoutDiagram
105120
parentLabel="Fetch Buckets"
@@ -119,9 +134,9 @@ Taking all of this into account, we define our `fetch_s3_buckets` workflow as fo
119134
</Tabs.Tab>
120135
</UniversalTabs>
121136

122-
Here, we set the concurrency key to the CEL expression `'singleton'`, a string literal that evaluates to the same value for every run. Combined with `max_runs=1` and `CANCEL_NEWEST`, this rule funnels all runs through **a single slot on a single worker**. While one is in-flight, any new run triggered by the cron is immediately canceled.
137+
Here, we set the concurrency key to the CEL expression `'singleton'`, a string literal that evaluates to the same value for every run. Combined with `max_runs=1` and `CANCEL_NEWEST`, this rule funnels all runs through **a single slot on a single worker**: while one is in-flight, any new run triggered by the cron is immediately canceled.
123138

124-
Mapping to our implementation, the `fetch_s3_buckets` workflow is responsible for gathering all relevant buckets from our region and spawning a new `fetch_objects` task from the `fetch_s3_objects` workflow for each.
139+
The task body itself is responsible for gathering all relevant buckets from our region and spawning a new `fetch_objects` task from the `fetch_s3_objects` workflow for each:
125140

126141
<UniversalTabs items={["Typescript", "Python"]}>
127142
<Tabs.Tab title="Typescript">
@@ -132,7 +147,7 @@ Mapping to our implementation, the `fetch_s3_buckets` workflow is responsible fo
132147
</Tabs.Tab>
133148
</UniversalTabs>
134149

135-
For a given trigger, we use the bucket name as a `child_key` to ensure deduplication.
150+
We use the bucket name as its child key for each spawn to ensure deduplication of downstream `fetch_objects` runs.
136151

137152
### Fetch Objects
138153

@@ -149,7 +164,7 @@ Next, we define a workflow responsible for polling a given S3 bucket for objects
149164
showCollect={false}
150165
/>
151166

152-
Similarly to [Fetch Buckets](#fetch-buckets-workflow), this workflow needs to ensure each bucket has at most one poller in-flight at a time, preventing overlaps via the `CANCEL_NEWEST` strategy and `max_runs=1`. Here, however, we group by bucket name using the CEL expression `input.bucket` to enforce per-bucket concurrency.
167+
Similarly to [Fetch Buckets](#fetch-buckets-workflow), this workflow needs to ensure each bucket has at most one poller in-flight at a time, preventing overlaps via the `CANCEL_NEWEST` strategy and constraining to a single run. Here, however, we group by bucket name using the CEL expression `input.bucket` to enforce per-bucket concurrency.
153168

154169
Additionally, we define a global concurrency expression that limits how many bucket pollers can spin up simultaneously. Tasks exceeding this rule are enqueued using the `GROUP_ROUND_ROBIN` strategy and will execute when concurrent slots free up.
155170

@@ -175,20 +190,24 @@ The task itself paginates through the bucket's objects and spawns a processing r
175190
</Tabs.Tab>
176191
</UniversalTabs>
177192

178-
The `child_key` here combines the bucket name and object key `({bucket}/{key})`, guaranteeing that re-triggering the parent won't spawn duplicate processing runs for the same object. S3 object keys are only unique within a specific bucket, so the bucket prefix is mandatory here.
193+
The child key here combines the bucket name and object key `({bucket}/{key})`, guaranteeing that re-triggering the parent won't spawn duplicate processing runs for the same object. S3 object keys are only unique within a specific bucket, so the bucket prefix is mandatory here.
179194

180195
### Process Objects
181196

182-
<AmazonS3ImagePipelineDiagram />
183197

184-
Finally, we define the processing workflow, which represents the bulk of the pipeline's actual computational work. Recall from the previous sections that:
198+
Lastly, we define the processing workflow, which represents the bulk of the pipeline's actual computational work. Recall from the previous sections that:
185199

186200
1. The distribution of objects per bucket is not uniform.
187201
2. Processed objects are immediately deleted.
188202

203+
189204
Rather than capping concurrency globally, we scope the limit _per bucket_ using `S3_WORKER_MAX_RUNS_PER_BUCKET`. A global cap would allow the largest buckets to monopolize all processing slots. A per-bucket cap keeps throughput fair across the system regardless of individual bucket size. When a bucket hits its limit, additional runs are enqueued and dispatched round-robin via `GROUP_ROUND_ROBIN` as slots open.
190205

191-
There is no child-side deduplication on this workflow (no concurrency expression keyed on `{bucket}/{object-key}`). Because `process-object` runs are only ever spawned by [`fetch_objects`](#fetch-objects-workflow)—which already handles deduplication via `child_key` at spawn time—a secondary concurrency expression would be redundant.
206+
<AmazonS3ImagePipelineDiagram/>
207+
208+
The diagram above shows this steady state: work interleaves across buckets, the worker pool runs at partial capacity, and a backlog drains in round-robin order. Even when `bucket-1` has the most pending objects, it doesn't monopolize the workers.
209+
210+
There is no child-side deduplication on this workflow (no concurrency expression keyed on `{bucket}/{object-key}`). Because `process-object` runs are only ever spawned by [`fetch_objects`](#fetch-objects-workflow) (which already handles deduplication via child key at spawn time) a secondary concurrency expression would be redundant.
192211

193212
<UniversalTabs items={["Typescript", "Python"]}>
194213
<Tabs.Tab title="Typescript">
@@ -223,13 +242,13 @@ This architecture provides an idempotent, effectively-exactly-once processing pi
223242

224243
## Test it
225244

226-
The test suite manages LocalStack via [Docker Compose](https://docs.docker.com/compose/). The start and teardown are fully automatic.
227-
228-
Each test seeds 3 buckets with 3 synthetic PNG objects each, triggers `fetch_s3_buckets` manually, and polls S3 until every object has been deleted by the pipeline, validating the full fan-out chain end-to-end.
229-
230245
<UniversalTabs items={["Python", "Typescript"]}>
231246
<Tabs.Tab title="Python">
232247

248+
The test suite manages LocalStack via [Docker Compose](https://docs.docker.com/compose/). Startup and teardown are fully automatic.
249+
250+
The suite seeds 3 buckets with 3 synthetic objects each, triggers `fetch_s3_buckets` manually, then polls S3 until every object has been deleted by the pipeline — validating the full fan-out chain end-to-end.
251+
233252
Make sure `boto3` is installed, then run from `sdks/python/`:
234253

235254
```bash
@@ -239,11 +258,20 @@ pytest examples/aws/s3/test_worker.py
239258
</Tabs.Tab>
240259
<Tabs.Tab title="Typescript">
241260

242-
Install the AWS SDK client, then run the e2e suite filtered to the S3 example from `sdks/typescript/`:
261+
Start LocalStack with Docker Compose from `sdks/python/examples/aws/s3/`:
262+
263+
```bash
264+
docker compose up -d --wait
265+
```
266+
267+
Then start the worker from `sdks/typescript/`:
243268

244269
```bash
245-
npm install @aws-sdk/client-s3
246-
pnpm run test:e2e -- --testPathPattern=aws/s3
270+
S3_WORKER_BUCKET_PREFIX=bucket- \
271+
S3_WORKER_MAX_CONCURRENT_BUCKET_POLLERS=10 \
272+
S3_WORKER_MAX_RUNS_PER_BUCKET=20 \
273+
S3_WORKER_SLOTS=40 \
274+
npx ts-node src/v1/examples/aws/s3/worker.ts
247275
```
248276

249277
</Tabs.Tab>
349 KB
Loading

0 commit comments

Comments
 (0)