Skip to content

feat(swf): add Simple Workflow Service emulation - #2257

Merged
pgermosen merged 17 commits into
floci-io:mainfrom
AndreKurait:feat/swf
Aug 14, 2026
Merged

pgermosen merged 17 commits into
floci-io:mainfrom
AndreKurait:feat/swf

Conversation

@AndreKurait

@AndreKurait AndreKurait commented Aug 12, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Closes #2260.

Adds Amazon SWF emulation: all 39 modeled operations over JSON 1.0
(X-Amz-Target: SimpleWorkflowService.*), covering domain and type registration, the
workflow execution state machine, and the decider/worker task lifecycle.

Follows the existing service pattern β€” SwfService (domain logic, throws AwsException),
SwfJsonHandler (protocol only), model/, storage through StorageFactory, registered in
ResolvedServiceCatalog, dispatched from AwsJsonController, and config under
floci.services.swf.

State machine notes:

  • One decision task outstanding per execution. An event arriving while one is
    outstanding sets a pending flag satisfied when that task completes, so a signal cannot
    produce two concurrent decision tasks.

  • Decision batches are all-or-nothing. The whole batch is validated before any of it is
    applied β€” an unknown decisionType, or a closing decision that is not last, is rejected with
    ValidationException leaving no DecisionTaskCompleted, no earlier decision applied, and the
    task token still claimable for a corrected batch. A decision that cannot be
    applied appends its *Failed event with the AWS cause (ACTIVITY_TYPE_DOES_NOT_EXIST,
    ACTIVITY_ID_ALREADY_IN_USE, TIMER_ID_UNKNOWN, …) and gives the decider a fresh task
    instead of failing the request.

  • Closing with open activities, timers, or children appends UNHANDLED_DECISION and
    leaves the execution open.

  • Timeouts (activity schedule-to-start / start-to-close / schedule-to-close /
    heartbeat, decision start-to-close, execution start-to-close, and timers) expire from one
    background sweep rather than a timer per task, so a restart leaves nothing to reconcile.

  • Child workflows link to their parent and report their outcome back; the closing
    parent's childPolicy (TERMINATE / REQUEST_CANCEL / ABANDON) is applied to open
    children.

  • All state is region-scoped. SWF names are unique per region, so storage keys are
    <region>:<domain>, <region>:<domain>/<name>/<version> and
    <region>:<domain>/<workflowId>/<runId>. Registering orders in two regions creates two
    independent domains with their own retention and ARNs; a runId from one region does not
    resolve in another, and a task is only handed to a poller in the region that owns it.

  • Mutation is serialized per region-qualified domain. Several invariants span more than
    one execution β€” StartWorkflowExecution must see a concurrently inserted run of the same
    workflowId, and a closing execution mutates its parent and its children. A parent and a
    child always share a domain, so one lock covers a whole family and never has to be nested;
    nesting would deadlock, since a closing child takes its parent while a closing parent takes
    its children. The region is part of the lock identity so two regions do not serialize
    against each other, and so it agrees with the sweeper's lock derived from a storage key.

  • ScheduleLambdaFunction runs a real function. SWF is otherwise pure coordination β€”
    deciders and activity workers are the user's own processes, so there is no user code for
    the emulator to run β€” but this is the one decision where SWF itself executes something.
    It is invoked through Floci's own Lambda service, in the container Floci starts for it,
    the same way the EventBridge and Scheduler invokers reach Lambda. lambdaRole resolves
    from StartWorkflowExecution then the type's defaultLambdaRole; without one the
    function is never started and StartLambdaFunctionFailed/ASSUME_ROLE_FAILED is
    recorded, matching the live service.

Limitations are documented in docs/services/swf.md rather than left implicit: polls
return immediately instead of long-polling for up to 60s; task tokens are in-memory; and
workflowExecutionRetentionPeriodInDays is stored and returned but closed executions are
not pruned.

Type of change

  • Bug fix (fix:)
  • New feature (feat:)
  • Breaking change (feat!: or fix!:)
  • Docs / chore

AWS Compatibility

Verified with AWS CLI 1.44.78 (botocore 1.42.97) and AWS SDK for Java v2 2.44.14
against the live service (swf.us-east-1.amazonaws.com) and then against Floci. The action
list and shapes come from botocore's swf/2012-01-25/service-2.json; these details were
confirmed on the wire because they are easy to get wrong from the model alone:

Detail Real SWF
Fault __type com.amazonaws.swf.base.model#<Fault>, but the bare name in x-amzn-query-error
Validation faults com.amazon.coral.validate#ValidationException, a different namespace
decisionType enum set in that fault the service's member order, which differs from the API model's
Timestamps epoch-second numbers (1.786560427599E9), not ISO
Domain ARN arn:aws:swf:<region>:<account>:/domain/<name>
Void operations HTTP 200 with an empty body, not {}
Idle poll taskToken: "" rather than omitting the member
Same domain name in two regions both succeed β€” names are unique per region, each ARN naming its own
Closing decision not last HTTP 400, Close must be last decision in list
Invalid decision anywhere in a batch nothing applied, token still claimable
Activity token whose task has closed Unknown activity, scheduledEventId = N
ChildWorkflowExecution* correlation initiatedEventId and startedEventId name different events
Child that continues as new successor keeps parentWorkflowExecution and parentInitiatedEventId
ActivityTaskTimedOut for a never-started task startedEventId: 0
DecisionTaskScheduled always carries scheduleToStartTimeout: "NONE"
PollForDecisionTask + nextPageToken pages the same task (identical startedEventId); never a new claim
startAtPreviousStartedEvent window starts at previousStartedEventId, inclusive
nextPageToken on the last page absent, even when that page is exactly full
maximumPageSize above 1000 ValidationException on every paginated operation, rejected not clamped
Corrupt nextPageToken ValidationException: Invalid token, not a silent restart at page 1
Lambda that cannot be resolved LambdaFunctionFailed/ResourceNotFoundException, after reporting Started
Lambda with no lambdaRole StartLambdaFunctionFailed/ASSUME_ROLE_FAILED; the function never starts
Reused Lambda id accepted β€” no ID_ALREADY_IN_USE, unlike activity ids

Two are worth calling out:

  • botocore prefers the x-amzn-query-error header over the body's __type, so putting the
    namespaced name on both surfaces makes the AWS CLI print
    com.amazonaws.swf.base.model#UnknownResourceFault where real SWF prints
    UnknownResourceFault. SwfException keeps getErrorCode() bare and overrides
    jsonType() to add the namespace, reproducing the live service on both surfaces.
  • Where being lenient was tempting, the live service is strict. It rejects a decision batch
    whose closing decision is not last rather than applying the prefix, so accepting it would
    let a decider pass locally and fail against AWS.

Checklist

  • ./mvnw test passes locally
  • New or updated integration test added
  • Commit messages follow Conventional Commits
Test results
SwfIntegrationTest          Tests run: 45, Failures: 0, Errors: 0
SwfServiceTest              Tests run: 46, Failures: 0, Errors: 0
SwfLambdaIntegrationTest    Tests run:  3, Failures: 0, Errors: 0
SwfTimeoutSweeperTest       Tests run:  2, Failures: 0, Errors: 0
SwfTest (SDK v2)            Tests run:  9, Failures: 0, Errors: 0
SwfLambdaTest (SDK v2)      Tests run:  1, Failures: 0, Errors: 0
SwfRegionPaginationTest     Tests run:  5, Failures: 0, Errors: 0  (SDK v2, two regions)

