forked from votrongdao/FlowX
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathEngineAllocationTests.cs
More file actions
1152 lines (985 loc) · 53.6 KB
/
Copy pathEngineAllocationTests.cs
File metadata and controls
1152 lines (985 loc) · 53.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
using System.Runtime.CompilerServices;
using FlowX.Runtime;
using Shouldly;
using Xunit;
namespace FlowX.Runtime.Tests;
/// <summary>
/// Budget B2 for the engine itself: <strong>zero allocations per step</strong> on the
/// success path of an ephemeral flow.
/// </summary>
/// <remarks>
/// <para>
/// This is WP-4's exit criterion, and it is asserted rather than benchmarked because
/// allocation counts are deterministic while timings are not. A benchmark would tell
/// us nightly; this tells us on every pull request.
/// </para>
/// <para>
/// <strong>Release only</strong>, and skipped rather than failed in Debug. The C#
/// compiler emits an async state machine as a class in Debug and as a struct in
/// Release, so a Debug run measures 376 B of Edit-and-Continue scaffolding and reports
/// it as an engine allocation. That number is not the engine's, and a gate that fails
/// in the configuration everyone runs locally is a gate people learn to ignore.
/// </para>
/// </remarks>
public sealed class EngineAllocationTests
{
private static readonly DateTimeOffset T0 = DateTimeOffset.UnixEpoch;
/// <summary>
/// Skips the measurement in Debug, where it would measure the compiler rather than
/// the engine. See the class remarks.
/// </summary>
private static void RequireOptimisedBuild()
{
#if DEBUG
Assert.Skip(
"Allocation budgets are measured in Release only. In Debug the compiler emits " +
"async state machines as classes, which shows up as a few hundred bytes per " +
"execution that the engine does not allocate. Run: dotnet test -c Release");
#endif
}
/// <summary>
/// Measures one execution after the pool, the JIT and the async state machine have
/// all warmed up. The warm-up matters: the first execution legitimately allocates
/// the pooled context, and charging that to the steady state would measure startup
/// rather than the hot path.
/// </summary>
[MethodImpl(MethodImplOptions.NoInlining)]
private static long MeasureSteadyState(FlowEngine engine, ExecutionPlan plan, IStepDispatcher dispatcher)
{
for (var i = 0; i < 64; i++)
{
RunSync(engine.ExecuteAsync(plan, dispatcher, Plans.Invocation));
}
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
var before = GC.GetAllocatedBytesForCurrentThread();
RunSync(engine.ExecuteAsync(plan, dispatcher, Plans.Invocation));
return GC.GetAllocatedBytesForCurrentThread() - before;
}
/// <summary>
/// Completes a <see cref="ValueTask{TResult}"/> that must already be finished.
/// </summary>
/// <remarks>
/// The assertion is the point, not a formality. When every step completes
/// synchronously the engine must too — a flow of synchronous steps that
/// gratuitously goes async would allocate a state-machine box per execution and
/// lose budget B2. Measuring across an await would also risk a thread switch,
/// which would make GetAllocatedBytesForCurrentThread meaningless.
/// </remarks>
private static FlowExecutionResult RunSync(ValueTask<FlowExecutionResult> execution)
{
execution.IsCompleted.ShouldBeTrue(
"The engine went asynchronous for a flow whose every step completed " +
"synchronously. That allocates a state machine on the hot path.");
return execution.GetAwaiter().GetResult();
}
[Fact]
public void TheMeasurementCanDetectAnAllocationItShouldSee()
{
// The positive control, same as in FlowX.Core.Tests. A zero-allocation
// assertion that cannot fail is worse than none.
GC.Collect();
var before = GC.GetAllocatedBytesForCurrentThread();
var wasted = new object[4];
var after = GC.GetAllocatedBytesForCurrentThread();
(after - before).ShouldBeGreaterThan(0);
wasted.Length.ShouldBe(4);
}
[Fact]
public void ASuccessfulFourStepFlowAllocatesNothingInSteadyState()
{
RequireOptimisedBuild();
var engine = new FlowEngine(new FakeClock(T0));
var plan = Plans.FourStepSaga();
var dispatcher = new NullDispatcher();
var allocated = MeasureSteadyState(engine, plan, dispatcher);
allocated.ShouldBe(0,
$"Measured {allocated} B. Budget B2 is a hard zero, and this is WP-4's exit criterion. The context " +
"is pooled, StepOutcome and FlowExecutionResult are structs, and the step " +
"loop indexes an ImmutableArray rather than enumerating an interface.");
}
[Fact]
public void AFlowWithNoCompensableStepsAllocatesNoCompensationStack()
{
RequireOptimisedBuild();
var engine = new FlowEngine(new FakeClock(T0));
var allocated = MeasureSteadyState(engine, Plans.TwoStepQuery(), new NullDispatcher());
allocated.ShouldBe(0,
$"Measured {allocated} B. A query flow must not pay for saga machinery it never uses — the engine " +
"only builds a CompensationStack when the plan declares one.");
}
/// <summary>
/// The failure path allocates today, and this records how much rather than
/// asserting a zero that is not true.
/// </summary>
/// <remarks>
/// Two sources: the <c>CompensationStack</c> (a <c>Stack<T></c> plus an
/// iterator, already tracked by the tripwire in <c>FlowX.Core.Tests</c>) and the
/// <c>Error</c> record with its structured data. Both are acceptable — they occur
/// once per *failed* flow, not per step — but they are measured so a future change
/// cannot quietly move an allocation from the failure path onto the success path.
/// </remarks>
[Fact]
public void TheFailurePathAllocatesAndTheAmountIsRecorded()
{
// Guarded for the same reason as the zero assertions: the ceiling is only
// meaningful against a Release measurement.
RequireOptimisedBuild();
var engine = new FlowEngine(new FakeClock(T0));
var plan = Plans.FourStepSaga();
var dispatcher = new NullDispatcher
{
FailAtStep = 2,
Failure = new Error("payment.declined", "declined", ErrorCategory.Conflict),
};
var allocated = MeasureSteadyState(engine, plan, dispatcher);
allocated.ShouldBeGreaterThan(0);
allocated.ShouldBeLessThan(2048,
"Compensation is allowed to allocate; it is not allowed to allocate a lot. " +
"If this ceiling is ever hit, something moved onto the failure path that " +
"does not belong there.");
}
/// <summary>
/// A flow whose undo carries a retry policy pays nothing for it while it is succeeding.
/// </summary>
/// <remarks>
/// <para>
/// The regression this forbids is the obvious way to build WP-57: resolving the
/// compensation's policy chain, or building a retry state object, somewhere the forward
/// loop can reach. The policy is resolved once when the plan is built and hangs off
/// <see cref="StepNode"/>; the loop that runs the flow never looks at it, and the flag
/// that says the flow has one is precomputed exactly the way
/// <see cref="ExecutionPlan.HasParallel"/> is.
/// </para>
/// <para><strong>Measured: 0 B, Release, .NET 10, x64.</strong></para>
/// </remarks>
[Fact]
public void ACompensationPolicyCostsTheSuccessPathNothing()
{
RequireOptimisedBuild();
var engine = new FlowEngine(new FakeClock(T0));
var allocated = MeasureSteadyState(engine, RetryingSaga(), new NullDispatcher());
allocated.ShouldBe(0,
$"Measured {allocated} B for a four-step saga whose first undo declares a retry. " +
"A policy nobody has needed yet is a field on a node the loop does not read.");
}
/// <summary>
/// A plan whose declarations all resolve to nothing armed costs the step loop nothing.
/// </summary>
/// <remarks>
/// <para>
/// <strong>The one way widening <c>StepPolicy.IsActive</c> could have lost B2.</strong>
/// Stages 1, 3 and 5 landed by adding fields to <c>StepPolicy</c> and counting three more
/// kinds in <c>IsActive</c> — which is
/// <a href="../../docs/adr/ADR-0023-policy-stages-hook-through-the-plan.md">ADR-0023</a>'s
/// "widening is mechanical" taken literally, and is why no plan flag went with them.
/// The failure mode that widening invites is an <c>IsActive</c> that has drifted into
/// meaning "some step declared something", at which point <c>HasStepPolicies</c> is true for
/// every flow in <c>samples/banking</c> and gates nothing.
/// </para>
/// <para>
/// <strong>The saga used to declare the kinds nothing applied; there are none.</strong> It
/// held a <c>Cache</c> and an <c>Audit</c>, which were free because no code read them. Both
/// are read now, so the plan that would go non-zero on that drift is the one whose four
/// declarations are each <em>degenerate</em>: a cache held for no time, a budget of zero
/// permits, a window that has already closed and an audit category no query could select.
/// Every one of them reaches <c>StepPolicy.From</c> and <c>StepAudit.From</c> and resolves
/// to <c>None</c>, which is exactly the distinction between "declared something" and "will
/// be wrapped" — and is a sharper fixture than the old one, because it stays valid however
/// many stages execute.
/// </para>
/// </remarks>
[Fact]
public void APlanWhoseDeclarationsArmNothingCostsTheSuccessPathNothing()
{
RequireOptimisedBuild();
var plan = DeferredPolicySaga();
plan.HasStepPolicies.ShouldBeFalse(
"Four kinds are declared and not one of them arms anything, so the flag that gates " +
"the whole policy path must stay false.");
plan.HasAuditedSteps.ShouldBeFalse(
"And the audit's own flag counts what will be written rather than what was " +
"declared, on the same bargain.");
var allocated = MeasureSteadyState(new FlowEngine(new FakeClock(T0)), plan, new NullDispatcher());
allocated.ShouldBe(0,
$"Measured {allocated} B for a four-step saga whose first step declares four " +
"policies that arm nothing. Seven kinds are counted by StepPolicy.IsActive now " +
"rather than four, and a declaration that resolves to None must still cost the " +
"flow nothing.");
}
/// <summary>The four-step saga, with four degenerate declarations on step 0.</summary>
private static ExecutionPlan DeferredPolicySaga() => ExecutionPlan.Create(
FlowDescriptor.Create("order.place", "1.0.0", ExecutionProfile.Ephemeral, TimeSpan.FromSeconds(30)),
StepGraph.Create([
StepNode.ForCapability(
0,
Plans.Validate,
policies: PolicyChain.ForStep(
PolicySet.Named("deferred")
.RateLimit(permits: 0, TimeSpan.FromSeconds(1))
.Idempotency(TimeSpan.Zero)
.Cache(TimeSpan.Zero)
.Audit(" "),
Plans.Validate)),
StepNode.ForCapability(1, Plans.Reserve, Plans.Release),
StepNode.ForCapability(2, Plans.Capture, Plans.Refund),
StepNode.ForEmit(3, "order.placed"),
]));
/// <summary>The four-step saga, with a compensation retry declared on step 1's undo.</summary>
private static ExecutionPlan RetryingSaga() => ExecutionPlan.Create(
FlowDescriptor.Create("order.place", "1.0.0", ExecutionProfile.Ephemeral, TimeSpan.FromSeconds(30)),
StepGraph.Create([
StepNode.ForCapability(0, Plans.Validate),
StepNode.ForCapability(
1,
Plans.Reserve,
Plans.Release,
compensationPolicies: PolicyChain.Create(
PolicySet.Named("undo").CompensationRetry(3), Plans.Release)),
StepNode.ForCapability(2, Plans.Capture, Plans.Refund),
StepNode.ForEmit(3, "order.placed"),
]));
/// <summary>
/// Budget B2 has to survive branching, or the DSL's most-used shape quietly buys
/// back the allocation the engine was built to avoid.
/// </summary>
/// <remarks>
/// Both directions, because they cost differently in principle: the true path falls
/// through to the next index, and the false path takes the target. Neither may
/// allocate — the predicate is a cached static delegate, <see cref="StepNode.Target"/>
/// is an <c>int?</c> read off a node that already exists, and the loop holds no
/// branch stack.
/// </remarks>
[Theory]
[InlineData(true)]
[InlineData(false)]
public void TakingEitherBranchOfAConditionalAllocatesNothing(bool predicate)
{
RequireOptimisedBuild();
var engine = new FlowEngine(new FakeClock(T0));
var dispatcher = new NullDispatcher { PredicateAnswer = predicate };
var allocated = MeasureSteadyState(engine, Plans.Conditional(), dispatcher);
allocated.ShouldBe(0,
$"Measured {allocated} B on the {(predicate ? "true" : "false")} path. A branch is an " +
"index assignment inside the existing step loop; if it costs anything, " +
"something started boxing, closing over, or enumerating.");
}
/// <summary>
/// Budget B2 has to survive a value branch too — including the arm nobody declared.
/// </summary>
/// <remarks>
/// <para>
/// Every arm, not a representative one. They cost differently in principle: arms 0..2
/// are an array read out of <see cref="StepNode.CaseTargets"/>, and the miss is the
/// <see cref="StepNode.Target"/> fallback, which is a different line of the engine.
/// A theory covering only one of them would pass against an engine that boxed the
/// answer on the other.
/// </para>
/// <para>
/// <c>-1</c> is the documented "no case matched", and <c>7</c> is out of range — a
/// dispatcher and a plan from different builds. Both must take the default target
/// rather than throw, and neither may allocate on the way.
/// </para>
/// </remarks>
[Theory]
[InlineData(0)]
[InlineData(1)]
[InlineData(2)]
[InlineData(-1)]
[InlineData(7)]
public void TakingAnyCaseOfASwitchAllocatesNothing(int arm)
{
RequireOptimisedBuild();
var engine = new FlowEngine(new FakeClock(T0));
var dispatcher = new NullDispatcher { CaseAnswer = arm };
var allocated = MeasureSteadyState(engine, Plans.Switching(), dispatcher);
allocated.ShouldBe(0,
$"Measured {allocated} B selecting arm {arm}. Taking a case is one comparison, " +
"one read out of an ImmutableArray<int> that already exists, and one assignment " +
"to the loop index. If it costs anything, something started boxing the " +
"selector's value, looking a case up in a dictionary, or closing over an arm.");
}
/// <summary>
/// A step whose input comes from <c>.Step<TCapability, TStepIn>(map)</c> costs
/// the same as one that binds from the state bag: nothing.
/// </summary>
/// <remarks>
/// <para>
/// <strong>The shape under test is the emitted one, reproduced by hand.</strong> The
/// generator writes the mapping into a
/// <c>static readonly Func<FlowContext<TIn>, TStepIn></c> field and calls it
/// as <c>StepInputs.StepN(Typed(ctx))</c>, and <see cref="MappingDispatcher"/> is that,
/// line for line. The generator's own output is asserted as text in
/// <c>StepInputMappingTests</c>; what cannot be asserted there is what the shape costs
/// when it runs, and budget B2 is a hard zero for the linear path a mapped step sits on.
/// </para>
/// <para>
/// Two things have to be free for this to hold, and both are structural. The delegate is
/// a field built once at type initialisation, so invoking it allocates nothing; and
/// <c>FlowContext<TIn></c> is a <c>readonly struct</c> over one reference, so
/// producing the typed view the mapping is written against allocates nothing either. A
/// lambda built at the call site would have cost a delegate per step per execution, and
/// a <c>FlowContext<TIn></c> that was still a class could not have existed at all.
/// </para>
/// <para>
/// <strong>Measured: 0 B, Release, .NET 10, x64</strong>, over the same four-step saga
/// the first assertion in this class uses. What the mapping's <em>body</em> allocates is
/// the author's own — <c>ctx => new CaptureRequest(…)</c> allocates a
/// <c>CaptureRequest</c>, exactly as the capability it feeds would have needed one
/// built somewhere — so the mapping here returns a pre-built value and what is measured
/// is the plumbing.
/// </para>
/// </remarks>
[Fact]
public void AMappedStepInputAllocatesNothing()
{
RequireOptimisedBuild();
var engine = new FlowEngine(new FakeClock(T0));
var allocated = MeasureSteadyState(engine, Plans.FourStepSaga(), new MappingDispatcher());
allocated.ShouldBe(0,
$"Measured {allocated} B. An explicit input mapping is a cached static delegate " +
"invoked through a readonly-struct view of the context, so it costs a call and " +
"a register. If it costs bytes, either the delegate stopped being a field or " +
"FlowContext<TIn> stopped being a struct.");
}
/// <summary>
/// A fork allocates, and this records how much rather than asserting a zero that
/// cannot be honoured.
/// </summary>
/// <remarks>
/// <para>
/// <strong>Budget B2 is unchanged and still a hard zero</strong> — for the linear,
/// conditional and switch paths, which is what it has always covered and what the four
/// theories above assert. A parallel flow is doing something none of those do: it runs
/// several branches at once, which needs a linked <c>CancellationTokenSource</c>, a
/// <c>Task</c> per branch, a list to drain them from, and the awaiters behind all of
/// that. There is no arrangement of those that costs nothing, so pretending otherwise
/// would mean either a false assertion or a fake concurrency.
/// </para>
/// <para>
/// The ceiling is what the test is for. It is deliberately close to the measured
/// figure, so that a change which quietly starts allocating <em>per step</em> inside a
/// branch — rather than per fork — trips it. The shape being defended is that the cost
/// is proportional to the number of branches and not to the work they do.
/// </para>
/// <para>
/// <strong>The measured figures, Release, .NET 10, x64.</strong> A three-branch
/// <c>AllMustSucceed</c> fork over a six-step plan, every step completing
/// synchronously: <strong>792 B</strong>. The same measurement with two branches over a
/// six-step plan: <strong>552 B</strong>. So a fork costs roughly <strong>240 B per
/// branch</strong> — one <c>Task</c> from <c>ValueTask.AsTask()</c>, its list slot and
/// its share of the awaiter — on top of about <strong>70 B</strong> fixed for the
/// linked token source and the drain list. Nothing scales with the number of steps a
/// branch runs, which is the property the second test pins.
/// </para>
/// <para>
/// The ceiling is 2048 B, the same headroom the failure path is given. The figures are
/// written down here rather than only implied by the assertion so that a future reader
/// can tell whether a change moved them and by how much.
/// </para>
/// </remarks>
[Fact]
public void AParallelFlowAllocatesAndTheAmountIsRecorded()
{
RequireOptimisedBuild();
var engine = new FlowEngine(new FakeClock(T0));
var plan = Plans.Parallel(MergeStrategy.AllMustSucceed);
var allocated = MeasureSteadyState(engine, plan, new NullDispatcher());
allocated.ShouldBeGreaterThan(0,
"A zero here would mean the branches did not actually fork — no linked token, " +
"no tasks — which is a correctness problem wearing a budget's clothes.");
allocated.ShouldBeLessThan(2048,
$"Measured {allocated} B for a three-branch fork. Concurrency is allowed to " +
"allocate; it is allowed to allocate per fork, not per step. If this ceiling " +
"is hit, something started allocating inside a branch's step loop.");
}
/// <summary>
/// The steady-state cost of a fork must not grow with the work its branches do.
/// </summary>
/// <remarks>
/// The measurement that actually protects the shape. An absolute ceiling would still
/// pass if a branch allocated a few bytes per step; comparing a two-branch fork over
/// five steps against a three-branch fork over six catches that, because the per-step
/// component would show up as a difference the per-fork component cannot explain.
/// </remarks>
[Fact]
public void TheCostOfAForkTracksItsBranchesRatherThanItsSteps()
{
RequireOptimisedBuild();
var engine = new FlowEngine(new FakeClock(T0));
var twoBranches = MeasureSteadyState(
engine, Plans.ParallelWithMultiStepBranches(MergeStrategy.AllMustSucceed), new NullDispatcher());
var threeBranches = MeasureSteadyState(
engine, Plans.Parallel(MergeStrategy.AllMustSucceed), new NullDispatcher());
// Two branches over four capability steps, against three branches over three. If
// steps cost anything, the four-step plan would be the expensive one.
threeBranches.ShouldBeGreaterThan(twoBranches,
$"A three-branch fork ({threeBranches} B) did not cost more than a two-branch " +
$"one ({twoBranches} B). Either branches are not being started per branch, or " +
"the per-step cost is drowning the per-branch one.");
}
/// <summary>
/// A loop allocates, and this records how much rather than asserting a zero that
/// cannot be honoured.
/// </summary>
/// <remarks>
/// <para>
/// <strong>Budget B2 is unchanged and still a hard zero</strong> — for the linear,
/// conditional and switch paths, which is what it has always covered and what the
/// theories above assert. A <c>ForEach</c> is doing something none of those do: it runs
/// its body once per element, and each pass needs the element to be visible to the
/// steps in it under its own type. There is no arrangement of that which costs nothing,
/// because the shared state bag is keyed by type and would give every pass the same
/// slot — which is a race above a concurrency of one and a leftover at one.
/// </para>
/// <para>
/// <strong>The measured figures, Release, .NET 10, x64.</strong> A sequential loop over
/// three elements with a one-step body: <strong>96 B</strong>, which is
/// <strong>32 B per element</strong> — one <c>IterationScope<T></c>, and nothing
/// else: an object header and the two references it holds, the enclosing context and
/// the element. The same loop over six elements: <strong>192 B</strong>. Nothing at all
/// is charged per step of the body, which is the property the companion test pins.
/// </para>
/// <para>
/// The ceiling is deliberately close to the figure, so that a change which starts
/// allocating per <em>step</em> rather than per element trips it. Written down here
/// rather than only implied by the assertion so a future reader can tell whether a
/// change moved it and by how much.
/// </para>
/// </remarks>
[Fact]
public void AForEachAllocatesAndTheAmountIsRecorded()
{
RequireOptimisedBuild();
var engine = new FlowEngine(new FakeClock(T0));
var allocated = MeasureSteadyState(
engine, Plans.ForEachWithOneStepBody(), new NullDispatcher { Elements = ThreeElements });
allocated.ShouldBeGreaterThan(0,
"A zero here would mean the elements did not get their own scope — which is " +
"a correctness problem wearing a budget's clothes, because every pass would " +
"then read the same slot.");
allocated.ShouldBeLessThan(256,
$"Measured {allocated} B for a three-element loop with a one-step body. A loop " +
"is allowed to allocate per element; it is not allowed to allocate per step. " +
"If this ceiling is hit, something started allocating inside the body's step loop.");
}
/// <summary>
/// The steady-state cost of a loop must track the elements, not the body.
/// </summary>
/// <remarks>
/// The measurement that actually protects the shape. An absolute ceiling would still
/// pass if each body step allocated a few bytes; comparing three elements over a
/// one-step body against three elements over a two-step body catches that, because a
/// per-step component would show up as a difference the per-element component cannot
/// explain. The second comparison is the other axis: twice the elements really does
/// cost about twice as much, because a scope per element is what a loop buys.
/// </remarks>
[Fact]
public void TheCostOfALoopTracksItsElementsRatherThanItsSteps()
{
RequireOptimisedBuild();
var engine = new FlowEngine(new FakeClock(T0));
var oneStepBody = MeasureSteadyState(
engine, Plans.ForEachWithOneStepBody(), new NullDispatcher { Elements = ThreeElements });
var twoStepBody = MeasureSteadyState(
engine, Plans.ForEach(), new NullDispatcher { Elements = ThreeElements });
var twiceTheElements = MeasureSteadyState(
engine, Plans.ForEachWithOneStepBody(), new NullDispatcher { Elements = SixElements });
twoStepBody.ShouldBe(oneStepBody,
$"Doubling the work per element changed the cost from {oneStepBody} B to " +
$"{twoStepBody} B. A loop pays for elements, not for the steps inside them.");
twiceTheElements.ShouldBe(oneStepBody * 2,
$"Six elements cost {twiceTheElements} B against {oneStepBody} B for three. " +
"The cost is one scope per element; anything else means a fixed cost crept in " +
"or a per-element one grew.");
}
/// <summary>
/// A composition allocates, and this records how much rather than asserting a zero it
/// cannot promise.
/// </summary>
/// <remarks>
/// <para>
/// <strong>Budget B2 is unchanged and still a hard zero</strong> for the linear,
/// conditional and switch paths, which is what it has always covered and what the
/// theories above assert. A sub-flow is doing something none of those do: it runs a
/// second, independent flow, with its own context, its own compensation stack and its
/// own deadline.
/// </para>
/// <para>
/// <strong>The measured figure, Release, .NET 10, x64: 0 B</strong> for a parent whose
/// child completes synchronously and leaves compensations pending. That is worth
/// writing down rather than quietly enjoying, because it is not "sub-flows are free" —
/// it is that every part of a composition was made pooled or a struct on purpose. The
/// child's context comes from the same <c>ContextPool</c> the parent's does; its
/// compensation stack is owned by that context and reset rather than rebuilt;
/// <c>SubFlowSource</c> and <c>FlowInvocation</c> are structs; the engine awaits the
/// child's range directly rather than through <c>Task</c>, so no state machine is boxed
/// when the child's steps complete synchronously; and the children still rented at the
/// end are found through a list the context owns rather than by walking the compensation
/// stack, which the first version did and which cost <strong>96 B</strong> — one
/// iterator per nesting level, on the success path.
/// </para>
/// <para>
/// <strong>What a real composition does allocate is the author's own input.</strong>
/// <c>ctx => new FulfilOrder(ctx.Get<OrderId>())</c> is a record construction,
/// and it is charged to the flow that wrote it — the dispatcher here returns a
/// pre-built value so the measurement is the engine's and not the test's litter, which
/// is the same convention the <c>ForEach</c> measurement uses for its elements.
/// </para>
/// <para>
/// The ceiling is 512 B, and the assertion is deliberately <em>not</em> "greater than
/// zero": unlike a fork, where a zero would mean the branches never actually forked,
/// there is no correctness claim hiding inside a composition costing nothing. What the
/// ceiling defends is that the cost stays per-composition — if it is ever hit, something
/// started allocating per step inside the child, or the child's context stopped coming
/// from the pool.
/// </para>
/// </remarks>
[Fact]
public void ASubFlowAllocatesAndTheAmountIsRecorded()
{
RequireOptimisedBuild();
var engine = new FlowEngine(new FakeClock(T0));
var allocated = MeasureSteadyState(
engine, Plans.Composing(), new ComposingDispatcher(new NullDispatcher()));
allocated.ShouldBeLessThan(512,
$"Measured {allocated} B for a flow composing a two-step child that leaves a " +
"compensation pending. A composition is allowed to cost something per child; " +
"it is not allowed to cost anything per step of that child, and it must not " +
"stop renting the child's context from the pool.");
}
/// <summary>
/// The steady-state cost of a composition must not grow with the child's work.
/// </summary>
/// <remarks>
/// The measurement that actually protects the shape, in the same spirit as the fork's
/// and the loop's. An absolute ceiling would still pass if each of the child's steps
/// allocated a few bytes; comparing a two-step child against a four-step one catches it,
/// because a per-step component would show up as a difference the per-composition
/// component cannot explain.
/// </remarks>
[Fact]
public void TheCostOfACompositionTracksTheChildRatherThanItsSteps()
{
RequireOptimisedBuild();
var engine = new FlowEngine(new FakeClock(T0));
var twoStepChild = MeasureSteadyState(
engine, Plans.Composing(), new ComposingDispatcher(new NullDispatcher()));
var fourStepChild = MeasureSteadyState(
engine,
Plans.Composing(),
new ComposingDispatcher(new NullDispatcher(), Plans.FourStepSaga()));
fourStepChild.ShouldBe(twoStepChild,
$"Doubling the child's work changed the cost from {twoStepChild} B to " +
$"{fourStepChild} B. A composition pays for the child, not for the steps " +
"inside it.");
}
/// <summary>
/// A dispatcher that composes a child at step 1 and allocates nothing itself.
/// </summary>
/// <remarks>
/// The input is a pre-built object rather than one created per call, for the reason the
/// loop's elements are pre-boxed: what is being measured is the engine's per-composition
/// cost, not the mapping the author wrote.
/// </remarks>
private sealed class ComposingDispatcher(IStepDispatcher child, ExecutionPlan? childPlan = null)
: IStepDispatcher
{
private static readonly object Input = new();
private readonly ExecutionPlan _plan = childPlan ?? Plans.Child();
public ValueTask<StepOutcome> ExecuteAsync(int stepIndex, FlowContext ctx, CancellationToken ct)
=> ValueTask.FromResult(StepOutcome.Success);
public ValueTask<StepOutcome> CompensateAsync(int stepIndex, FlowContext ctx, CancellationToken ct)
=> ValueTask.FromResult(StepOutcome.Success);
public bool Evaluate(int stepIndex, FlowContext ctx) => true;
public int Select(int stepIndex, FlowContext ctx) => -1;
public IterationSource BeginIteration(int stepIndex, FlowContext ctx) =>
throw new NotSupportedException("This dispatcher has no iteration to begin.");
public FlowContext EnterIteration(int stepIndex, in IterationSource source, int iteration, FlowContext ctx) =>
throw new NotSupportedException("This dispatcher has no iteration to enter.");
public SubFlowSource BeginSubFlow(int stepIndex, FlowContext ctx) =>
new(_plan, child, Input);
public void EnterSubFlow(int stepIndex, in SubFlowSource source, FlowContext child) =>
child.Set(source.Input!);
}
/// <summary>
/// A durable flow pays for its journal, and this records how much of that is the
/// engine's rather than the store's.
/// </summary>
/// <remarks>
/// <para>
/// <strong>Budget B2 is unchanged and still a hard zero</strong> for the linear,
/// conditional and switch paths of an <em>ephemeral</em> flow, which is what it has
/// always covered and what the assertions above pin. B2 was never a budget on durability:
/// a journaled step boundary is roughly 1–15 ms against ~1 µs in memory, so a handful of
/// bytes is not the interesting cost of one. What matters is that the ephemeral path pays
/// none of it — a durable seam that charged every flow would be a second engine wearing
/// one engine's name.
/// </para>
/// <para>
/// <strong>The store is deliberately not in the measurement.</strong>
/// <see cref="NullJournal"/> accepts every commit and allocates nothing, so what is left
/// is what the <em>engine</em> spends to describe a step boundary: the
/// <c>StepCommit</c> record, the non-determinism envelope, the scope path where there is
/// one, and the awaiters behind an interface call that could have gone asynchronous. A
/// real store's row, its transaction and its round trip are its own to measure, and
/// <c>JournalBenchmarks</c> is where B7 does it.
/// </para>
/// <para>
/// <strong>The measured figures, Release, .NET 10, x64.</strong> The four-step saga
/// declared <c>Durable</c>: <strong>768 B</strong>, which is <strong>192 B per
/// step</strong> — a <c>StepCommit</c>, a <c>NondeterminismCapture</c>, and the awaiters
/// behind an interface call that could have suspended. The identical plan declared
/// <c>Ephemeral</c>: <strong>0 B</strong>, which the assertion below it pins. The ceiling
/// is per step rather than per flow so that it keeps its meaning when the plan changes,
/// and it is close enough to the figure that a new per-step allocation shows up rather
/// than hiding in headroom.
/// </para>
/// </remarks>
[Fact]
public void ADurableFlowPaysForItsJournalAndTheAmountIsRecorded()
{
RequireOptimisedBuild();
var engine = new FlowEngine(new FakeClock(T0));
var plan = Durable(Plans.FourStepSaga());
var allocated = MeasureDurableSteadyState(engine, plan, new NullDispatcher());
allocated.ShouldBeGreaterThan(0,
"A zero here would mean nothing was journaled, which is a correctness problem " +
"wearing a budget's clothes.");
(allocated / plan.Graph.Count).ShouldBeLessThan(384,
$"Measured {allocated} B over {plan.Graph.Count} steps. A journaled boundary is " +
"allowed to allocate; it is allowed to allocate per boundary, not per anything " +
"else. If this ceiling is hit, something started building a payload, a list or a " +
"closure the seam did not need.");
}
/// <summary>
/// The ephemeral path pays nothing for the existence of the durable one.
/// </summary>
/// <remarks>
/// <para>
/// The regression this forbids is the one ADR-0015 names as its accepted cost and its
/// biggest risk: "the ephemeral hot path grows a branch it does not need". It is allowed
/// to grow the branch. It is not allowed to grow an allocation — the same bargain
/// <c>ExecutionPlan.HasParallel</c> struck, where a linear flow pays one predictable,
/// always-false comparison for a fork lock it never takes.
/// </para>
/// <para>
/// <strong>The outbox is inside this assertion, not beside it.</strong> The plan is
/// <c>FourStepSaga</c>, whose last step is an <c>Emit</c>, and the dispatcher describes an
/// event for it. So an ephemeral flow that emits pays nothing for the outbox: not the
/// list, not the array, not the record.
/// </para>
/// </remarks>
[Fact]
public void TheEphemeralPathPaysNothingForTheExistenceOfTheDurableOne()
{
RequireOptimisedBuild();
var plan = Plans.FourStepSaga();
plan.HasEmit.ShouldBeTrue(
"The measurement is only about the outbox if the plan has an event in it.");
var engine = new FlowEngine(new FakeClock(T0));
var allocated = MeasureSteadyState(engine, plan, new JournallingDispatcher());
allocated.ShouldBe(0,
$"Measured {allocated} B for an ephemeral flow whose dispatcher can describe a " +
"step for a journal and an event for the outbox. The seam is gated on the flow's " +
"declared profile, so this must be exactly what it was before the journal existed.");
}
/// <summary>
/// Staging an event costs the durable path one array and no more, and the amount is
/// recorded.
/// </summary>
/// <remarks>
/// <para>
/// The complement of the assertion above: the ephemeral path pays nothing, and the
/// durable path pays the honest price of the row it is about to write. That price is the
/// single-element <c>IReadOnlyList<OutboxWrite></c> the commit carries — the
/// <c>OutboxWrite</c> itself and its <c>JournalPayload</c> are the generated dispatcher's,
/// built where the flow's own contract type is nameable, and this double pre-builds them
/// for the same reason every other double in this file pre-builds its answers.
/// </para>
/// <para>
/// <strong>Measured, Release, .NET 10, x64: 792 B over four steps</strong>, against 768 B
/// for the identical plan whose dispatcher describes no event. The 24 B difference is one
/// one-element array, allocated once for the one step that emits.
/// </para>
/// </remarks>
[Fact]
public void StagingAnEventCostsTheDurablePathOneArray()
{
RequireOptimisedBuild();
var engine = new FlowEngine(new FakeClock(T0));
var plan = Durable(Plans.FourStepSaga());
var silent = MeasureDurableSteadyState(engine, plan, new NullDispatcher());
var emitting = MeasureDurableSteadyState(engine, plan, new JournallingDispatcher());
(emitting - silent).ShouldBeGreaterThan(0,
"A zero here would mean the event never reached the commit, which is the defect " +
"this whole package exists to close.");
(emitting - silent).ShouldBeLessThan(128,
$"Measured {emitting} B against {silent} B without an event. Staging is one " +
"array per emitting step. Anything materially larger means the seam started " +
"building a list, a builder or a closure it does not need.");
}
/// <summary>The same plan, re-declared <c>Durable</c>.</summary>
private static ExecutionPlan Durable(ExecutionPlan plan) => ExecutionPlan.Create(
FlowDescriptor.Create(
plan.Flow.Id, plan.Flow.Version, ExecutionProfile.Durable, plan.Flow.Deadline),
plan.Graph);
/// <summary>
/// <see cref="MeasureSteadyState"/> for a journaled flow: a fresh instance per run,
/// because an append-only journal refuses a key it has already seen.
/// </summary>
/// <remarks>
/// The instance id and the <c>DurableExecution</c> are built outside the measured region,
/// so what is counted is the execution and not the session that carries it. Opening an
/// instance is once per flow and a store's cost anyway.
/// </remarks>
[MethodImpl(MethodImplOptions.NoInlining)]
private static long MeasureDurableSteadyState(
FlowEngine engine, ExecutionPlan plan, IStepDispatcher dispatcher)
{
var journal = new NullJournal();
for (var i = 0; i < 64; i++)
{
RunSync(engine.ExecuteAsync(plan, dispatcher, Plans.Invocation, journal.Open()));
}
var run = journal.Open();
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
var before = GC.GetAllocatedBytesForCurrentThread();
RunSync(engine.ExecuteAsync(plan, dispatcher, Plans.Invocation, run));
return GC.GetAllocatedBytesForCurrentThread() - before;
}
/// <summary>
/// A journal that accepts everything and allocates nothing, so the measurement is the
/// engine's.
/// </summary>
/// <remarks>
/// Every answer is a pre-built value returned through an already-completed
/// <see cref="ValueTask{TResult}"/>, which is the same convention the dispatcher doubles
/// in this file follow: what a real store spends on a row and a transaction is its own,
/// and mixing the two would produce a number that means nothing about either.
/// </remarks>
private sealed class NullJournal : IFlowJournal
{
private static readonly FencingToken Token = new(1);
private static readonly JournalStep Committed = new()
{
Key = StepKey.First(Guid.Empty, 0),
Sequence = 1,
CapabilityId = "measured",
CapabilityVersion = "1.0.0",
Outcome = JournalOutcome.Success,
};
private readonly ValueTask<Result<JournalStep>> _commit = new(Result.Ok(Committed));
private FlowInstanceRecord _instance = new()
{
InstanceId = Guid.Empty,
FlowId = "measured",
FlowVersion = "1.0.0",
State = FlowInstanceState.Running,
Fence = Token,
};
/// <summary>A session over a fresh instance, so no key is ever committed twice.</summary>
public DurableExecution Open()
{
var instanceId = Guid.NewGuid();
_instance = _instance with { InstanceId = instanceId };
// Through a Task, not off the ValueTask: reading a ValueTask that has not
// finished is undefined rather than merely slow, and this runs outside every
// measured region so the conversion costs the measurement nothing.
var begun = DurableExecution
.BeginAsync(this, Plans.FourStepSaga(), Plans.Invocation, instanceId, Token)
.AsTask();
return begun.GetAwaiter().GetResult().Value;
}
public ValueTask<Result<FlowInstanceRecord>> StartAsync(
FlowInstanceStart start, CancellationToken cancellationToken) =>
new(Result.Ok(_instance));
public ValueTask<Result<FencingToken>> FenceAsync(
Guid instanceId, FencingToken token, CancellationToken cancellationToken) =>
new(Result.Ok(token));
public ValueTask<Result<JournalStep>> CommitAsync(
StepCommit commit, CancellationToken cancellationToken) => _commit;
public ValueTask<Result<FlowInstanceRecord>> CompleteAsync(
Guid instanceId,
FencingToken token,
FlowInstanceState state,
JournalPayload stateBag,
FlowWake? wake,
CancellationToken cancellationToken) =>
new(Result.Ok(_instance));
public ValueTask<Result<FlowInstanceRecord>> ReadInstanceAsync(
Guid instanceId, CancellationToken cancellationToken) =>
new(Result.Ok(_instance));
public ValueTask<Result<ResumeFrontier>> ReadResumeFrontierAsync(
Guid instanceId, CancellationToken cancellationToken) =>
new(Result.Ok(new ResumeFrontier { Instance = _instance, Committed = [] }));
public ValueTask<Result<IReadOnlyList<OutboxRecord>>> ReadOutboxAsync(
Guid instanceId, CancellationToken cancellationToken) =>
new(Result.Ok<IReadOnlyList<OutboxRecord>>([]));
}
/// <summary>
/// A dispatcher that can describe a step for a journal, and allocates nothing doing it.
/// </summary>
/// <remarks>
/// <para>
/// Present so the ephemeral measurement is taken against a dispatcher that <em>could</em>
/// have been asked. A double with no <c>DescribeStep</c> at all would prove only that an
/// absent member costs nothing.
/// </para>
/// <para>
/// <strong>It describes an event for the plan's <c>Emit</c> step</strong>, because
/// <c>Plans.FourStepSaga</c> has one and the point of the measurement is that an
/// ephemeral flow pays nothing for the outbox. A double that described a result and no
/// event would leave that untested and the assertion would go on reading zero for the
/// wrong reason. Step 3 and no other, which is what a generated dispatcher does.
/// </para>
/// <para>
/// The event is a static, so what is measured is the engine's route to it rather than
/// this class's litter — the same convention every other double in this file follows.
/// </para>
/// </remarks>
private sealed class JournallingDispatcher : IStepDispatcher
{
private const int EmitStep = 3;
private static readonly StepJournalEntry Entry = StepJournalEntry.OfEvent(
new OutboxWrite { Type = "order.placed", SchemaVersion = "1.0.0" });
public ValueTask<StepOutcome> ExecuteAsync(int stepIndex, FlowContext ctx, CancellationToken ct)
=> ValueTask.FromResult(StepOutcome.Success);
public ValueTask<StepOutcome> CompensateAsync(int stepIndex, FlowContext ctx, CancellationToken ct)
=> ValueTask.FromResult(StepOutcome.Success);
public bool Evaluate(int stepIndex, FlowContext ctx) => true;
public int Select(int stepIndex, FlowContext ctx) => -1;
public IterationSource BeginIteration(int stepIndex, FlowContext ctx) =>
throw new NotSupportedException("This dispatcher has no iteration to begin.");
public FlowContext EnterIteration(int stepIndex, in IterationSource source, int iteration, FlowContext ctx) =>
throw new NotSupportedException("This dispatcher has no iteration to enter.");
public StepJournalEntry DescribeStep(int stepIndex, FlowContext ctx) =>
stepIndex == EmitStep ? Entry : StepJournalEntry.Nothing;
}
/// <summary>
/// The guarded state bag must not cost a flow that never forks anything at all.
/// </summary>
/// <remarks>
/// The specific regression this forbids is the obvious fix for the concurrency problem: