Skip to content

Commit 09947d2

Browse files
feat(hot-reload): the metadata-delta route is on by default for hot reload workers, with a measured cost and the wire to say so
With the variable on, the whole host tier (312 tests, none failed) and the eight browser journeys passed, a save to a run_app app is served in 2.6 s on .NET 10 and 1.8 s on .NET 11 against about 7.5 s and 5.8 s for the restart it replaces, and a loop of 50 million calls costs the same with the process editable (489 against 481 ms, 444 against 448 ms). SAGEFS_METADATA_DELTA=off is the way back. The daemon's lastReload and ReloadReported event now carry the worker's mechanism (detour, metadata-delta, empty for a verdict that is not a patch) as a closed set, so a client never has to read it from the words. The fixture gets a call-heavy route, and RunAppDeltaTests prints the cost (DELTA-COST) with a tripwire at twice. The docs say what the route takes, what it refuses and why, what it costs, and what it leaves undone: the REPL and live tests keep the last build's code until a restart. Prior art credited to dotnet/fsharp#19941 (Nat Elkins). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016SwvKtCMh6Dg7JAWDof5LS
1 parent 4b1a6d4 commit 09947d2

22 files changed

Lines changed: 418 additions & 45 deletions

‎SageFs.Core/Features/MetadataDelta/MetadataDeltaFlag.fs‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,11 @@ module MetadataDeltaMode =
2424
/// What it is set to for the route.
2525
let modifiableAssembliesValue = "debug"
2626

27-
/// What a process gets when nothing says otherwise.
28-
let defaultMode : MetadataDeltaMode = MetadataDeltaMode.Off
27+
/// What a process gets when nothing says otherwise. On, because the whole host tier and the browser journeys pass with the
28+
/// route on, a save is patched in about a third of the time a restart took and keeps the app's state, and being editable
29+
/// costs a call-heavy loop nothing measurable on a build SageFs already makes unoptimized (docs/decisions.md has the
30+
/// numbers). `SAGEFS_METADATA_DELTA=off` is the way back.
31+
let defaultMode : MetadataDeltaMode = MetadataDeltaMode.On
2932

3033
/// The spellings below turn it on or off. Anything else, an empty value included, is the default.
3134
let parse (value: string | null) : MetadataDeltaMode =

‎SageFs.Core/Features/ReloadOutcome.fs‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,14 @@ module PatchMechanism =
259259
| PatchMechanism.Detour -> "detour"
260260
| PatchMechanism.MetadataDelta -> "metadata-delta"
261261

262+
/// The mechanism a payload names. A spelling nobody knows is no mechanism, so a client older than the worker shows
263+
/// a patch with nothing to say about how, never a wrong how.
264+
let ofWireName (name: string) : PatchMechanism =
265+
match name with
266+
| "detour" -> PatchMechanism.Detour
267+
| "metadata-delta" -> PatchMechanism.MetadataDelta
268+
| _ -> PatchMechanism.NoPatch
269+
262270
/// The stages of a patch applied as a metadata delta, which mirror the detour's: applied and not yet seen running,
263271
/// seen running, never seen. A separate type so a delta's outcome cannot be built as anything but one of the three.
264272
[<RequireQualifiedAccess>]

‎SageFs.Core/SessionReload.fs‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,9 @@ type ReloadFacts = {
4949
Message: string
5050
/// What to do about it; empty when nothing is needed.
5151
SuggestedAction: string
52+
/// How a patch reached the process: a detour in the reload agent, or a metadata delta in the worker. `NoPatch` for a
53+
/// verdict that is not a patch (a restart, a compile failure, nothing changed).
54+
Mechanism: SageFs.Features.ReloadOutcome.PatchMechanism
5255
}
5356

5457
[<RequireQualifiedAccess>]
@@ -147,7 +150,8 @@ module SessionReload =
147150
Patched = count "patched"
148151
Considered = count "considered"
149152
Message = text "message"
150-
SuggestedAction = text "suggestedAction" })
153+
SuggestedAction = text "suggestedAction"
154+
Mechanism = SageFs.Features.ReloadOutcome.PatchMechanism.ofWireName (text "mechanism") })
151155
| _ -> Result.Error ReloadPayloadError.NoOutcome
152156
match text "type" with
153157
| "none" -> Result.Ok SessionReload.NoReloadYet
@@ -174,7 +178,8 @@ module SessionReload =
174178
patched = facts.Patched
175179
considered = facts.Considered
176180
message = facts.Message
177-
suggestedAction = facts.SuggestedAction |}
181+
suggestedAction = facts.SuggestedAction
182+
mechanism = SageFs.Features.ReloadOutcome.PatchMechanism.wireName facts.Mechanism |}
178183

