Skip to content

Commit c359a43

Browse files
committed
refactor(server): first step in implementing clean safe architecture, creating more cohesive modules
1 parent 11886f6 commit c359a43

File tree

10 files changed

+1556
-10
lines changed

10 files changed

+1556
-10
lines changed

docs/mdr/design-history/agent-architecture.md

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -310,15 +310,11 @@ Each command sent to an agent is a discrete, serialisable event. Enabling an aud
310310
```fsharp
311311
let auditedAgent innerAgent =
312312
Agent.createReply<Command, Response>(fun cmd ->
313-
// Wrap in try/with: if postAndReply throws (e.g. timeout), we still reply
314-
// so the caller is never left blocked on an unanswered reply channel.
313+
// Wrap in try/with so the caller is never left blocked
314+
// on an unanswered reply channel if the inner agent fails.
315315
try
316316
writeInfoMessage $"[AUDIT] {cmd |> Command.toString}"
317317
let response = innerAgent |> Agent.postAndReply cmd
318-
// Wrap in try/with: if postAndReply throws (e.g. timeout), we still reply
319-
// so the caller is never left blocked on an unanswered reply channel.
320-
try
321-
let response = innerAgent |> Agent.postAndReply cmd
322318
writeInfoMessage $"[AUDIT] response received"
323319
response
324320
with ex ->

src/Informedica.Agents.Lib/Agent.fs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -286,7 +286,7 @@ module Agent =
286286
let postAndReply msg (agent: Agent<_>) =
287287
if agent.DefaultTimeout = Timeout.Infinite then
288288
agent
289-
|> tryPostAndReply 1000 msg
289+
|> tryPostAndReply 30_000 msg
290290
|> function
291291
| Some v -> v
292292
| None -> failwith "Timed out waiting for reply"
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
/// Phase 0: Fix Agent.fs timeout bug (Greptile finding from PR #177)
2+
///
3+
/// Problem: Agent.postAndReply falls back to tryPostAndReply with a
4+
/// hardcoded 1-second timeout when DefaultTimeout = Timeout.Infinite.
5+
/// This causes slow operations (resource loading, constraint solving)
6+
/// to fail with "Timed out waiting for reply".
7+
///
8+
/// Fix: Increase the fallback timeout to 30 seconds (30_000 ms).
9+
/// The postAndReply function already has a fast-path when DefaultTimeout
10+
/// is set to a specific value, so this only affects agents that haven't
11+
/// configured a timeout (i.e. left at Infinite).
12+
13+
#I __SOURCE_DIRECTORY__
14+
15+
#load "load.fsx"
16+
17+
open System.Threading
18+
open Informedica.Agents.Lib
19+
20+
21+
// ============================================================
22+
// 1. Demonstrate the bug: 1-second timeout is too short
23+
// ============================================================
24+
25+
// Create an agent that takes 2 seconds to reply (simulating slow work)
26+
let slowAgent =
27+
Agent.createReply<string, string>(fun msg ->
28+
Thread.Sleep(2000) // simulate slow operation
29+
$"processed: {msg}"
30+
)
31+
32+
// This will fail with the current 1-second timeout because
33+
// DefaultTimeout is Timeout.Infinite by default
34+
printfn $"DefaultTimeout = {slowAgent |> Agent.getDefaultTimeout}"
35+
printfn $"Timeout.Infinite = {Timeout.Infinite}"
36+
37+
let result1 =
38+
try
39+
slowAgent
40+
|> Agent.postAndReply "slow-request"
41+
|> Some
42+
with ex ->
43+
printfn $"BUG: {ex.Message}"
44+
None
45+
46+
printfn $"Result with 1s timeout (should fail): {result1}"
47+
48+
49+
// ============================================================
50+
// 2. Workaround: set DefaultTimeout explicitly
51+
// ============================================================
52+
53+
slowAgent |> Agent.setDefaultTimeout 30_000
54+
55+
let result2 =
56+
try
57+
slowAgent
58+
|> Agent.postAndReply "slow-request-with-timeout"
59+
|> Some
60+
with ex ->
61+
printfn $"Error: {ex.Message}"
62+
None
63+
64+
printfn $"Result with 30s timeout (should succeed): {result2}"
65+
66+
slowAgent |> Agent.dispose
67+
68+
69+
// ============================================================
70+
// 3. Test the fix: after patching Agent.fs line 289
71+
// Change: tryPostAndReply 1000 → tryPostAndReply 30_000
72+
// ============================================================
73+
74+
// After the fix, this should work without setting DefaultTimeout:
75+
let slowAgent2 =
76+
Agent.createReply<string, string>(fun msg ->
77+
Thread.Sleep(2000)
78+
$"processed: {msg}"
79+
)
80+
81+
let result3 =
82+
try
83+
slowAgent2
84+
|> Agent.postAndReply "test-after-fix"
85+
|> Some
86+
with ex ->
87+
printfn $"Still failing after fix: {ex.Message}"
88+
None
89+
90+
printfn $"Result after fix (should succeed): {result3}"
91+
92+
slowAgent2 |> Agent.dispose
93+
94+
95+
// ============================================================
96+
// 4. Verify fast agents still work fine
97+
// ============================================================
98+
99+
let fastAgent =
100+
Agent.createReply<int, int>(fun n -> n * 2)
101+
102+
let result4 = fastAgent |> Agent.postAndReply 21
103+
printfn $"Fast agent result (should be 42): {result4}"
104+
105+
fastAgent |> Agent.dispose
106+
107+
108+
// ============================================================
109+
// 5. Test stateful agent with slow init
110+
// ============================================================
111+
112+
let statefulAgent =
113+
Agent.createStatefulReply<string, string, int>(
114+
0,
115+
fun state msg ->
116+
Thread.Sleep(1500) // simulate moderate work
117+
let newState = state + 1
118+
$"call #{newState}: {msg}", newState
119+
)
120+
121+
let result5 =
122+
try
123+
statefulAgent
124+
|> Agent.postAndReply "stateful-test"
125+
|> Some
126+
with ex ->
127+
printfn $"Stateful agent failed: {ex.Message}"
128+
None
129+
130+
printfn $"Stateful agent result (should succeed after fix): {result5}"
131+
132+
statefulAgent |> Agent.dispose
133+
134+
printfn "\nAll tests complete."

src/Informedica.GenPRES.Server/Informedica.GenPRES.Server.fsproj

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,10 @@
88
</PropertyGroup>
99
<ItemGroup>
1010
<Compile Include="Logging.fs" />
11-
<Compile Include="ServerApi.fs" />
11+
<Compile Include="ServerApi.Mappers.fs" />
12+
<Compile Include="ServerApi.Services.fs" />
13+
<Compile Include="ServerApi.Command.fs" />
14+
<Compile Include="ServerApi.ApiImpl.fs" />
1215
<Compile Include="Server.fs" />
1316
<None Include="Scripts\load.fsx" />
1417
<None Include="Scripts\Scripts.fsx" />

src/Informedica.GenPRES.Server/Scripts/load.fsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,10 @@
1717
// These can be loaded all at once.
1818

1919
#load "../Logging.fs"
20-
#load "../ServerApi.fs"
20+
#load "../ServerApi.Mappers.fs"
21+
#load "../ServerApi.Services.fs"
22+
#load "../ServerApi.Command.fs"
23+
#load "../ServerApi.ApiImpl.fs"
2124

2225
fsi.AddPrinter<System.DateTime> _.ToShortDateString()
2326

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
namespace ServerApi
2+
3+
4+
[<AutoOpen>]
5+
module ApiImpl =
6+
7+
open Informedica.Utils.Lib.ConsoleWriter.NewLineNoTime
8+
open Shared.Api
9+
10+
11+
/// An implementation of the Shared IServerApi protocol.
12+
let createServerApi provider : IServerApi =
13+
{
14+
processCommand =
15+
fun cmd ->
16+
async {
17+
try
18+
writeInfoMessage $"Processing command: {cmd |> Command.toString}"
19+
let! result = Command.processCmd provider cmd
20+
writeInfoMessage $"Finished processing command: {cmd |> Command.toString}"
21+
return result
22+
with
23+
| ex ->
24+
writeErrorMessage $"Error processing command: {cmd |> Command.toString}\n{ex.Message}"
25+
return Error [| ex.Message |]
26+
}
27+
28+
testApi =
29+
fun () ->
30+
async {
31+
return "Hello world!"
32+
}
33+
}
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
namespace ServerApi
2+
3+
4+
module Command =
5+
6+
open Shared.Api
7+
open Informedica.GenForm.Lib
8+
9+
/// Check if resources are loaded. Returns Error with messages if not.
10+
let requireLoaded (provider: Resources.IResourceProvider) =
11+
let info = provider.GetResourceInfo()
12+
if info.IsLoaded then None
13+
else
14+
info.Messages
15+
|> Array.map (sprintf "%A")
16+
|> Error
17+
|> Some
18+
19+
20+
let processCmd provider cmd =
21+
let agent, logger =
22+
match Logging.loggingLevel with
23+
| None -> None, Informedica.GenOrder.Lib.OrderLogging.noOp
24+
| Some level ->
25+
let agent =
26+
Logging.getLogger level Logging.OrderLogger
27+
(Some agent, agent.Logger)
28+
29+
match requireLoaded provider with
30+
| Some err -> async { return err }
31+
| None ->
32+
33+
match cmd with
34+
| OrderContextCmd (ctxCmd, ctx) ->
35+
async {
36+
if agent.IsSome then
37+
do! agent.Value |> Logging.setComponentName (Some "OrderContext")
38+
39+
return
40+
ctx
41+
|> OrderContext.evaluate logger provider ctxCmd
42+
|> Result.map (OrderContextResult >> OrderContextResp)
43+
}
44+
45+
| OrderPlanCmd (UpdateOrderPlan (tp, cmdOpt)) ->
46+
async {
47+
if agent.IsSome then
48+
do! agent.Value |> Logging.setComponentName (Some "TreatmentPlan")
49+
return
50+
OrderPlan.updateOrderPlan logger provider tp cmdOpt
51+
|> OrderPlan.calculateTotals
52+
|> OrderPlanUpdated
53+
|> OrderPlanResp
54+
|> Ok
55+
}
56+
57+
| OrderPlanCmd (FilterOrderPlan tp) ->
58+
async {
59+
return
60+
tp
61+
|> OrderPlan.calculateTotals
62+
|> OrderPlanFiltered
63+
|> OrderPlanResp
64+
|> Ok
65+
}
66+
67+
| FormularyCmd form ->
68+
async {
69+
return
70+
form
71+
|> Formulary.get provider
72+
|> Result.map FormularyResp
73+
}
74+
75+
| ParenteraliaCmd par ->
76+
async {
77+
return
78+
par
79+
|> Parenteralia.get provider
80+
|> Result.mapError Array.singleton
81+
|> Result.map ParenteraliaResp
82+
}
83+
84+
| NutritionPlanCmd (InitNutritionPlan patient) ->
85+
async {
86+
return
87+
NutritionPlan.initNutritionPlan logger provider patient
88+
|> Result.map (NutritionPlanInitialised >> NutritionPlanResp)
89+
}
90+
91+
| NutritionPlanCmd (UpdateNutritionOrderContext (plan, label, ctx)) ->
92+
async {
93+
return
94+
NutritionPlan.updateNutritionOrderContext logger provider (plan, label, ctx)
95+
|> Result.map (NutritionPlanUpdated >> NutritionPlanResp)
96+
}
97+
98+
| NutritionPlanCmd (SelectNutritionOrderScenario (plan, label, ctx)) ->
99+
async {
100+
return
101+
NutritionPlan.selectNutritionOrderScenario logger provider (plan, label, ctx)
102+
|> Result.map (NutritionPlanUpdated >> NutritionPlanResp)
103+
}
104+
105+
| NutritionPlanCmd (NavigateNutritionOrderContext (plan, label, ctxCmd, ctx)) ->
106+
async {
107+
return
108+
NutritionPlan.navigateNutritionOrderContext logger provider (plan, label, ctxCmd, ctx)
109+
|> Result.map (NutritionPlanUpdated >> NutritionPlanResp)
110+
}
111+
112+
| NutritionPlanCmd (AddNutritionContext (plan, category)) ->
113+
async {
114+
return
115+
NutritionPlan.addNutritionContext logger provider (plan, category)
116+
|> Result.map (NutritionPlanUpdated >> NutritionPlanResp)
117+
}
118+
119+
| NutritionPlanCmd (RemoveNutritionContext (plan, id)) ->
120+
async {
121+
return
122+
NutritionPlan.removeNutritionContext (plan, id)
123+
|> Result.map (NutritionPlanUpdated >> NutritionPlanResp)
124+
}

0 commit comments

Comments
 (0)