Thumbnails and a manifest
An upload becomes a set of thumbnails in several formats and sizes, a manifest describing what was produced, and a notification to the caller.
In this example:
- A thumbnail that cannot be produced is recorded, not fatal.
- A manifest that cannot be stored is fatal, and there is nothing to notify about without it.
- A notification that cannot be delivered is logged, and the run still counts.
Workflow definition#
thumbnails, err := workflow.New("thumbnails",
workflow.WithTimeout(10*time.Minute),
workflow.WithRetention(workflow.RetentionPolicy{Completed: 24 * time.Hour, Failed: 72 * time.Hour}),
workflow.WithAutoPurgeCron("@hourly"),
// Encoding is the expensive part of a worker, so this is the number of encoders per host
workflow.WithConcurrency(runtime.NumCPU()),
workflow.WithSteps(
// Normalizes the request into one item per thumbnail to produce
workflow.Step("plan", workflow.WithRun(planThumbnails)),
// One task per thumbnail
// A task that cannot be encoded is recorded and does not stop the run
workflow.ForEach("generate",
workflow.WithItemsFrom("plan"),
workflow.WithRun(generateThumbnail),
workflow.WithMaxAttempts(3),
workflow.WithFailurePolicy(workflow.TolerateFailures),
),
// The manifest is the durable record of the run
// Without it there is nothing to notify about
workflow.Step("manifest",
workflow.WithRun(writeManifest),
workflow.WithMaxAttempts(5),
workflow.WithSkipOnFailure("notify"),
),
// The thumbnails and the manifest are in the store either way, so a lost notification does not fail the run
workflow.Step("notify",
workflow.WithRun(deliverNotification),
workflow.WithMaxAttempts(10),
workflow.WithRetryBackoff(5*time.Second, 5*time.Minute),
workflow.WithOptional(),
),
),
)Nothing here needs a compensation: a partial result in an object store is not a concern in this example (although it may cause garbage that will need to be collected eventually), and the manifest records what failed.
Handlers#
planThumbnails turns one request into the list the fan-out iterates:
type uploadRequest struct {
SourceKey string `json:"sourceKey"`
Formats []string `json:"formats"`
Sizes [][2]int `json:"sizes"`
Callback string `json:"callback"`
}
type thumbnailSpec struct {
Format string `json:"format"`
Width int `json:"width"`
Height int `json:"height"`
}
func planThumbnails(ctx context.Context, t workflow.Task) (any, error) {
var in uploadRequest
err := t.DecodeInput(&in)
if err != nil {
return nil, errors.Join(actor.ErrJobPermanentFailure, err)
}
specs := make([]thumbnailSpec, 0, len(in.Formats)*len(in.Sizes))
for _, format := range in.Formats {
for _, size := range in.Sizes {
specs = append(specs, thumbnailSpec{Format: format, Width: size[0], Height: size[1]})
}
}
// The fan-out is sized from this array, and the items are fixed once this step returns
return specs, nil
}generateThumbnail reads its item, does the work, and returns a small handle, never the bytes:
type thumbnailResult struct {
Key string `json:"key"`
Width int `json:"width"`
Height int `json:"height"`
Size int64 `json:"size"`
}
func generateThumbnail(ctx context.Context, t workflow.Task) (any, error) {
var in uploadRequest
err := t.DecodeInput(&in)
if err != nil {
return nil, errors.Join(actor.ErrJobPermanentFailure, err)
}
var spec thumbnailSpec
err = t.DecodeItem(&spec)
if err != nil {
return nil, errors.Join(actor.ErrJobPermanentFailure, err)
}
// A source that cannot be decoded fails the same way on every attempt, so it is reported permanently
// A store that is briefly unavailable recovers, so that error is returned as-is and retried
src, err := store.ReadOriginal(ctx, in.SourceKey)
if errors.Is(err, store.ErrNotFound) {
return nil, errors.Join(actor.ErrJobPermanentFailure, err)
} else if err != nil {
return nil, err
}
img, err := encoder.Encode(src, spec)
if errors.Is(err, encoder.ErrUnsupportedSource) {
return nil, errors.Join(actor.ErrJobPermanentFailure, err)
} else if err != nil {
return nil, err
}
// The key is deterministic, so a re-run overwrites the same object rather than leaving a duplicate
key := thumbnailKey(t.InstanceID(), t.Index(), spec)
err = store.Write(ctx, key, img.Data, img.ContentType)
if err != nil {
return nil, err
}
return thumbnailResult{Key: key, Width: img.Width, Height: img.Height, Size: int64(len(img.Data))}, nil
}writeManifest reads the fan-out’s output, an array with {"error": …} in the failed slots, and stores the manifest under a key derived from the instance ID, so a retried write stores the same object:
func writeManifest(ctx context.Context, t workflow.Task) (any, error) {
var results []json.RawMessage
err := t.DecodeOutput("generate", &results)
if err != nil {
return nil, errors.Join(actor.ErrJobPermanentFailure, err)
}
manifest := manifestDoc{InstanceID: t.InstanceID()}
for _, raw := range results {
var thumb thumbnailResult
dErr := json.Unmarshal(raw, &thumb)
if dErr == nil && thumb.Key != "" {
manifest.Produced = append(manifest.Produced, thumb)
continue
}
// A slot that is not a result is a failure, and the manifest is where it is recorded
var failure struct {
Error string `json:"error"`
}
_ = json.Unmarshal(raw, &failure)
manifest.Failed = append(manifest.Failed, failure.Error)
}
key := manifestKey(t.InstanceID())
body, err := json.Marshal(manifest)
if err != nil {
return nil, errors.Join(actor.ErrJobPermanentFailure, err)
}
err = store.Write(ctx, key, body, "application/json")
if err != nil {
return nil, err
}
return manifestResult{Key: key, Produced: len(manifest.Produced), Failed: len(manifest.Failed)}, nil
}deliverNotification posts the manifest key to the callback and treats only a 2xx as delivered:
func deliverNotification(ctx context.Context, t workflow.Task) (any, error) {
var in uploadRequest
err := t.DecodeInput(&in)
if err != nil {
return nil, errors.Join(actor.ErrJobPermanentFailure, err)
}
var manifest manifestResult
err = t.DecodeOutput("manifest", &manifest)
if err != nil {
return nil, errors.Join(actor.ErrJobPermanentFailure, err)
}
status, err := postJSON(ctx, in.Callback, manifest)
if err != nil {
return nil, err
}
if status < 200 || status >= 300 {
return nil, fmt.Errorf("callback returned %d", status)
}
return nil, nil
}Starting the workflow#
svc := thumbnails.Service(host.Service())
id, _, err := svc.Start(ctx, uploadRequest{
SourceKey: "uploads/abc.jpg",
Formats: []string{"webp", "avif"},
Sizes: [][2]int{{320, 240}, {640, 480}, {1280, 960}},
Callback: "https://example.com/hooks/thumbnails",
})Example failures#
Example failures and how they are handled:
- One thumbnail’s format is unsupported.
Its attempt reports a permanent failure, andTolerateFailuresrecords it. The manifest lists it with its error, the notification goes out, and the instance iscompleted.GetStatusshows the failed task inside a completed step. - The object store is unreachable for twenty seconds during the fan-out.
Every in-flight attempt reports a retryable error, so second attempts are scheduled two seconds out and third attempts four seconds out if needed.GetStatusshowsattempts: 2or3on the affected tasks, and the run completes a little later. - The manifest store is down for longer than five attempts cover.
The step fails,WithSkipOnFailurerecordsnotifyas skipped, and the instance terminatesfailed(with nothing to roll back).List(Status: failed)finds it, and a new instance with the same input re-drives it once the store is back. - The callback endpoint returns 503 for an hour.
The notification step exhausts its ten attempts over about twenty-five minutes of backoff.WithOptionalrecords the failure and the instance iscompleted, with the failed step visible inGetStatusand in the step-failure metric.