179184
/// One line for a person or an agent: what the save did, or that it is still
180185
/// compiling, or that nothing has been saved yet.

‎SageFs.Tests/DaemonStateChangeContractTests.fs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ let daemonStateChangeContractTests =
3737

3838
testCase "ReloadReported serializes with the owning session ID and what the worker said the save did" <| fun _ ->
3939
let facts : ReloadFacts =
40-
{ Case = ReloadCase.RestartRequired; Patched = 0; Considered = 2; Message = "restart the app to apply this"; SuggestedAction = "restart" }
40+
{ Case = ReloadCase.RestartRequired; Patched = 0; Considered = 2; Message = "restart the app to apply this"; SuggestedAction = "restart"; Mechanism = SageFs.Features.ReloadOutcome.PatchMechanism.NoPatch }
4141
let json = SseEvent.toJson (SseEvent.ReloadReported (sid "cafe0001", SessionReload.Finished facts))
4242
json |> Expect.stringContains "the owning session" "\"sessionId\":\"cafe0001\""
4343
json |> Expect.stringContains "the outcome token" "\"outcome\":\"RestartRequired\""

‎SageFs.Tests/DeltaRouteTests.fs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ let deltaRouteTests =
123123
"maybe", MetadataDeltaMode.defaultMode; "", MetadataDeltaMode.defaultMode ]
124124
|> List.iter (fun (text, expected) -> MetadataDeltaMode.parse text |> Expect.equal (sprintf "%A" text) expected)
125125
MetadataDeltaMode.parse null |> Expect.equal "unset is the default" MetadataDeltaMode.defaultMode
126+
MetadataDeltaMode.defaultMode |> Expect.equal "and the default is on: the escape hatch is SAGEFS_METADATA_DELTA=off" MetadataDeltaMode.On
126127

127128
// -- what a chain is good for ---------------------------------------------------------------------------
128129

‎SageFs.Tests/MetadataDeltaTests.fs‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -397,12 +397,13 @@ let metadataDeltaTests =
397397

398398
// -- the switch and the closed sets ---------------------------------------------------------------------
399399