full suite                  9870 tests, 0 failures, 2 errors (see below)
  • SwfServiceTest β€” unit tests driving timeout expiry through MutableClock rather than
    wall time, plus the decision-outstanding invariant, child policy, continue-as-new, and the
    two concurrency invariants (one open run per workflowId; a parent's history stays
    contiguous while several children close against it).
  • SwfIntegrationTest β€” asserts the wire shapes and exact fault strings captured from the
    live service.
  • SwfTimeoutSweeperTest β€” a misconfigured sweep interval must not stop startup.
  • SwfLambdaIntegrationTest β€” deploys a real function through Floci's Lambda API and
    drives ScheduleLambdaFunction against it, asserting the recorded result is what the
    container returned, so it cannot pass against a synthesised event. Kept in its own class
    because these are the only SWF tests needing a Docker daemon.
  • compatibility-tests/sdk-test-java/…/SwfTest.java β€” drives the same flows through the AWS
    SDK v2 client, so responses must deserialize into the generated model (epoch seconds into
    Instant, attributes into the right *EventAttributes member) and faults into typed
    exceptions (UnknownResourceException, DomainAlreadyExistsException,
    TypeAlreadyExistsException, WorkflowExecutionAlreadyStartedException).

Each behaviour fix in this branch ships a guard that fails on the code before it, so the
tests demonstrate the bug rather than just covering the fix.

The 2 errors in the local run are TlsIntegrationTest / TlsUserCertIntegrationTest, both
QuarkusBindException: Port already bound: 8444 β€” an unrelated process holds that port on
this host. CI, which has the port free, runs both to green as part of a fully passing suite.

Also verified in the packaged Docker image (docker/Dockerfile): swf reports running in
/_floci/health, the AWS CLI drives a full lifecycle to the same 11-event history as the live
service, ScheduleLambdaFunction runs a real Python function in a sibling container, the
timeout sweep produces DecisionTaskTimedOut{START_TO_CLOSE} followed by a fresh
DecisionTaskScheduled, and faults render with the AWS error codes and messages.

Docs

docs/services/swf.md with the action table generated by
tools/docs/regen_action_docs.py (SWF registered in tools/docs/services.yaml;
make docs-check passes), plus mkdocs nav and the two README tables.

Implements all 39 SWF operations over the JSON 1.0 protocol
(X-Amz-Target: SimpleWorkflowService.*): domain and type registration,
the workflow execution state machine, and the decider/worker task
lifecycle.

Behaviour was derived from botocore's swf-2012-01-25 model and verified
against the live service, which drove several details that are easy to
get wrong from the model alone:

- Faults carry a namespaced __type (com.amazonaws.swf.base.model#Fault)
  in the body but the bare fault name in x-amzn-query-error. botocore
  prefers the header, so a namespaced value there makes the CLI print
  "com.amazonaws.swf.base.model#UnknownResourceFault" where SWF prints
  "UnknownResourceFault". SwfException keeps the two spellings apart.
- Timestamps are epoch-second numbers, not ISO strings.
- Void operations answer 200 with an empty body rather than {}.
- An idle poll returns taskToken "" rather than omitting the member;
  the SDKs read the empty token as "no task".
- ActivityTaskTimedOut reports startedEventId 0 for a task that never
  started, and WorkflowExecutionStarted reports parentInitiatedEventId 0
  on root executions, rather than omitting either member.

State machine:

- One decision task outstanding per execution. Events arriving while one
  is outstanding set a pending flag that is satisfied when it completes,
  so a signal cannot produce two concurrent decision tasks.
- Decisions apply in order; a decision after a closing decision is
  dropped. A decision that cannot be applied appends its *Failed event
  with the AWS cause and gives the decider a fresh task instead of
  failing the request.
- Closing with open activities, timers, or children appends
  UNHANDLED_DECISION and leaves the execution open.
- Timeouts (activity schedule-to-start, start-to-close,
  schedule-to-close, heartbeat; decision start-to-close; execution
  start-to-close; timers) expire from one background sweep rather than a
  timer per task, so a restart leaves nothing to reconcile.
- Child executions link to their parent and report their outcome back;
  the closing parent's childPolicy is applied to open children.

Tests: 29 unit tests drive timeout expiry through a controllable clock,
32 integration tests assert the wire shapes and fault strings captured
from the live service, and a compatibility suite exercises the same
flows through the AWS SDK v2 client so responses must deserialize into
the generated model and faults into typed exceptions.

Long polling, ScheduleLambdaFunction invocation, and retention-based
pruning are not implemented; docs/services/swf.md records each.

Signed-off-by: Andre Kurait <akurait@amazon.com>
@github-actions

Copy link
Copy Markdown

πŸŽ‰ Thanks for your first pull request to Floci!

Your CI checks need a maintainer to approve them before they run. That is GitHub's standard gate on first-time contributors, not a problem with your PR β€” so if the checks look like they are doing nothing, that is why. Once a maintainer approves, CI and the compatibility suite start automatically. Nothing is needed from you in the meantime.

While you wait, a couple of things that make review faster:

  • Link the issue this fixes with Closes #N in the description
  • Commits follow Conventional Commits (feat(s3): ..., fix(dynamodb): ...)
  • Behaviour changes come with a test β€” see CONTRIBUTING.md

Come join us in Slack β€” it is the fastest way to reach maintainers if you get stuck, or want feedback on an approach before investing more time in it.

@greptile-apps

greptile-apps Bot commented Aug 12, 2026 •

Copy link
Copy Markdown

Greptile Summary

The PR adds Amazon Simple Workflow Service emulation, including JSON protocol routing, region-scoped persistent state, workflow and task lifecycles, timeout sweeping, child workflows, pagination, and Lambda invocation.

  • Registers and configures the new SWF service and its 39 modeled operations.
  • Adds domain, type, execution, decision, activity, timer, child-workflow, and timeout models.
  • Adds SDK, integration, concurrency, regional-isolation, pagination, and Lambda execution coverage.
  • Documents supported behavior, configuration, and known emulator limitations.

Confidence Score: 4/5

The PR is not yet safe to merge because a valid decision batch can invoke a real Lambda after the workflow has already closed and discard its terminal result.

ScheduleLambdaFunction records Started and queues the invocation, but queued Lambda work is absent from the workflow's open-work check; a following closing decision therefore closes the execution before the deferred invocation runs, after which the function's side effect occurs and its result is dropped.

Files Needing Attention: src/main/java/io/github/hectorvent/floci/services/swf/SwfService.java

Important Files Changed

Filename Overview
src/main/java/io/github/hectorvent/floci/services/swf/SwfService.java Implements the SWF state machine, region-qualified domain serialization, decision and worker lifecycles, timeout processing, child workflows, pagination, and deferred Lambda invocation.
src/main/java/io/github/hectorvent/floci/services/swf/SwfJsonHandler.java Maps the modeled JSON 1.0 SWF operations between protocol requests and service methods.
src/main/java/io/github/hectorvent/floci/services/swf/SwfTimeoutSweeper.java Adds periodic timeout processing with a safe fallback for non-positive configured intervals.
src/main/java/io/github/hectorvent/floci/core/common/AwsJsonController.java Extends shared JSON request routing to dispatch SimpleWorkflowService targets.
src/main/java/io/github/hectorvent/floci/services/swf/SwfLambdaInvoker.java Connects SWF Lambda decisions to Floci's Lambda execution infrastructure.
src/test/java/io/github/hectorvent/floci/services/swf/SwfServiceTest.java Adds broad state-machine, timeout, concurrency, child-workflow, pagination, and Lambda lifecycle coverage.
docs/services/swf.md Documents SWF actions, configuration, behavior, and emulator limitations.

Sequence Diagram

sequenceDiagram
    participant SDK as AWS SDK / CLI
    participant C as AwsJsonController
    participant H as SwfJsonHandler
    participant S as SwfService
    participant Store as StorageBackend
    participant L as Lambda Service
    SDK->>C: JSON 1.0 request
    C->>H: Dispatch SimpleWorkflowService action
    H->>S: Parsed operation request
    S->>Store: Read or mutate region-scoped SWF state
    opt ScheduleLambdaFunction
        S->>L: Invoke configured Lambda
        L-->>S: Invocation result
        S->>Store: Append terminal Lambda event
    end
    Store-->>S: Updated state
    S-->>H: Operation result
    H-->>SDK: AWS-compatible JSON response
Loading

Reviews (15): Last reviewed commit: "Merge branch 'main' of https://github.co..." | Re-trigger Greptile

…* events

A ChildWorkflowExecution* event carries two correlation ids, and they point at
different events in the parent's history: initiatedEventId is the
StartChildWorkflowExecutionInitiated id, startedEventId is the
ChildWorkflowExecutionStarted id. Both were reported as the initiated id, so a
decider correlating a child's outcome on startedEventId matched the wrong event.

Verified against the live service, where a parent that starts one child and sees
it complete produces:

  5 StartChildWorkflowExecutionInitiated
  6 ChildWorkflowExecutionStarted        {initiatedEventId: 5}
  8 ChildWorkflowExecutionCompleted      {initiatedEventId: 5, startedEventId: 6}

The child now records the parent's ChildWorkflowExecutionStarted id at start
time and reports it back on every ChildWorkflowExecution* event.

The integration test asserts both ids against the events they name rather than
against a "greater than zero" placeholder, and fails on the previous behaviour
with "startedEventId Expected: <6> Actual: <5>".

Signed-off-by: Andre Kurait <akurait@amazon.com>
RespondDecisionTaskCompleted accepted a batch with decisions after a closing
decision and silently dropped the remainder. The live service rejects the whole
batch instead:

  HTTP 400
  {"__type":"com.amazon.coral.validate#ValidationException",
   "message":"Close must be last decision in list"}

Silently accepting it hides a decider bug and diverges on both the status code
and the response body, so a decider that ships a malformed batch sees success
locally and a 400 against AWS.

The batch is now validated before any state is mutated, matching the live
service's behaviour of leaving the decision task outstanding so the decider can
retry with a corrected batch. Note this fault is namespaced
com.amazon.coral.validate rather than com.amazonaws.swf.base.model.

Tests assert the status code, both fault spellings, the message, that no
DecisionTaskCompleted event is appended, and that the same task token still
works afterwards. They fail on the previous behaviour with "Expected status code
<400> but was <200>".

Signed-off-by: Andre Kurait <akurait@amazon.com>
…closed

An activity task token that resolves to a task which is no longer open reported
"Unknown or expired task token". The live service distinguishes the two cases: a
genuine token whose task has closed names the scheduled event instead, because
the token itself was valid.

Captured from the live service, heartbeating a task that a cancel had already
closed:

  HTTP 400
  {"__type":"com.amazonaws.swf.base.model#UnknownResourceFault",
   "message":"Unknown activity, scheduledEventId = 5"}

Both cases are UnknownResourceFault, so SDK error typing was already correct and
only the message diverged β€” but a worker that logs or matches on the message sees
a different string locally than against AWS.

A token that never existed still reports the bad token; only a resolved token on
a closed task reports the scheduled event. This required keeping the token's map
entry when an activity closes, since removing it made both cases indistinguishable;
reuse is still refused by the existing open-state check.

Test asserts both branches and fails on the previous behaviour with "expected:
<Unknown activity, scheduledEventId = 5> but was: <Unknown or expired task token>".

Signed-off-by: Andre Kurait <akurait@amazon.com>
… interval

Addresses three review findings.

Concurrent StartWorkflowExecution calls for one workflowId all scanned for an
open run before any of them inserted, so each persisted its own run key. Eight
concurrent starts produced eight open runs where SWF admits one, and later
operations without a runId then selected an arbitrary run. The open-run check
and the insert are now one atomic step.

A closing execution mutates its parent (ChildWorkflowExecution* events) and its
children (childPolicy), but held only its own lock, so event-id allocation and
the history list raced. Six children closing against one parent reproduced
ConcurrentModificationException.

Both come from the same mismatch: the lock was per execution while the
invariants span an execution family. The lock is now per domain. A parent and a
child always share a domain, so one lock covers the family and never has to be
nested β€” nesting would deadlock, since a closing child takes its parent while a
closing parent takes its children. Per-domain granularity is sufficient here:
the emulator serves a single developer's workload, not production concurrency.

A zero or negative timeout-sweep interval reached scheduleWithFixedDelay, which
threw IllegalArgumentException out of the StartupEvent observer and stopped the
whole emulator becoming ready β€” one bad SWF setting taking down every other
service. A non-positive interval now falls back to 1s with a warning.

Tests fail on the previous behaviour with "expected: <1> but was: <8>",
ConcurrentModificationException, and IllegalArgumentException respectively.
Verified over the wire too: eight concurrent starts yield one success, seven
WorkflowExecutionAlreadyStartedFault, and one open run.

Signed-off-by: Andre Kurait <akurait@amazon.com>
@AndreKurait

Copy link
Copy Markdown
Contributor Author

Thanks for the review β€” all three P1s were real. Fixed in 43cdb1a4, with replies on each thread.

The first two shared a root cause worth naming: mutation was serialized per execution, but several SWF invariants span an execution family. StartWorkflowExecution has to see a concurrently inserted run of the same workflowId, and a closing execution mutates its parent and its children. Taking the related execution's lock would deadlock (a closing child wants its parent; a closing parent wants its children), so the lock is now per domain β€” a parent and a child always share one, which covers the family without nesting.

Both were reproduced before being fixed, and each guard fails on the previous code:

Finding Reproduced as
Duplicate starts 8 concurrent starts β†’ 8 open runs (expected: <1> but was: <8>)
Unlocked family mutation 6 children closing on one parent β†’ ConcurrentModificationException
Invalid sweep interval IllegalArgumentException out of the StartupEvent observer

The duplicate-start fix is also confirmed over the wire, not just in-process: 8 concurrent StartWorkflowExecution calls yield 1 success, 7 WorkflowExecutionAlreadyStartedFault, and 1 open run.

For the sweep interval I chose a warned fallback to 1s rather than failing fast, since taking every other service down over one misconfigured SWF setting is the worse outcome for a local emulator. Happy to switch it to a hard startup failure if you'd prefer that convention.

Also worth flagging from your summary: earlier commits on this branch fixed three wire-level divergences found by continuing to diff captured live-service traffic against the implementation β€” child-workflow correlation ids, the closing-decision-must-be-last rejection, and the Unknown activity, scheduledEventId = N fault. Those are folded into the description rather than left as a running commentary; the branch history has the detail per commit.

Current state: SwfIntegrationTest 33/33, SwfServiceTest 33/33, SwfTimeoutSweeperTest 2/2, SDK v2 SwfTest 9/9, full suite 9349 tests / 0 failures (the 2 errors are the pre-existing Port already bound: 8444 conflict, reproducible on unmodified ea74b979 on this host). Docker image rebuilt at the final commit and re-verified.

DeleteWorkflowType/DeleteActivityType against a still-registered type
reported the type descriptor, e.g. "WorkflowType=[name=W, version=1]". The
live service answers with prose instead, and uses the same wording for both
type kinds:

  {"__type":"com.amazonaws.swf.base.model#TypeNotDeprecatedFault",
   "message":"The type is currently registered and cannot be deleted in its
              current state"}

Since both variants are identical, the two fault factories collapse into one.
The descriptor form is still correct for the other type faults
(TypeDeprecatedFault, TypeAlreadyExistsFault, UnknownResourceFault), which were
verified unchanged in the same probe.

Also adds integration coverage for the five actions that had none at either
test layer: DeprecateActivityType, UndeprecateActivityType, DeleteActivityType,
ListWorkflowTypes, and RespondActivityTaskFailed. Every assertion is taken from
a live-service capture, including that scheduling a deprecated activity type
appends ScheduleActivityTaskFailed with cause ACTIVITY_TYPE_DEPRECATED and
leaves the execution open, that ListWorkflowTypes filters by registration
status and name, and that a second RespondActivityTaskFailed on the same token
reports "Unknown activity, scheduledEventId = 5".

The message tests fail on the previous behaviour with "Expected: The type is
currently registered ... Actual: WorkflowType=[name=TestWf, version=1.0]".

Signed-off-by: Andre Kurait <akurait@amazon.com>
The decision was recorded as ScheduleLambdaFunctionFailed with cause
LAMBDA_SERVICE_NOT_AVAILABLE_IN_REGION rather than invoking anything. SWF is
otherwise a pure coordination service β€” deciders and activity workers are the
user's own processes, so there is no user code for the emulator to run β€” but
ScheduleLambdaFunction is the one decision where SWF itself executes something,
and Floci already runs Lambda functions in containers. A decider that fans work
out to Lambda could not be exercised locally at all.

Floci now invokes the function through its own Lambda service, the same way the
EventBridge and Scheduler invokers do, and records the outcome. Verified against
the live service:

  5 LambdaFunctionScheduled  {control, id, input, name, startToCloseTimeout}
  6 LambdaFunctionStarted    {scheduledEventId: 5}
  7 LambdaFunctionCompleted  {result, scheduledEventId: 5, startedEventId: 6}
  8 DecisionTaskScheduled

Four behaviours came from that capture and are easy to get wrong:

- A function that cannot be resolved is LambdaFunctionFailed with Lambda's own
  error code as reason (ResourceNotFoundException) β€” not
  ScheduleLambdaFunctionFailed β€” and it still reports Started first.
- An execution with no lambdaRole never starts the function at all: Scheduled is
  followed by StartLambdaFunctionFailed with cause ASSUME_ROLE_FAILED and the
  message "No IAM role is attached to the current workflow execution."
- lambdaRole resolves from StartWorkflowExecution's lambdaRole, falling back to
  the workflow type's defaultLambdaRole, and is echoed in the started event.
- An absent input reaches the handler as {}, and reusing an id is accepted β€”
  there is no ID_ALREADY_IN_USE for Lambda the way there is for activity ids.

SwfService takes a LambdaInvoker seam rather than LambdaService directly, so the
unit tests keep running without a Docker daemon while SwfLambdaInvoker wires the
real thing at runtime. The invocation is RequestResponse because the response
body is what gets recorded as result; the fire-and-forget invokers use Event for
the opposite reason.

The Lambda region comes from the execution's domain ARN, so a workflow in one
region does not invoke a function in another.

Tests: SwfServiceTest covers all four paths against a recording invoker and
fails on the previous behaviour with "the function must actually be invoked
==> expected: <1> but was: <0>". SwfLambdaIntegrationTest and the SDK
compatibility SwfLambdaTest deploy a real function and assert the recorded
result is what the container returned, so neither passes against a synthesised
event. Docker-backed cases live in their own class so a Docker-less run loses
only them rather than the whole SWF wire suite.

Signed-off-by: Andre Kurait <akurait@amazon.com>

@abanna abanna 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.

Thank you for the exceptionally thorough SWF implementation and the live-AWS measurements throughout the commit history. I ran all four focused SWF suitesβ€”81 tests passed, including the Docker-backed Lambda integration. Two AWS compatibility gaps remain:

  1. SWF state is not region-scoped. Registering orders in us-east-1 stores it under the bare key orders; registering the same domain in eu-west-1 then fails as a duplicate, and describe/list operations can expose the first region’s domain and ARN. AWS requires the name to be unique only within the region: RegisterDomain.
  2. ListDomains, ListWorkflowTypes, and ListActivityTypes ignore maximumPageSize and nextPageToken, return the entire result set, and never emit a continuation token. AWS explicitly defines pagination for these operations: ListDomains, ListWorkflowTypes, and ListActivityTypes.

Please namespace the domain/type/execution storage consistently by region and add AWS SDK regression tests for cross-region isolation and registration-list pagination. Thank you againβ€”this is a substantial and thoughtfully tested contribution.

@pgermosen pgermosen added enhancement New feature or request waiting-contributor Deprecated: use waiting-author labels Aug 13, 2026
@hectorvent hectorvent added the swf Amazon Simple Workflow Service (SWF) label Aug 13, 2026
Addresses two AWS compatibility gaps from review.

SWF names are unique per region, but storage keys were the bare name, so
registering a domain in us-east-1 made the same name in eu-west-1 fail as a
duplicate, and describe/list could return the first region's domain and ARN.
Verified against the live service: the same name registers in both regions with
independent descriptions and retention periods, each ARN naming its own region;
a type registered in one region answers UnknownResourceFault in the other; and
executions, including their runIds, do not cross.

Keys are now region-qualified β€” `<region>:<domain>` for domains,
`<region>:<domain>/<name>/<version>` for types, and
`<region>:<domain>/<workflowId>/<runId>` for executions β€” and every store scan
is prefixed by region. The region reaches the service on each call and is stored
on the execution, which is also what task tokens resolve through, so a token
issued in one region cannot address another's execution. The per-domain
mutation lock is region-qualified too; leaving it bare would have let two
regions serialize against each other, and would have disagreed with the
sweeper's own lock derived from the storage key.

ListDomains, ListWorkflowTypes and ListActivityTypes ignored maximumPageSize and
nextPageToken and returned everything. They now share one paging helper with the
history and execution listings. Details taken from live captures rather than the
docs: nextPageToken is absent on the final page even when that page is exactly
full, a maximumPageSize of 0 or absent means "no caller limit", above 1000 is
rejected rather than clamped, and a corrupt token answers
`ValidationException: Invalid token` instead of silently restarting at the first
page β€” which would make a paging loop repeat forever.

Adds AWS SDK regression tests (SwfRegionPaginationTest) driving two clients at
different regions against the same emulator, plus wire-level integration
coverage. Against the previous implementation those SDK tests fail with
DomainAlreadyExists on the second region, "maximumPageSize must cap the page: 5",
and nothing thrown for an oversized page size.

Signed-off-by: Andre Kurait <akurait@amazon.com>
@AndreKurait

Copy link
Copy Markdown
Contributor Author

Thank you β€” both gaps were real, and I measured each against the live service before fixing it. Both are addressed in 24ed45e8.

1. Region scoping

Confirmed exactly as you described: keys were the bare name, so orders in eu-west-1 collided with us-east-1. The live-service measurement:

Check Real SWF
Same domain name, two regions both 200, independent description and retention
ARN arn:aws:swf:us-east-1:… vs arn:aws:swf:eu-west-1:…
Type registered in one region UnknownResourceFault from the other
ListWorkflowTypes only the caller's region
Same workflowId in both two independent runs; a runId does not cross

Keys are now region-qualified β€” <region>:<domain>, <region>:<domain>/<name>/<version>, <region>:<domain>/<workflowId>/<runId> β€” and every store scan is prefixed by region.

Two consequences worth calling out, since neither was in the report:

  • The mutation lock had to move too. domainLock keyed on the bare domain would have serialized two regions against each other, and worse, it would have disagreed with the sweeper's lock, which derives its scope from the storage key. Both now resolve to the same region-qualified domain.
  • Task tokens needed no separate change, because they resolve through the (now region-scoped) execution key β€” so a token issued in one region cannot address another's execution. There is a test for that.

2. Registration-list pagination

Also confirmed. Four details came from the captures rather than the docs, and I would have gotten two of them wrong by guessing:

  • nextPageToken is absent on the final page even when that page is exactly full (6 items at size 2 β†’ 3 pages, no token on the third).
  • maximumPageSize: 0 means "no caller limit", not "empty page".
  • Above 1000 is rejected, not clamped: Value '1001' at 'maximumPageSize' failed to satisfy constraint: Member must have value less than or equal to 1000.
  • A corrupt token answers ValidationException: Invalid token. This one matters most β€” the previous helper silently restarted at offset 0, which turns a paging loop into an infinite one. The three list operations now share one paging helper with the history and execution listings.

Tests

SwfRegionPaginationTest in the compatibility suite drives two SDK clients at different regions against the same emulator, as you asked. Against the previous implementation all five fail, reproducing your report:

DomainAlreadyExists sdk-region-… (Status Code: 400)      <- the duplicate-region bug
maximumPageSize must cap the page: 5 ==> expected: <true> but was: <false>
Expected SwfException to be thrown, but nothing was thrown   <- 1001 accepted
expected: <3> but was: <4>                                  <- page size ignored

Plus wire-level integration tests and unit tests for the page-size limits. Current SWF totals: SwfIntegrationTest 40, SwfServiceTest 42, SwfLambdaIntegrationTest 3, SwfTimeoutSweeperTest 2, and SDK SwfTest 9 / SwfLambdaTest 1 / SwfRegionPaginationTest 5.

One incidental find while sweeping for unscoped keys: listExecutions had also been scanning on an unqualified prefix, so it would have leaked across regions once domains were shared. Fixed in the same pass and covered by typesAndExecutionsAreIsolatedPerRegion.

The registration lists reverse the whole result set and then page it, so with
maximumPageSize=3 and reverseOrder=true the first page is the tail of the
ascending order. That was already the behaviour but nothing asserted it, and the
shared paging helper makes "page first, then reverse the page" an easy
regression to introduce.

Measured against the live service: 5 types at page size 3 reversed returns
Paged4, Paged3, Paged2 and then Paged1, Paged0. Injecting the page-then-reverse
ordering fails the new test with
"Expected: <[Paged4, Paged3, Paged2]>".

Signed-off-by: Andre Kurait <akurait@amazon.com>
The region-scoping tests went in at the service and SDK layers, but every
SwfIntegrationTest request omitted an Authorization header, so nothing asserted
that the region is read from the SigV4 credential scope β€” the step that decides
which region's state a call addresses.

Adds a wire test that sends a credential scope naming the region, covering the
same name registering in two regions with independent descriptions, retentions
and ARNs, a type being unknown in the other region, and a same-region
re-registration still faulting.

Against 7cc6c0f the test fails with
DomainAlreadyExistsFault / "Expected status code <200> but was <400>".

Signed-off-by: Andre Kurait <akurait@amazon.com>
@greptile-apps

greptile-apps Bot commented Aug 13, 2026

Copy link
Copy Markdown

Want your agent to iterate on Greptile's feedback? Try greploops.

@abanna

abanna commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Thank you for the very thorough follow-up here. I re-reviewed the full change at 4cea487e, including the later commits and all of the discussion. The two items from my earlier reviewβ€”regional isolation and registration-list paginationβ€”look well addressed, and I appreciate the live-service measurements plus the SDK and signed-wire coverage you added.

I did find four remaining correctness issues that I think should be addressed before this is ready:

  1. PollForDecisionTask continuation paging does not return the same task. The handler always calls service.pollForDecisionTask(...) before interpreting nextPageToken. After the first page has claimed the task, a continuation request therefore returns an empty/new task instead of the next history page for the original task. AWS explicitly says that calling PollForDecisionTask with the returned token must not return a new decision task. startAtPreviousStartedEvent also appears to be ignored.

  2. A child workflow that continues as new loses its parent relationship. continueAsNew creates the successor and sets continuedExecutionRunId, but it does not carry over the parent identifiers or update the parent's childExecutions entry to the successor run. In a focused reproduction, the successor's parent was null. That leaves the parent tracking the now-closed original run, so it can stall and never receive the successor's eventual outcome or apply child policy to it.

  3. An invalid decision later in a batch can still consume the task and apply earlier decisions. respondDecisionTaskCompleted appends DecisionTaskCompleted, removes the token, and applies decisions before every decision type has been validated. With [RecordMarker, NotARealDecision], the request failed, but the history still grew from 3 to 5 with both DecisionTaskCompleted and MarkerRecorded, and the original task token was no longer retryable. The batch should be validated before any state is mutated, as is already done for the β€œclose must be last” rule.

  4. The synchronous Lambda invocation still holds the domain-wide lock. This matches the current Greptile thread. A blocking invoker prevented a signal to an unrelated workflow in the same domain from completing until the Lambda was released. The invocation needs to happen outside the domain monitor while preserving an atomic state transition around the recorded result.

I reproduced all four with small focused regression probes, then removed those probes from the checkout. The existing focused SWF suite still passes all 89 tests, including the Docker-backed Lambda tests, and the current CI is greenβ€”these are gaps that the current coverage does not exercise.

This is a substantial and thoughtfully tested addition, and the response to the earlier feedback was excellent. I think it is close; these remaining state-machine and polling cases are the pieces I would want pinned down before merging.

@pgermosen pgermosen left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@AndreKurait β€” I checked all four of @abanna's findings against the code at 4cea487e rather than restating them, because a second reviewer repeating a list is not much use to you. All four are real. I would rather you hear that from an independent read than spend your evening working out which ones to believe.

First, the part that is not in doubt: this is an exceptional contribution. Thirty-nine operations, two dozen wire-level details measured against the live service rather than inferred from the model, red-green guards on each behaviour fix, and ten commits answering every round so far. @abanna's earlier two items β€” region scoping and registration-list pagination β€” are properly closed. The remaining four are state-machine corners, not a problem with the design.

What I confirmed

1. PollForDecisionTask continuation β€” confirmed, and AWS states it in as many words.

SwfJsonHandler:491 calls service.pollForDecisionTask(...) unconditionally, and only reads nextPageToken at line 503. The API reference says:

Calling PollForDecisionTask with a nextPageToken doesn't return a new decision task.

Adding to @abanna's note on startAtPreviousStartedEvent: git grep -c startAtPreviousStartedEvent over src/ returns zero hits. It is a documented, modeled request parameter that is currently read nowhere, so a decider passing true silently gets the full history instead of events since the last DecisionTaskStarted.

2. continueAsNew drops the parent β€” confirmed.

SwfWorkflowExecution carries parentWorkflowId, parentRunId, parentInitiatedEventId, parentStartedEventId and childExecutions. continueAsNew builds the successor from a StartWorkflowExecutionRequest, which has no parent fields, and then sets only continuedExecutionRunId. The only parent/child tokens anywhere in the method body are childPolicy, which is a different thing entirely.

3. Partial mutation β€” confirmed, and I think it needs a different fix than it first looks.

applyDecision's default -> throw SwfFaults.validationConstraint(...) fires mid-loop, after DecisionTaskCompleted has been appended, decisionTokens.remove(taskToken) has run, and any earlier decisions have been applied.

The part worth flagging: this cannot be fixed with a try/catch around applyDecisions. decisionTokens is a separate map, and the history mutations land on the in-memory execution object, so executionStore.put(...) never being reached does not undo them. The fix is to validate every decision type up front β€” exactly the shape requireCloseIsLastDecision already has two lines above, which is a good precedent to extend rather than a new pattern to invent.

4. Lambda under the domain lock β€” confirmed.

lambdaInvoker.invoke(...) is a synchronous call inside scheduleLambdaFunction, reached from respondDecisionTaskCompleted's synchronized (domainLock(located)). By your own design note that lock deliberately covers a whole domain family, so a cold-start invocation stalls every unrelated execution in the domain for its duration.

Suggested order, if it helps

Not all four are equal, and you should not have to guess which matter most:

  1. #3 first. It leaves corrupt history and an unretryable token, so the decider has no recovery path at all. Bad persisted state is the hardest kind to walk back once anyone builds on it.
  2. #1 next. It is a documented contract violation that hits any execution whose history exceeds one page β€” which is every long-running workflow, i.e. SWF's whole point.
  3. #2 is real but needs the parent + child + continue-as-new combination to show up.
  4. #4 is liveness, not correctness. If you want to scope this PR down, I would accept it as a tracked follow-up rather than hold the service on it β€” say the word and I will file the issue. The other three I would want in.

On the bar here

This is a new service with nothing depending on it yet, which normally argues for a more permissive bar, and I do not want to hold a contribution of this quality over polish. #3 is the one that changes my mind: history corruption in a brand-new event-sourced service is precisely the thing that becomes unfixable once people have workflows running against it.

Nothing here is a re-litigation of design β€” the region scoping, the per-domain lock rationale, the decision-outstanding invariant and the live-service fault strings all hold up well. Happy to re-review quickly whenever you push, and thank you for the care that has gone into this one.

An invalid decision anywhere in a batch could still mutate the execution.
respondDecisionTaskCompleted appended DecisionTaskCompleted, dropped the token
and applied earlier decisions before applyDecision's default arm rejected an
unknown decisionType mid-loop, so [RecordMarker, NotARealDecision] grew the
history from 3 to 5 events and burned the token. A try/catch cannot undo that β€”
decisionTokens is a separate map and the history lands on the in-memory
execution β€” so the whole batch is now validated up front, extending the shape
requireCloseIsLastDecision already used. Both rules live in one requireValidBatch,
and the accepted types come from SwfConstants.DECISION_TYPES so the check and the
message quoting the enum set cannot drift.

PollForDecisionTask with a nextPageToken claimed a new task instead of paging the
original. AWS states that "calling PollForDecisionTask with a nextPageToken
doesn't return a new decision task", and the live service answers a continuation
with the same startedEventId, previousStartedEventId and workflowExecution, plus
the next contiguous history page. The continuation token now names the task it
belongs to, so a continuation resolves that task rather than polling again.
startAtPreviousStartedEvent was modeled but read nowhere; it now trims the window
to events from previousStartedEventId onward, which the live service treats as
inclusive.

A child that continued as new lost its parent. The successor was built from a
StartWorkflowExecutionRequest, which carries no parent fields, so its parent was
null and the parent kept tracking the closed run β€” never learning the successor's
outcome and never applying child policy to it. carryParentTo moves the parent
identifiers and repoints the parent's childExecutions entry before the successor's
WorkflowExecutionStarted is appended, so that event names the parent as it does
on the live service.

The Lambda invocation held the domain lock. That lock deliberately covers a whole
domain family, so a cold start stalled every unrelated execution in the domain.
A decision now queues the invocation, records LambdaFunctionScheduled and
LambdaFunctionStarted under the lock, and the invocation runs after the lock is
released; the outcome is recorded under the lock again, so the state transition
around the result stays atomic.

Guards, each failing on the code before it: the batch case with
"a rejected batch must not grow the history ==> expected: <3> but was: <5>", the
continue-as-new case with "the successor of a child must keep its parent ==>
expected: <wf-cn-parent> but was: <null>", the two polling cases on an empty
continuation response, and the liveness case by blocking inside the invoker and
signalling an unrelated execution in the same domain.

Signed-off-by: Andre Kurait <akurait@amazon.com>
…eration

Three page-token codecs and three copies of the same offset/limit/token block had
accumulated across the history, execution and registration listings, plus a
fourth for the PollForDecisionTask continuation. There is now one Page record
with one encode/decode pair β€” the task token is an optional field, which is the
only thing the poll needed β€” and one pagedList helper the history and execution
listings reuse. Net 62 fewer lines in the service and handler.

Collapsing the two page-size methods surfaced a real divergence rather than just
shortening code: historyPageSize clamped an oversized maximumPageSize while the
registration lists rejected it. The live service rejects it everywhere, measured
on GetWorkflowExecutionHistory, ListOpenWorkflowExecutions and
PollForDecisionTask, and rejects a negative value with its own message
("greater than or equal to 0"). One pageSize method now serves all of them.

Signed-off-by: Andre Kurait <akurait@amazon.com>
…ize cap

The three behaviours the last two commits changed were only visible in the code: a decision batch is all-or-nothing, a PollForDecisionTask continuation pages the same task rather than claiming a new one (and startAtPreviousStartedEvent is inclusive), and the 1000 page-size cap applies to every paginated operation rather than only the registration lists.

Signed-off-by: Andre Kurait <akurait@amazon.com>
@AndreKurait

Copy link
Copy Markdown
Contributor Author

@abanna @pgermosen β€” all four confirmed, all four fixed in e4efddc4, plus a simplification pass in 8fbb7165. Thank you both; @pgermosen, the independent read and the priority ordering were genuinely useful, and your note that #3 could not be fixed with a try/catch was correct and saved me starting down that path.

I measured each case against the live service before changing anything. Two came back differently from the report, so those are worth flagging up front.

#3 β€” batch validation (took it first, as suggested)

Confirmed exactly: [RecordMarker, NotARealDecision] grew the history 3 β†’ 5 and burned the token. The live service rejects the batch, persists nothing, and leaves the token claimable β€” verified:

history before: [WorkflowExecutionStarted, DecisionTaskScheduled, DecisionTaskStarted]
[RecordMarker, NotARealDecision] -> HTTP 400 ValidationException
history after:  [WorkflowExecutionStarted, DecisionTaskScheduled, DecisionTaskStarted]
SAME token retried with a valid batch -> HTTP 200
markers recorded: ['retry-after-reject']

Fixed by extending the requireCloseIsLastDecision shape you pointed at, rather than inventing a pattern: both rules now live in one requireValidBatch that runs before any mutation, and the accepted set comes from SwfConstants.DECISION_TYPES so the check and the enum list in the fault message cannot drift. applyDecision's default arm became unreachable, so it is now an IllegalStateException marking the invariant rather than a user-facing fault.

Guard fails on the previous code with a rejected batch must not grow the history ==> expected: <3> but was: <5>.

#1 β€” poll continuation

Confirmed, and AWS does state it outright. One correction to the report worth having: on the live service the continuation returns a different taskToken but the same startedEventId, previousStartedEventId and workflowExecution, with the next contiguous page:

page 1: events=[1,2,3]  startedEventId=23  previousStartedEventId=18
page 2: events=[4,5,6]  startedEventId=23 (SAME)  workflowExecution identical

So "same task, next page" is the invariant; the token itself is not stable. The continuation token now names the task it belongs to, and a continuation resolves that task instead of polling again.

startAtPreviousStartedEvent also measured differently than I expected β€” it starts at previousStartedEventId, inclusive, not after it:

default:                        events=13  eventIds=[1..13]
startAtPreviousStartedEvent:    events=6   eventIds=[8,9,10,11,12,13]   previousStartedEventId=8

I would have written > from and been off by one event. Now implemented as >= from.

#2 β€” continue-as-new parent

Confirmed; the successor's parent was null. The live service keeps the link across the boundary:

successor WorkflowExecutionStarted:
  parentWorkflowExecution = {'workflowId': 'wf-parent', 'runId': '22jxFdTId...'}
  parentInitiatedEventId  = 5
  continuedExecutionRunId = 220h5FT9D...

carryParentTo moves the parent identifiers and repoints the parent's childExecutions entry, before the successor's WorkflowExecutionStarted is appended so that event names the parent as the live service does. Guard fails on the previous code with the successor of a child must keep its parent ==> expected: <wf-cn-parent> but was: <null>.

#4 β€” Lambda under the domain lock

I fixed this rather than deferring it β€” the guard was quick to write and it is the kind of thing that gets forgotten. A decision now queues the invocation, records LambdaFunctionScheduled/LambdaFunctionStarted under the lock, runs the invocation with no lock held, then re-takes the lock to record the outcome, so the transition around the result stays atomic.

The guard blocks inside the invoker and signals an unrelated execution in the same domain. On the previous code it fails with signalling an unrelated execution blocked on the Lambda invocation. If you would still rather this were a separate PR, say so and I will split it back out β€” but it is small and covered.

Simplification pass (8fbb7165)

Separately from the fixes, since @AndreKurait asked me to look for reducible code: four page-token codecs and three copies of the same offset/limit/token block had accumulated. Now one Page record with one encode/decode pair (the task token is just an optional field, which is all the poll needed) and one pagedList helper the history and execution listings reuse. Net 62 fewer lines in the service and handler.

That collapse also surfaced a real divergence rather than only shortening code: historyPageSize clamped an oversized maximumPageSize while the registration lists rejected it. The live service rejects it everywhere:

GetWorkflowExecutionHistory maximumPageSize=1001 -> 400  ... less than or equal to 1000
ListOpenWorkflowExecutions  maximumPageSize=1001 -> 400  ... less than or equal to 1000
PollForDecisionTask         maximumPageSize=1001 -> 400  ... less than or equal to 1000
GetWorkflowExecutionHistory maximumPageSize=-1   -> 400  ... greater than or equal to 0

One pageSize method now serves all of them, including the distinct negative-value message.

Coverage

SWF is now 45 wire + 45 unit + 3 Docker-backed Lambda + 2 sweeper locally, plus 15 in the SDK compatibility suite. Every fix above ships a guard that fails on the commit before it, with the failure message quoted rather than described.

One process note in the interest of not overclaiming: my first attempt at proving these baselines used git checkout HEAD -- src/main/... to restore after reverting, which restores the commit β€” it silently destroyed the four uncommitted fixes and I had to re-apply them. The fixes are committed now and the baseline script refuses to run against a dirty tree, but it is the kind of thing worth admitting rather than quietly redoing.

The rejection message for an unknown decisionType listed the enum members in the
API model's order. The live service uses a different order, stable across
repeated calls, so a client comparing the message byte-for-byte saw a mismatch:

  service: [CompleteWorkflowExecution, StartTimer, RequestCancelExternal...
  model:   [ScheduleActivityTask, RequestCancelActivityTask, CompleteWork...

Taken from four live responses rather than from the model. The guard now asserts
the whole message instead of a substring, and fails on the previous order with
the two strings diffed in full.

Signed-off-by: Andre Kurait <akurait@amazon.com>
@AndreKurait

Copy link
Copy Markdown
Contributor Author

One more wire divergence, found by re-reading my own capture rather than reported: fb252121.

The rejection message for an unknown decisionType listed the enum members in the API model's order. The live service uses a different order:

service: [CompleteWorkflowExecution, StartTimer, RequestCancelExternalWorkflowExecution, ...
model:   [ScheduleActivityTask, RequestCancelActivityTask, CompleteWorkflowExecution, ...

I had built that string from service-2.json instead of from a response, which is exactly the mistake this PR has otherwise avoided. Sampled four live rejections to check it was not arbitrary β€” the service's order was identical every time, so a client comparing the message byte-for-byte would have seen a mismatch.

The guard now asserts the entire message rather than a substring, and fails on the previous order with both strings diffed in full. Verified over the wire as byte-identical to the live capture:

emulator: 1 validation error detected: Value 'NotARealDecision' at 'decisions.2.member.decisionType' faile ...
live AWS: 1 validation error detected: Value 'NotARealDecision' at 'decisions.2.member.decisionType' faile ...
BYTE-IDENTICAL: PASS

SWF is now 95 tests locally plus 15 in the SDK suite. This does not change any behaviour the four review findings covered β€” it is only the text of that one fault.

The queue of deferred invocations was a ThreadLocal drained after the domain lock
was released. On a pooled request thread that outlives the request: if anything
threw between queueing an invocation and draining, the work stayed on the thread
and the next decision response ran it β€” invoking a real function for a request
that had already failed, and recording the outcome against whatever state that
later execution was in.

The queue is now a local list created per call and threaded through
applyDecisions, so the drain is reachable only after the batch has been applied
and stored. A request that throws leaves the list unreferenced; there is no
cross-request carrier left to leak through.

Signed-off-by: Andre Kurait <akurait@amazon.com>
Reaches the leak window by failing the execution store after a Lambda has been queued and the history mutated, then issuing a second request on the same thread.

Signed-off-by: Andre Kurait <akurait@amazon.com>
@AndreKurait

Copy link
Copy Markdown
Contributor Author

Both addressed in 43dc56e4 (fix), f2128943 (guard) and 1d8ee47c (merge of main).

P1: failed batches retained Lambda work

Confirmed, and the fix is structural rather than a finally. The queue was a ThreadLocal drained after the domain lock released; on a pooled request thread that outlives the request, anything thrown between queueing and draining left the work behind for whichever request reused the thread.

It is now a plain local list created per call and threaded through applyDecisions, so the drain is only reachable after the batch has been applied and stored. A request that throws leaves the list unreferenced β€” there is no cross-request carrier left to leak through. That also deleted drainPendingLambdas and the ThreadLocal field outright, so the class of bug is gone rather than guarded against.

I want to be precise about severity, because my first attempt at a guard passed on the pre-fix code and I nearly shipped it as proof. No decision handler throws today, and WalStorage.appendPut swallows its IOException, so the leak was not reachable through a normal AWS-shaped rejection β€” Close must be last is rejected before anything is queued. Reaching it needs a throw from the store between the queue and the drain.

So the guard injects exactly that: a storage backend whose put fails once, after a Lambda has been queued and the history mutated, followed by a second request on the same thread. On fb252121 it fails with the leaked invocation named:

a later request must not run a failed request's queued Lambda: [us-east-1|leaked-fn|{}]
==> expected: <true> but was: <false>

That is the reviewer's scenario reproduced end to end: an external side effect from a request that had already failed, executed by an unrelated later one.

The CI compile failure was staleness, not this branch

The Ec2Service constructor error is not from my changes β€” git diff --name-only across my whole branch touches zero EC2 files. My branch was 35 commits behind upstream, and two of those changed Ec2Service's constructor:

2ab795c6 fix(ec2): assign a public IP only when the subnet opts in via MapPublicIpOnLaunch (#2023)
fc74987b fix(ec2): delete key pairs by name through the storage backend (#2187)

CI builds the merge of the PR into current main, so it saw the new call site against my older constructor while a local build of my branch alone compiled fine. Merged main in β€” no conflicts β€” and it compiles clean.

SWF is 96 tests locally after the merge, all passing, and make docs-check is clean. Full suite against merged main:

Tests run: 9870, Failures: 0, Errors: 2, Skipped: 9

9870 rather than the 9380 I reported earlier because the merge brought 490 new tests with it; all of them pass. The 2 errors are the Port already bound: 8444 pair, which is this host, not the branch.

I also tidied the PR while I was in here: the compatibility table had grown into a chronological log of review rounds, so it is now grouped by theme with the self-evident rows dropped, and I deleted my own superseded per-commit verification comments. The thread should read as one reply per review round now.

@AndreKurait

Copy link
Copy Markdown
Contributor Author

@pgermosen @abanna can you take a look?

@pgermosen pgermosen added waiting-maintainer Contributor has replied; waiting on a maintainer response and removed waiting-contributor Deprecated: use waiting-author waiting-maintainer Contributor has replied; waiting on a maintainer response labels Aug 14, 2026

@pgermosen pgermosen left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Approving. All four are genuinely fixed, and I verified each one by reverting it rather than reading the diff and agreeing β€” the priority order I suggested turned out to be the order you took them in, and the hardest one came out cleanest.

Each fix, and the mutation that proves its guard

I reverted each change individually against current main (12cf6e2f) and recorded which test fires. Every failure message matches the one you quoted:

Reverted Test that fires
requireValidBatch moved after the append 3 tests β€” a rejected batch must not grow the history ==> expected: <3> but was: <4>
>= from β†’ > from the window starts at previousStartedEventId inclusive ==> expected: <3> but was: <4>
poll continuation branch disabled pollForDecisionTask_continuationReturnsTheSameTasksNextPage
carryParentTo disabled the successor of a child must keep its parent ==> expected: <wf-cn-parent> but was: <null>
Lambda invoke moved back inside the lock signalling an unrelated execution blocked on the Lambda invocation

96 SWF tests green locally on that base β€” 45 wire, 46 unit, 3 Docker-backed Lambda, 2 sweeper.

The structural claims hold up on a read as well as under mutation. requireValidBatch sits at SwfService:599, ahead of the first appendEvent at :601 and the decisionTokens.remove at :607, so the rejection path cannot touch history or burn the token. lambdaInvoker.invoke at :1240 runs with no monitor held, and the lock is retaken at :1264 only to record the outcome β€” exactly the shape you described.

Two things I want to single out.

#3 was solved the right way. Folding both rules into one requireValidBatch and sourcing the accepted set from SwfConstants.DECISION_TYPES means the validation and the enum list in the fault message cannot drift apart. Turning applyDecision's default arm into an IllegalStateException is the correct consequence β€” it is now an invariant marker rather than a reachable user-facing fault, and saying so in the type is better than leaving a dead branch that looks live.

Your two corrections to our reports were right, and both are now encoded as tests. startAtPreviousStartedEvent starting at previousStartedEventId inclusive is not what I would have assumed either, and the window starts at previousStartedEventId inclusive is exactly the kind of assertion name that stops someone "tidying" it to > a year from now. Same for the continuation invariant being "same task, next page" rather than a stable token. Measuring first and reporting the delta β€” rather than implementing what the reviewers said β€” is the behaviour I would want on every PR.

Scope

I offered to take #4 as a tracked follow-up and you fixed it instead, with a guard that blocks inside the invoker and signals an unrelated execution. Keep it here; splitting it out now would be pure churn.

The 8fbb7165 simplification pass earns its place too β€” collapsing four page-token codecs and three offset/limit blocks into one Page and one pagedList is a net reduction, and it surfaced a real divergence rather than only shortening code: historyPageSize clamping an oversized maximumPageSize where the registration lists rejected it. One pageSize serving all of them, with the distinct negative-value message, is the correct resolution.

One process note for the thread

@abanna's formal review still reads CHANGES_REQUESTED from 2026-08-13. That is stale rather than outstanding β€” the two items it names were confirmed resolved in their own follow-up, and the four raised afterwards are the ones closed here. Flagging it only so nobody reads the standing red mark as an open objection.

Nothing further from me. Thank you for the care on this one β€” thirty-nine operations with the wire details measured against the live service instead of inferred, a guard on every behaviour change that fails on the commit before it, and an honest note about destroying your own uncommitted fixes with git checkout HEAD --. That last one costs nothing to omit and you included it anyway; it is a good sign about everything else in the PR I did not personally re-run.

@pgermosen
pgermosen merged commit ecad6ac into floci-io:main Aug 14, 2026
14 checks passed
@abanna

abanna commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Thanks for the substantial SWF implementation and for addressing the earlier review feedback β€” the region scoping, pagination, decision-batch atomicity, child continuation, and Lambda lock changes are thoughtful, and the focused tests are green.

I did find a few follow-ups on the current head (1d8ee47) worth addressing before merge:

  • Lambda lifecycle: SwfService.java queues Lambda work without representing it as open execution state. A batch containing ScheduleLambdaFunction followed by CompleteWorkflowExecution can close the workflow, invoke the function afterward, and leave no terminal Lambda event. This also leaves openLambdaFunctions at zero and does not enforce the Lambda timeout.

  • Multi-account timeout sweep: the background sweep scans the account-aware store outside request scope, where it falls back to the default account. Expired executions in other accounts therefore remain open.

  • Tag persistence key: tagging reads a region-qualified domain but writes under the bare domain name, creating an orphan key and risking lost updates with WAL storage after restart.

  • List/count validation and ordering: the execution filters accept mutually exclusive filters together and allow missing required time filters. Closed listings also always sort by start time instead of close time when closeTimeFilter is used.

  • Concurrent registration: domain and workflow/activity type registration check then put without synchronization; concurrent requests can all report success instead of returning the expected already-exists fault.

These should be straightforward to cover with focused regressions. Thanks again for the care in this PR β€” it is a strong foundation, and these state/storage edges are the main things holding it back for me.

@AndreKurait
AndreKurait deleted the feat/swf branch August 14, 2026 19:29
@hectorvent

Copy link
Copy Markdown
Collaborator

πŸŽ‰ This PR is included in version 1.7.0 πŸŽ‰

The release is available on GitHub release

Your semantic-release bot πŸ“¦πŸš€

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request released swf Amazon Simple Workflow Service (SWF)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEAT] SWF: emulate the SimpleWorkflowService control plane (domains, types, and the decider/worker state machine)

4 participants