Skip to content

Commit 6a2e8b0

Browse files
committed
Flush buffered events before emitting time-skipping transition
A time-skipping transition must be the last event of the transaction that produces it: it is a server-generated state change applied synchronously (it bumps AccumulatedSkippedDuration and drives timer-task regeneration in the same close-transaction), so it must order after every other event. WORKFLOW_EXECUTION_OPTIONS_UPDATED is a buffered event type while the transition is not. When an options update re-enables (or enables) time skipping on an idle workflow with a pending timer, the close-transaction emits a transition in the same transaction as the buffered OPTIONS_UPDATED. The non-buffered transition was allocated an event ID immediately, before the buffered OPTIONS_UPDATED was flushed in closeTransactionPrepareEvents, inverting their order and producing non-monotonic event timestamps. Flush pending buffered events in closeTransactionHandleWorkflowTimeSkipping before emitting the transition. Safe here: isWorkflowSkippable already guarantees no in-flight workflow task, which is FlushBufferedEvents' own precondition. The transition stays non-buffered, so reorderBuffer never moves it and it remains the last-allocated event. Add TestTransitionEventIsLastInTransaction to TimeSkippingTestSuite guarding the invariant.
1 parent a31f476 commit 6a2e8b0

2 files changed

Lines changed: 93 additions & 2 deletions

File tree

