Repository navigation
feat(swf): add Simple Workflow Service emulation - #2257
Conversation
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>
|
π 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:
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. |
|
| 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
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>
|
Thanks for the review β all three P1s were real. Fixed in The first two shared a root cause worth naming: mutation was serialized per execution, but several SWF invariants span an execution family. Both were reproduced before being fixed, and each guard fails on the previous code:
The duplicate-start fix is also confirmed over the wire, not just in-process: 8 concurrent 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 Current state: |
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
left a comment
There was a problem hiding this comment.
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:
- SWF state is not region-scoped. Registering
ordersinus-east-1stores it under the bare keyorders; registering the same domain ineu-west-1then 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. ListDomains,ListWorkflowTypes, andListActivityTypesignoremaximumPageSizeandnextPageToken, 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.
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>
|
Thank you β both gaps were real, and I measured each against the live service before fixing it. Both are addressed in 1. Region scopingConfirmed exactly as you described: keys were the bare name, so
Keys are now region-qualified β Two consequences worth calling out, since neither was in the report:
2. Registration-list paginationAlso confirmed. Four details came from the captures rather than the docs, and I would have gotten two of them wrong by guessing:
Tests
Plus wire-level integration tests and unit tests for the page-size limits. Current SWF totals: One incidental find while sweeping for unscoped keys: |
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>
|
Want your agent to iterate on Greptile's feedback? Try greploops. |
|
Thank you for the very thorough follow-up here. I re-reviewed the full change at I did find four remaining correctness issues that I think should be addressed before this is ready:
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
left a comment
There was a problem hiding this comment.
@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
PollForDecisionTaskwith anextPageTokendoesn'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:
- #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.
- #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.
- #2 is real but needs the parent + child + continue-as-new combination to show up.
- #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>
|
@abanna @pgermosen β all four confirmed, all four fixed in 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: Fixed by extending the Guard fails on the previous code with #1 β poll continuationConfirmed, and AWS does state it outright. One correction to the report worth having: on the live service the continuation returns a different 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.
I would have written #2 β continue-as-new parentConfirmed; the successor's parent was null. The live service keeps the link across the boundary:
#4 β Lambda under the domain lockI 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 The guard blocks inside the invoker and signals an unrelated execution in the same domain. On the previous code it fails with Simplification pass (
|
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>
|
One more wire divergence, found by re-reading my own capture rather than reported: The rejection message for an unknown I had built that string from 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: 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>
|
Both addressed in P1: failed batches retained Lambda workConfirmed, and the fix is structural rather than a It is now a plain local list created per call and threaded through 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 So the guard injects exactly that: a storage backend whose 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 branchThe 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 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 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. |
|
@pgermosen @abanna can you take a look? |
pgermosen
left a comment
There was a problem hiding this comment.
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.
|
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:
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. |
|
π This PR is included in version 1.7.0 π The release is available on GitHub release Your semantic-release bot π¦π |
Summary
Closes #2260.
Adds Amazon SWF emulation: all 39 modeled operations over JSON 1.0
(
X-Amz-Target: SimpleWorkflowService.*), covering domain and type registration, theworkflow execution state machine, and the decider/worker task lifecycle.
Follows the existing service pattern β
SwfService(domain logic, throwsAwsException),SwfJsonHandler(protocol only),model/, storage throughStorageFactory, registered inResolvedServiceCatalog, dispatched fromAwsJsonController, and config underfloci.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 withValidationExceptionleaving noDecisionTaskCompleted, no earlier decision applied, and thetask token still claimable for a corrected batch. A decision that cannot be
applied appends its
*Failedevent with the AWS cause (ACTIVITY_TYPE_DOES_NOT_EXIST,ACTIVITY_ID_ALREADY_IN_USE,TIMER_ID_UNKNOWN, β¦) and gives the decider a fresh taskinstead of failing the request.
Closing with open activities, timers, or children appends
UNHANDLED_DECISIONandleaves 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 openchildren.
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>. Registeringordersin two regions creates twoindependent domains with their own retention and ARNs; a
runIdfrom one region does notresolve 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 β
StartWorkflowExecutionmust see a concurrently inserted run of the sameworkflowId, and a closing execution mutates its parent and its children. A parent and achild 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.
ScheduleLambdaFunctionruns 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.
lambdaRoleresolvesfrom
StartWorkflowExecutionthen the type'sdefaultLambdaRole; without one thefunction is never started and
StartLambdaFunctionFailed/ASSUME_ROLE_FAILEDisrecorded, matching the live service.
Limitations are documented in
docs/services/swf.mdrather than left implicit: pollsreturn immediately instead of long-polling for up to 60s; task tokens are in-memory; and
workflowExecutionRetentionPeriodInDaysis stored and returned but closed executions arenot pruned.
Type of change
fix:)feat:)feat!:orfix!:)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 actionlist and shapes come from botocore's
swf/2012-01-25/service-2.json; these details wereconfirmed on the wire because they are easy to get wrong from the model alone:
__typecom.amazonaws.swf.base.model#<Fault>, but the bare name inx-amzn-query-errorcom.amazon.coral.validate#ValidationException, a different namespacedecisionTypeenum set in that fault1.786560427599E9), not ISOarn:aws:swf:<region>:<account>:/domain/<name>{}taskToken: ""rather than omitting the memberClose must be last decision in listUnknown activity, scheduledEventId = NChildWorkflowExecution*correlationinitiatedEventIdandstartedEventIdname different eventsparentWorkflowExecutionandparentInitiatedEventIdActivityTaskTimedOutfor a never-started taskstartedEventId: 0DecisionTaskScheduledscheduleToStartTimeout: "NONE"PollForDecisionTask+nextPageTokenstartedEventId); never a new claimstartAtPreviousStartedEventpreviousStartedEventId, inclusivenextPageTokenon the last pagemaximumPageSizeabove 1000ValidationExceptionon every paginated operation, rejected not clampednextPageTokenValidationException: Invalid token, not a silent restart at page 1LambdaFunctionFailed/ResourceNotFoundException, after reporting StartedlambdaRoleStartLambdaFunctionFailed/ASSUME_ROLE_FAILED; the function never startsidID_ALREADY_IN_USE, unlike activity idsTwo are worth calling out:
x-amzn-query-errorheader over the body's__type, so putting thenamespaced name on both surfaces makes the AWS CLI print
com.amazonaws.swf.base.model#UnknownResourceFaultwhere real SWF printsUnknownResourceFault.SwfExceptionkeepsgetErrorCode()bare and overridesjsonType()to add the namespace, reproducing the live service on both surfaces.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 testpasses locallyTest results
SwfServiceTestβ unit tests driving timeout expiry throughMutableClockrather thanwall 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 stayscontiguous while several children close against it).
SwfIntegrationTestβ asserts the wire shapes and exact fault strings captured from thelive service.
SwfTimeoutSweeperTestβ a misconfigured sweep interval must not stop startup.SwfLambdaIntegrationTestβ deploys a real function through Floci's Lambda API anddrives
ScheduleLambdaFunctionagainst it, asserting the recordedresultis what thecontainer 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 AWSSDK v2 client, so responses must deserialize into the generated model (epoch seconds into
Instant, attributes into the right*EventAttributesmember) and faults into typedexceptions (
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, bothQuarkusBindException: Port already bound: 8444β an unrelated process holds that port onthis 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):swfreportsrunningin/_floci/health, the AWS CLI drives a full lifecycle to the same 11-event history as the liveservice,
ScheduleLambdaFunctionruns a real Python function in a sibling container, thetimeout sweep produces
DecisionTaskTimedOut{START_TO_CLOSE}followed by a freshDecisionTaskScheduled, and faults render with the AWS error codes and messages.Docs
docs/services/swf.mdwith the action table generated bytools/docs/regen_action_docs.py(SWF registered intools/docs/services.yaml;make docs-checkpasses), plus mkdocs nav and the two README tables.