400-
testCase "WHY - the delta path is off unless the process turns it on, and only the named spellings do" <| fun _ ->
401-
[ null, MetadataDeltaMode.Off
402-
"", MetadataDeltaMode.Off
400+
testCase "WHY - the delta path is on unless the process turns it off, and only the named spellings do" <| fun _ ->
401+
[ null, MetadataDeltaMode.On
402+
"", MetadataDeltaMode.On
403+
"yes please", MetadataDeltaMode.On
403404
"0", MetadataDeltaMode.Off
404405
"off", MetadataDeltaMode.Off
405-
"yes please", MetadataDeltaMode.Off
406+
" FALSE ", MetadataDeltaMode.Off
406407
"1", MetadataDeltaMode.On
407408
"on", MetadataDeltaMode.On
408409
" TRUE ", MetadataDeltaMode.On ]

‎SageFs.Tests/PatchConfirmationTests.fs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -235,7 +235,7 @@ let tests =
235235

236236
testCase "WHY — the session status carries the new cases under their own tokens, so an agent reading lastReload can tell applied from seen running" <| fun _ ->
237237
for case, token in [ ReloadCase.PatchPending, "PatchPending"; ReloadCase.NeverEntered, "NeverEntered"; ReloadCase.Patched, "Patched" ] do
238-
let facts : ReloadFacts = { Case = case; Patched = 0; Considered = 2; Message = "m"; SuggestedAction = "a" }
238+
let facts : ReloadFacts = { Case = case; Patched = 0; Considered = 2; Message = "m"; SuggestedAction = "a"; Mechanism = SageFs.Features.ReloadOutcome.PatchMechanism.NoPatch }
239239
let wire = SessionReload.toWire (SessionReload.Finished facts)
240240
System.Text.Json.JsonSerializer.Serialize wire
241241
|> Expect.stringContains (sprintf "%A is reported as %s" case token) (sprintf "\"outcome\":\"%s\"" token)

‎SageFs.Tests/PushEventTests.fs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ let private emptyActionQueue =
2828
SageFs.Features.ActionPrioritizer.ActionQueueReport.empty
2929

3030
let private sampleReload : SageFs.ReloadFacts =
31-
{ Case = SageFs.ReloadCase.Patched; Patched = 3; Considered = 5; Message = "3 of 5 changed definitions are live"; SuggestedAction = "" }
31+
{ Case = SageFs.ReloadCase.Patched; Patched = 3; Considered = 5; Message = "3 of 5 changed definitions are live"; SuggestedAction = ""; Mechanism = SageFs.Features.ReloadOutcome.PatchMechanism.Detour }
3232

3333
let private allPushEvents = [
3434
PushEvent.DiagnosticsChanged []

‎SageFs.Tests/RunAppDeltaHarness.fs‎

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -164,9 +164,20 @@ let private runApp (proxy: SessionProxy) (project: string) : Task<int> = task {
164164

165165
/// Spin the whole thing up: scratch copy, SageFs build, host, `run_app`, the app answering. The worker
166166
/// watches the project's sources for hot reload as soon as the app is running.
167-
let startRunAppWith (deltaMode: SageFs.Features.MetadataDelta.MetadataDeltaMode) (runtime: HostRuntime) : Task<RunningApp> = task {
167+
/// How long the parts of a start took.
168+
type StartTimes =
169+
{ /// `dotnet build` of the scratch project, which every route pays.
170+
BuildMs: float
171+
/// From starting the host to the app answering: a new process, its session, the project loaded, the app started.
172+
/// This is what a restart pays on top of the build.
173+
ProcessMs: float }
174+
175+
let startRunAppTimed (deltaMode: SageFs.Features.MetadataDelta.MetadataDeltaMode) (runtime: HostRuntime) : Task<RunningApp * StartTimes> = task {
168176
let runDir, project = copyFixture runAppFixture runtime
177+
let watch = Stopwatch.StartNew()
169178
do! buildAsSageFsDoes runDir project
179+
let buildMs = watch.Elapsed.TotalMilliseconds
180+
watch.Restart()
170181
let hostLog = StringBuilder()
171182
let! proc, workerUrl = spawnHost deltaMode runtime runDir project hostLog
172183
let proxy = HttpWorkerClient.httpProxy workerUrl
@@ -196,10 +207,15 @@ let startRunAppWith (deltaMode: SageFs.Features.MetadataDelta.MetadataDeltaMode)
196207
| true -> ()
197208
| false -> do! Task.Delay TestTimeouts.pollService
198209
match answered with
199-
| true -> return app
210+
| true -> return app, { BuildMs = buildMs; ProcessMs = watch.Elapsed.TotalMilliseconds }
200211
| false -> return failwithf "the run_app fixture never answered /%s.\n%s" runAppFixture.ReadyRoute (RunningApp.log app)
201212
}
202213

214+
let startRunAppWith (deltaMode: SageFs.Features.MetadataDelta.MetadataDeltaMode) (runtime: HostRuntime) : Task<RunningApp> = task {
215+
let! app, _ = startRunAppTimed deltaMode runtime
216+
return app
217+
}
218+
203219
/// The route as a user gets it when the flag is on.
204220
let startRunApp (runtime: HostRuntime) : Task<RunningApp> =
205221
startRunAppWith SageFs.Features.MetadataDelta.MetadataDeltaMode.On runtime

‎SageFs.Tests/RunAppDeltaTests.fs‎

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,3 +330,106 @@ let runAppDeltaTests =
330330
stop app
331331
}
332332
]
333+
334+
// -- what the route costs -------------------------------------------------------------------------------------------
335+
336+
/// How many times each mode runs the call-heavy loop after a warm-up run.
337+
let private spinRuns = 5
338+
339+
/// The most the call-heavy loop may cost when the process can be edited, as a multiple of what it costs when it cannot.
340+
/// The measured figure is well under this: it is a tripwire for the cost doubling, not a target.
341+
let private spinCostBound = 2.0
342+
343+
/// How many saves the latency run makes.
344+
let private latencySaves = 6
345+
346+
let private median (values: float list) : float =
347+
let sorted = List.sort values
348+
sorted[sorted.Length / 2]
349+
350+
let private spinMs (app: RunningApp) : Task<float> = task {
351+
let! answer = get app "spin"
352+
return float (answer.Substring(0, answer.IndexOf ':'))
353+
}
354+
355+
let private spinTimes (mode: SageFs.Features.MetadataDelta.MetadataDeltaMode) (runtime: HostRuntime) : Task<float list> = task {
356+
let! app = startRunAppWith mode runtime
357+
try
358+
// The first run pays the JIT and the first-request costs.
359+
let! _ = spinMs app
360+
let times = ResizeArray<float>()
361+
for _ in 1 .. spinRuns do
362+
let! ms = spinMs app
363+
times.Add ms
364+
return List.ofSeq times
365+
finally
366+
stop app
367+
}
368+
369+
let private machine () : string =
370+
sprintf "%d logical cores, %s, %s" Environment.ProcessorCount System.Runtime.InteropServices.RuntimeInformation.OSDescription System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription
371+
372+
/// The milliseconds a worker log line names after `label`, for every line that has it.
373+
let private loggedMs (log: string) (label: string) : float list =
374+
System.Text.RegularExpressions.Regex.Matches(log, label + @" (\d+) ms")
375+
|> Seq.map (fun m -> float m.Groups[1].Value)
376+
|> List.ofSeq
377+
378+
[<Tests>]
379+
let runAppDeltaCostTests =
380+
Integration.hostList "run_app metadata delta cost" [
381+
for runtime in HostRuntime.all do
382+
testTask (sprintf "[%s] a process started so it can take a delta runs a call-heavy loop within a small multiple of one that was not" (HostRuntime.moniker runtime)) {
383+
let! off = spinTimes SageFs.Features.MetadataDelta.MetadataDeltaMode.Off runtime
384+
let! on = spinTimes SageFs.Features.MetadataDelta.MetadataDeltaMode.On runtime
385+
let ratio = median on / median off
386+
eprintfn "DELTA-COST machine: %s" (machine ())
387+
eprintfn "DELTA-COST [%s] %d calls of a NoInlining method, n=%d runs after a warm-up: route off %s ms (median %.0f), route on %s ms (median %.0f), ratio %.2f"
388+
(HostRuntime.moniker runtime) 50000000 spinRuns
389+
(off |> List.map (sprintf "%.0f") |> String.concat ", ") (median off)
390+
(on |> List.map (sprintf "%.0f") |> String.concat ", ") (median on)
391+
ratio
392+
(ratio, spinCostBound) |> Expect.isLessThan (sprintf "the route's cost on a call-heavy loop (off %A ms, on %A ms)" off on)
393+
}
394+
testTask (sprintf "[%s] what a save costs: file written to new code served, with the build, the diff and the runtime's call apart, against a process start" (HostRuntime.moniker runtime)) {
395+
let! app, started = startRunAppTimed SageFs.Features.MetadataDelta.MetadataDeltaMode.On runtime
396+
try
397+
let served = ResizeArray<float>()
398+
// The reload watcher drops a second change to a file inside its double-compile guard, so the first write waits it out.
399+
do! Task.Delay (SageFs.DevReload.DevReloadConfig.defaults.DoubleCompileGuardMs * 3)
400+
let letters = [ 'A'; 'B'; 'C'; 'D'; 'E'; 'F'; 'G'; 'H' ] |> List.truncate (latencySaves + 1)
401+
for was, now in List.pairwise letters do
402+
let source = System.IO.File.ReadAllText app.StateSource
403+
let find = sprintf "\"closure:%c\"" was
404+
let after = source.Replace(find, sprintf "\"closure:%c\"" now)
405+
after |> Expect.notEqual (sprintf "the anchor %s is in the file" find) source
406+
let watch = System.Diagnostics.Stopwatch.StartNew()
407+
System.IO.File.WriteAllText(app.StateSource, after)
408+
let want = sprintf "closure:%c!?" now
409+
let mutable answer = ""
410+
while answer <> want && watch.Elapsed < TestTimeouts.saveVerdict do
411+
let! read = tryGet app "closure"
412+
answer <- read
413+
match answer = want with
414+
| true -> ()
415+
| false -> do! Task.Delay TestTimeouts.pollMeasure
416+
answer |> Expect.equal "the process serves the new body" want
417+
served.Add watch.Elapsed.TotalMilliseconds
418+
// The watcher's double-compile guard, again, before the next write.
419+
do! Task.Delay (SageFs.DevReload.DevReloadConfig.defaults.DoubleCompileGuardMs * 3)
420+
let log = RunningApp.log app
421+
let build = loggedMs log "build"
422+
let prepare = loggedMs log "diff and write"
423+
let apply = loggedMs log "apply"
424+
eprintfn "DELTA-COST machine: %s" (machine ())
425+
eprintfn "DELTA-COST [%s] n=%d saves to a running run_app app, file written to new body served (ms): %s, median %.0f"
426+
(HostRuntime.moniker runtime) served.Count (served |> Seq.map (sprintf "%.0f") |> String.concat ", ") (median (List.ofSeq served))
427+
eprintfn "DELTA-COST [%s] of which the build (ms): median %.0f, the diff and the delta written: median %.0f, the runtime's call and its handlers: median %.0f"
428+
(HostRuntime.moniker runtime) (median build) (median prepare) (median apply)
429+
eprintfn "DELTA-COST [%s] a restart starts a process on top of the same build: host to app answering %.0f ms, after a build of %.0f ms (one start, measured on this run)"
430+
(HostRuntime.moniker runtime) started.ProcessMs started.BuildMs
431+
build |> List.length |> Expect.equal "every save logged its parts" served.Count
432+
finally
433+
stop app
434+
}
435+
]

0 commit comments

Comments
 (0)