service/history/workflow/timeskipping.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -404,14 +404,17 @@ func (ms *MutableStateImpl) closeTransactionHandleWorkflowTimeSkipping(
404404
if !transition.IsValid() {
405405
return false
406406
}
407-
// 3. state change
407+
// 3. flush buffered events so that previous events will have an eventID smaller
408+
// than the eventID for time skipping
409+
ms.FlushBufferedEvents()
410+
// 4. state change
408411
_, err := ms.AddWorkflowExecutionTimeSkippingTransitionedEvent(
409412
ctx, transition.TargetTime, transition.DisabledAfterFastForward)
410413
if err != nil {
411414
ms.logger.Error("failed to add workflow execution time skipping transitioned event", tag.Error(err))
412415
return false
413416
}
414-
// 4. task regeneration
417+
// 5. task regeneration
415418
return true
416419
case historyi.TransactionPolicyPassive:
417420
return false

tests/timeskipping_test.go

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1246,3 +1246,91 @@ func (s *TimeSkippingFastForwardFunctionalSuite) TestTimeSkipping_ExecutionTimeo
12461246
"timing out at the execution timeout is not a fast-forward disable")
12471247
s.True(hasEventType(hist, enumspb.EVENT_TYPE_WORKFLOW_EXECUTION_TIMED_OUT))
12481248
}
1249+
1250+
// TestTransitionEventIsLastInTransaction guarantees that a time-skipping transition is the last
1251+
// event of the transaction that produces it — in particular that it is ordered after a
1252+
// WorkflowExecutionOptionsUpdated event emitted in the same transaction.
1253+
//
1254+
// Repro of the ordering bug this guards against: start without time skipping and schedule a user
1255+
// timer so the workflow goes idle, then enable time skipping via UpdateWorkflowExecutionOptions.
1256+
// That update's close-transaction finds the idle workflow now skippable and emits a transition in
1257+
// the same transaction as the OPTIONS_UPDATED event. OPTIONS_UPDATED is a buffered event type
1258+
// while the transition is not, so without flushing buffered events before emitting the transition,
1259+
// the non-buffered transition is allocated an event ID before the buffered OPTIONS_UPDATED is
1260+
// flushed — inverting their order and producing non-monotonic event timestamps.
1261+
func (s *TimeSkippingTestSuite) TestTransitionEventIsLastInTransaction() {
1262+
env := testcore.NewEnv(s.T())
1263+
env.OverrideDynamicConfig(dynamicconfig.TimeSkippingEnabled, true)
1264+
tv := testvars.New(s.T())
1265+
ctx := testcore.NewContext()
1266+
1267+
// Start WITHOUT time skipping.
1268+
startResp, err := env.FrontendClient().StartWorkflowExecution(ctx, &workflowservice.StartWorkflowExecutionRequest{
1269+
RequestId: uuid.NewString(),
1270+
Namespace: env.Namespace().String(),
1271+
WorkflowId: tv.WorkflowID(),
1272+
WorkflowType: tv.WorkflowType(),
1273+
TaskQueue: tv.TaskQueue(),
1274+
WorkflowRunTimeout: durationpb.New(24 * time.Hour),
1275+
WorkflowTaskTimeout: durationpb.New(10 * time.Second),
1276+
})
1277+
s.NoError(err)
1278+
runID := startResp.RunId
1279+
1280+
// WT1: schedule a user timer. Time skipping is off, so the workflow just goes idle with a
1281+
// pending timer (no skip yet).
1282+
_, err = env.TaskPoller().PollAndHandleWorkflowTask(tv, func(_ *workflowservice.PollWorkflowTaskQueueResponse) (*workflowservice.RespondWorkflowTaskCompletedRequest, error) {
1283+
return &workflowservice.RespondWorkflowTaskCompletedRequest{
1284+
Commands: []*commandpb.Command{startTimerCmd("t1", time.Hour)},
1285+
}, nil
1286+
})
1287+
s.NoError(err)
1288+
1289+
// Enable time skipping. The workflow is idle with a pending timer, so this update's close-tx
1290+
// emits a transition skipping to the timer — in the same transaction as OPTIONS_UPDATED.
1291+
_, err = env.FrontendClient().UpdateWorkflowExecutionOptions(ctx, &workflowservice.UpdateWorkflowExecutionOptionsRequest{
1292+
Namespace: env.Namespace().String(),
1293+
WorkflowExecution: &commonpb.WorkflowExecution{WorkflowId: tv.WorkflowID(), RunId: runID},
1294+
WorkflowExecutionOptions: &workflowpb.WorkflowExecutionOptions{
1295+
TimeSkippingConfig: &commonpb.TimeSkippingConfig{Enabled: true},
1296+
},
1297+
UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"time_skipping_config"}},
1298+
})
1299+
s.NoError(err)
1300+
1301+
hist := env.GetHistory(env.Namespace().String(), &commonpb.WorkflowExecution{WorkflowId: tv.WorkflowID(), RunId: runID})
1302+
1303+
// The transition and the OPTIONS_UPDATED that triggered it must both be present, and the
1304+
// OPTIONS_UPDATED must come first (the transition is the last event of the transaction).
1305+
optionsUpdatedIdx, transitionIdx := -1, -1
1306+
for i, e := range hist {
1307+
switch e.GetEventType() {
1308+
case enumspb.EVENT_TYPE_WORKFLOW_EXECUTION_OPTIONS_UPDATED:
1309+
optionsUpdatedIdx = i
1310+
case enumspb.EVENT_TYPE_WORKFLOW_EXECUTION_TIME_SKIPPING_TRANSITIONED:
1311+
transitionIdx = i
1312+
}
1313+
}
1314+
s.NotEqual(-1, optionsUpdatedIdx, "expected an OPTIONS_UPDATED event")
1315+
s.NotEqual(-1, transitionIdx, "expected a TIME_SKIPPING_TRANSITIONED event")
1316+
s.Less(optionsUpdatedIdx, transitionIdx,
1317+
"OPTIONS_UPDATED (event %d) must precede the transition it triggered (event %d)",
1318+
hist[optionsUpdatedIdx].GetEventId(), hist[transitionIdx].GetEventId())
1319+
1320+
// Event IDs and timestamps must both be non-decreasing across the whole history.
1321+
for i := 1; i < len(hist); i++ {
1322+
prev, cur := hist[i-1], hist[i]
1323+
s.Greater(cur.GetEventId(), prev.GetEventId(),
1324+
"event IDs must be strictly increasing: event %d follows event %d",
1325+
cur.GetEventId(), prev.GetEventId())
1326+
s.False(cur.GetEventTime().AsTime().Before(prev.GetEventTime().AsTime()),
1327+
"event %d (%s) time precedes event %d (%s)",
1328+
cur.GetEventId(), cur.GetEventType(), prev.GetEventId(), prev.GetEventType())
1329+
}
1330+
1331+
_, _ = env.FrontendClient().TerminateWorkflowExecution(ctx, &workflowservice.TerminateWorkflowExecutionRequest{
1332+
Namespace: env.Namespace().String(),
1333+
WorkflowExecution: &commonpb.WorkflowExecution{WorkflowId: tv.WorkflowID(), RunId: runID},
1334+
Reason: "test cleanup",
1335+
})
1336+
}

0 commit comments

Comments
 (0)