Repository navigation
Conversation
… on start failures A parent starts its children on its own execution thread, so a store or scheduler timeout while starting one escapes the parent's workflow method, where it is taken for a bug in the workflow code and ends the parent in ERROR for good. InvocationBuilder.asChildOf(parent) returns a ChildWorkflowBuilder whose start(call) builds the child, runs the call, and turns any failure Skipper has not already classified, and that is not a rejected request, into a RetryableError. The parent then runs again after startFailureRetryDelay (5 s by default) and starts the child again under the same id. The engine is unchanged; callers opt in per call site.
…sk was never scheduled
…start lambda asChildOf(parent) now returns the ordinary InvocationBuilder after recording the parent's id, and the workflow factory's proxy classifies start failures for any invocation that carries a parent id. Callers keep the usual `.asChildOf(this).build().method(input)` shape. Wrapping only SkipperEngine.startWorkflow inside the proxy also narrows what is classified to the start itself: building the input and waiting for the result stay outside it. The retry delay is a fixed 5 s (WorkflowFactory.CHILD_START_RETRY_DELAY), since there is no per-invocation channel for it without widening IWorkflowFactory.
| try { | ||
| skipperEngine.startWorkflow(request) | ||
| } catch (e: Exception) { | ||
| if (e is SkipperError || |
There was a problem hiding this comment.
skippererror could also be retryable error? in which case we'd like to treat it as retryable?
There was a problem hiding this comment.
Yes, and it already is. RetryableError extends SkipperError, so case 1 rethrows it unchanged. From there WorkflowExecutor.handleExecutionError puts the parent in TRANSIENT_ERROR and reruns it on the error's own delay or strategy. Only errors Skipper doesn't recognize get rewrapped, so a NonRetryableError still fails the parent and a RetryableError still retries it.
Done: in b60c9d9 I made this explicit in the KDoc (case 1 now names RetryableError and OptimisticLockingError as examples). I also added retryableErrorWhileCreatingAChildRetriesTheParent, which injects an OptimisticLockingError from the store while starting a child and asserts the parent retries and completes. That test passes with or without asChildOf, since this passthrough is existing executor behavior; it pins the behavior so it doesn't change. The unit test testChildStartClassifiedOrInvalidFailureIsRethrownUnchanged checks that the same instance is rethrown.
- Ricardo's agent
Addresses review feedback asking whether a SkipperError that is a RetryableError is treated as retryable. It is: it is rethrown unchanged and the executor retries the parent on it. The KDoc now says so, and an integration test covers it.
sndre
left a comment
There was a problem hiding this comment.
Two things inline: the first can leave a child that never runs on a default setup; the second is about reusing the existing retry path.
… starts through InternalError Starting an existing workflow is a no-op by default (CREATE_EXISTING_WORKFLOW_IS_NOOP), so a parent retrying a child whose instance was written but whose task insert failed got the instance back unscheduled and the child never ran. The engine now schedules an existing RUNNING instance that has no task, even when the start would otherwise be a no-op. A child-start failure Skipper does not recognize now becomes an InternalError, the error the stores and schedulers already throw, so the parent retries through the task handler's unexpected-error delay and CHILD_START_RETRY_DELAY goes away.
… cannot run alongside it A start that finds an existing RUNNING instance with no task now writes only the persistent task, due once a lease has passed, instead of scheduling it in memory. That is the row an in-memory start writes as its fallback, so whichever of the two writes it first, the other honours its lease and the workflow runs once. Also document that unrecognized child-start failures, programming errors included, retry until the scheduler's max retries and leave the parent in TRANSIENT_ERROR.
…ace it The TLA+ model now has several startWorkflow callers, each with its own in-memory queue, and the recovery start that schedules a stranded workflow after a lease. A new NoOverlappingRuns invariant says no two workers run the workflow at once. - start_race: two in-process starts from different JVMs, either of which may fail after creating the row. Passes. - start_race_unguarded: the same with the stranded start scheduled like any other. TLC finds the double run, so the model catches the interleaving. - start_retry now passes: the stranded workflow is scheduled on the retry. Trace validation checks the engine against it. ChildWorkflowStartFailureTest now traces its stock backends beneath its fault wrappers, the recorder skips wrapper frames, and a start that found the workflow already there is a model step. With the engine guard reverted, the stranded child's recorded run is rejected.
Problem
A workflow that starts children does so on its own execution thread, so whatever a child start throws escapes the parent's workflow method.
WorkflowExecutortreats any exception it doesn't recognize as a bug in the workflow code and wraps it in aNonRetryableError, so the parent ends inERROR, which is terminal. Starting a child writes to the workflow store and the scheduler, though, and a timeout on one of those writes isn't a bug. In a large fan-out, one transient blip among thousands of starts fails the whole parent.Approach
The caller marks the invocation as a child start at the builder, and the factory's proxy acts on that. The engine changes in one narrow place, covered under "Recovering a child that was written but never scheduled" below.
InvocationBuilder.asChildOf(parent)records the parent's id, the same as.parentWorkflowId(parent.id).WorkflowFactoryproxy wraps itsSkipperEngine.startWorkflowcall and sorts out what that call throws:SkipperErrorsubclasses, internal errors): rethrown unchanged.IllegalArgumentException,ValidationError): rethrown unchanged, so it still fails the parent. Retrying would only repeat it.InternalError, the error Skipper's SQLite and MySQL stores and schedulers already throw when a statement fails. The task handler retries the parent aftertaskUnexpectedErrorRetryDelay, and the parent starts the child again under the same id. Children it already started aren't duplicated.Recovering a child that was written but never scheduled
If the store commits the child's instance and then the scheduler fails to insert its task, the retried start finds an existing instance.
CREATE_EXISTING_WORKFLOW_IS_NOOPis on by default, so before this change the start returned that instance without scheduling it, and the child never ran.SkipperEngine.startWorkflownow schedules an existing RUNNING instance that has no task, even when starting an existing workflow is otherwise a no-op.RUNNING with no task means a start that failed, or one that hasn't reached its schedule yet. The store writes new instances as RUNNING, and the task handler persists the next status before the scheduler removes the task. This applies to any repeated start, not only children. It costs one
getTaskread per repeated start of a RUNNING workflow; WAITING and terminal instances aren't looked up.Because the first start may only be slow, the recovering start writes just the persistent task, due once a lease has passed (
SchedulerExecutionQueue.scheduleAfterLease), and queues no in-memory copy. That is the fallback row an in-memory start writes anyway, so whichever start writes it first, the other honours its lease and the workflow runs once. A child that really was stranded runs afterschedulerTaskLeaseDuration(8 minutes by default).Key decisions and trade-offs
parentWorkflowIdon every invocation, so this needs no change toIWorkflowFactory. A new parameter there would break Java implementors and fakes, because the Kotlin interface has no JVM default methods. The consequence is that existing callers of.parentWorkflowId(id)get the new behavior too. A parent id means "this is a child", so that seems right. A caller outside a workflow that sets a parent id would now see anInternalErrorwrapping the cause instead of the raw exception.InternalErrorinstead of aRetryableErrorwith its own delay means a child-start failure retries exactly like a store failure anywhere else in the parent's execution. It needs no new public constant, and the delay stays configurable throughtaskUnexpectedErrorRetryDelay. Like that path, retries stop atschedulerTaskMaxRetries(100), leaving the parent in TRANSIENT_ERROR for an admin retry. That includes programming errors from a store, which before ended in ERROR. Deferring to theExceptionClassifierwas considered and rejected: the default one only retries timeouts, so a store's own rate-limit error would still fail the parent.Testing
ChildWorkflowStartFailureTest): real SQLite store and scheduler, with a fault injected on the first store or scheduler call for the second of three children.parentWorkflowIdrecorded.CREATE_EXISTING_WORKFLOW_IS_NOOPon (the default;TestRuntimemocks gates off): the stranded child is scheduled and COMPLETES.OptimisticLockingErrorfrom the store: rethrown unchanged and retried on its own delay.IllegalArgumentException): the parent ends in ERROR and isn't retried.asChildOf: the parent ends in ERROR. This is the baseline showingasChildOfis what makes the difference.SkipperEngineTest): with the no-op gate on, an existing RUNNING instance with no task is scheduled after a lease. One with a task isn't. A WAITING one isn't looked up.QueueBasedSchedulerTest): two queues share one scheduler, standing in for two nodes, and an in-memory start and an after-lease recovery write the same task in both orders. Each time only one copy can run, and the persistent row isn't due while it does. Dropping the lease delay fails them.formal/tla):startWorkflowcallers and aNoOverlappingRunsinvariant.start_race(two in-process starts, either of which may fail) passes.start_race_unguarded(the stranded start scheduled like any other) makes TLC find the double run.start_retry, the stranded-start finding, now passes.ChildWorkflowStartFailureTest. Locally, all 110 in-scope runs are accepted and all 540 mutants rejected.WorkflowFactoryTest, mocked engine):InternalErrorwith the original cause, and the request carries the parent id.NonRetryableError,RetryableError,InternalError,IllegalArgumentExceptionandValidationErrorare rethrown as the same instance../gradlew :testpasses 933 of 996 tests (3 skipped). The other 60 are the MySQL suites, which need an embedded MariaDB that doesn't run locally; none touch this change.spotlessCheckis clean.