Repository navigation
Expand file tree
/
Copy pathAsyncMemoize.fs
More file actions
368 lines (289 loc) · 12.2 KB
/
Copy pathAsyncMemoize.fs
File metadata and controls
368 lines (289 loc) · 12.2 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
namespace Internal.Utilities.Collections
open System.Diagnostics
open System.IO
open System.Threading
open System.Threading.Tasks
open System.Runtime.CompilerServices
open FSharp.Compiler.DiagnosticsLogger
type AsyncLazyState<'t> =
| Initial of computation: Async<'t>
| Running of initialComputation: Async<'t> * work: Task<'t> * CancellationTokenSource * requestCount: int
| Completed of result: 't
| Faulted of exn
/// Represents a computation that will execute only once but can be requested by multiple clients.
/// It keeps track of the number of requests. When all clients cancel their requests, the underlying computation will also cancel and can be restarted.
/// If cancelUnawaited is set to false, the computation will run to completion even when all requests are canceled.
/// When cacheException is false, subsequent requests will restart the computation after an exceptional result.
type AsyncLazy<'t> private (initial: AsyncLazyState<'t>, cancelUnawaited: bool, cacheException: bool) =
let stateUpdateSync = obj ()
let mutable state = initial
// This should remain the only function that mutates the state.
let withStateUpdate f =
lock stateUpdateSync
<| fun () ->
let next, result = f state
state <- next
result
let updateState f =
withStateUpdate <| fun prev -> f prev, ()
let cancelIfUnawaited cancelUnawaited =
function
| Running(computation, _, cts, 0) when cancelUnawaited ->
// To keep state updates fast we don't actually wait for the work to cancel.
// This means single execution is not strictly enforced.
cts.Cancel()
Initial computation
| state -> state
let afterRequest =
function
| Running(c, work, cts, count) -> Running(c, work, cts, count - 1) |> cancelIfUnawaited cancelUnawaited
| state -> state // Nothing more to do if state already transitioned.
let detachable (work: Task<'t>) =
async {
try
let! ct = Async.CancellationToken
// Using ContinueWith with a CancellationToken allows detaching from the running 'work' task.
// If the current async workflow is canceled, the 'work' task will continue running independently.
do! work.ContinueWith(ignore<Task<'t>>, ct) |> Async.AwaitTask
with :? TaskCanceledException ->
()
// If we're here it means there was no cancellation and the 'work' task has completed.
return! work |> Async.AwaitTask
}
let onComplete (t: Task<'t>) =
updateState
<| function
| Running(computation, _, _, _) ->
try
Completed t.Result
with exn ->
if cacheException then Faulted exn else Initial computation
| state -> state
t.Result
let request =
function
| Initial computation ->
let cts = new CancellationTokenSource()
let work =
Async
.StartAsTask(computation, cancellationToken = cts.Token)
.ContinueWith(onComplete, TaskContinuationOptions.NotOnCanceled)
Running(computation, work, cts, 1), detachable work
| Running(c, work, cts, count) -> Running(c, work, cts, count + 1), detachable work
| Completed result as state -> state, async { return result }
| Faulted exn as state -> state, async { return raise exn }
// computation will deallocate after state transition to Completed ot Faulted.
new(computation, ?cancelUnawaited: bool, ?cacheException) =
AsyncLazy(Initial computation, defaultArg cancelUnawaited true, defaultArg cacheException true)
member _.Request() =
async {
try
return! withStateUpdate request
finally
updateState afterRequest
}
member _.CancelIfUnawaited() = updateState (cancelIfUnawaited true)
member _.State = state
member _.TryResult =
match state with
| Completed result -> Some result
| _ -> None
[<AutoOpen>]
module internal Utils =
/// Return file name with one directory above it
let shortPath (path: string) =
let dirPath = Path.GetDirectoryName(path) |> Option.ofObj |> Option.defaultValue ""
let dir =
dirPath.Split Path.DirectorySeparatorChar
|> Array.tryLast
|> Option.map (sprintf "%s/")
|> Option.defaultValue ""
$"{dir}{Path.GetFileName path}"
type internal JobEvent =
| Requested
| Started
| Restarted
| Finished
| Canceled
| Evicted
| Collected
| Weakened
| Strengthened
| Failed
| Cleared
static member AllEvents =
[
Requested
Started
Restarted
Finished
Canceled
Evicted
Collected
Weakened
Strengthened
Failed
Cleared
]
type internal ICacheKey<'TKey, 'TVersion> =
// TODO Key should probably be renamed to Identifier
abstract member GetKey: unit -> 'TKey
abstract member GetVersion: unit -> 'TVersion
abstract member GetLabel: unit -> string
[<Extension>]
type Extensions =
[<Extension>]
static member internal WithExtraVersion(cacheKey: ICacheKey<_, _>, extraVersion) =
{ new ICacheKey<_, _> with
member _.GetLabel() = cacheKey.GetLabel()
member _.GetKey() = cacheKey.GetKey()
member _.GetVersion() = cacheKey.GetVersion(), extraVersion
}
type private KeyData<'TKey, 'TVersion> =
{
Label: string
Key: 'TKey
Version: 'TVersion
}
type Job<'t> = AsyncLazy<Result<'t, exn> * CapturingDiagnosticsLogger>
[<DebuggerDisplay("{DebuggerDisplay}")>]
type internal AsyncMemoize<'TKey, 'TVersion, 'TValue
when 'TKey: equality and 'TVersion: equality and 'TKey: not null and 'TVersion: not null>
(?keepStrongly, ?keepWeakly, ?name: string, ?cancelUnawaitedJobs: bool, ?cancelDuplicateRunningJobs: bool) =
let _name = defaultArg name "N/A"
let cancelUnawaitedJobs = defaultArg cancelUnawaitedJobs true
let cancelDuplicateRunningJobs = defaultArg cancelDuplicateRunningJobs false
let event = Event<_>()
let eventCounts = [ for j in JobEvent.AllEvents -> j, ref 0 ] |> dict
let mutable hits = 0
let mutable duration = 0L
let keyTuple (keyData: KeyData<_, _>) =
keyData.Label, keyData.Key, keyData.Version
let logK (eventType: JobEvent) key =
Interlocked.Increment(eventCounts[eventType]) |> ignore
event.Trigger(eventType, key)
let log eventType keyData = logK eventType (keyTuple keyData)
let cache =
LruCache<'TKey, 'TVersion, Job<'TValue>>(
keepStrongly = defaultArg keepStrongly 100,
keepWeakly = defaultArg keepWeakly 200,
event =
(function
| CacheEvent.Evicted -> logK JobEvent.Evicted
| CacheEvent.Collected -> logK JobEvent.Collected
| CacheEvent.Weakened -> logK JobEvent.Weakened
| CacheEvent.Strengthened -> logK JobEvent.Strengthened
| CacheEvent.Cleared -> logK JobEvent.Cleared)
)
member _.Get(key: ICacheKey<_, _>, computation) =
let key =
{
Label = key.GetLabel()
Key = key.GetKey()
Version = key.GetVersion()
}
let wrappedComputation =
Async.TryCancelled(
async {
let sw = Stopwatch.StartNew()
log Started key
let logger = CapturingDiagnosticsLogger "cache"
SetThreadDiagnosticsLoggerNoUnwind logger
match! computation |> Async.Catch with
| Choice1Of2 result ->
log Finished key
Interlocked.Add(&duration, sw.ElapsedMilliseconds) |> ignore
return Result.Ok result, logger
| Choice2Of2 exn ->
log Failed key
return Result.Error exn, logger
},
fun _ -> log Canceled key
)
let getOrAdd () =
let cached, otherVersions = cache.GetAll(key.Key, key.Version)
let countHit v =
Interlocked.Increment &hits |> ignore
v
let cacheSetNewJob () =
let job =
Job(wrappedComputation, cancelUnawaited = cancelUnawaitedJobs, cacheException = false)
cache.Set(key.Key, key.Version, key.Label, job)
job
otherVersions,
cached |> Option.map countHit |> Option.defaultWith cacheSetNewJob
async {
let otherVersions, job = lock cache getOrAdd
log Requested key
if cancelDuplicateRunningJobs && not cancelUnawaitedJobs then
otherVersions |> Seq.map snd |> Seq.iter _.CancelIfUnawaited()
use _ = new CompilationGlobalsScope()
let! result, logger = job.Request()
logger.CommitDelayedDiagnostics DiagnosticsThreadStatics.DiagnosticsLogger
match result with
| Ok result -> return result
| Error exn -> return raise exn
}
member _.TryGet(key: 'TKey, predicate: 'TVersion -> bool) : 'TValue option =
lock cache
<| fun () ->
cache.GetAll(key)
|> Seq.tryPick (fun (version, job) ->
match predicate version, job.TryResult with
| true, Some(Ok result, _) -> Some result
| _ -> None)
member _.Clear() = lock cache cache.Clear
member _.Clear predicate =
lock cache <| fun () -> cache.Clear predicate
member val Event = event.Publish
member this.OnEvent = this.Event.Add
member this.Count = lock cache <| fun () -> cache.Count
member this.DebuggerDisplay =
let cachedJobs = cache.GetValues() |> Seq.map (fun (_, _, job) -> job)
let jobStateName =
function
| Initial _ -> nameof Initial
| Running _ -> nameof Running
| Completed _ -> nameof Completed
| Faulted _ -> nameof Faulted
let valueStats = cachedJobs |> Seq.countBy (_.State >> jobStateName) |> Map
let getStat key =
valueStats.TryFind key |> Option.defaultValue 0
let running =
let count = getStat "Running"
if count > 0 then $" Running {count}" else ""
let finished = eventCounts[Finished].Value
let avgDuration =
if finished = 0 then
""
else
$"| Avg: %.0f{float duration / float finished} ms"
let requests = eventCounts[Requested].Value
let hitRatio =
if requests = 0 then
""
else
$" (%.0f{float hits / (float requests) * 100.0} %%)"
let faulted = getStat "Faulted"
let failed = eventCounts[Failed].Value
let stats =
seq {
if faulted + failed > 0 then
" (_!_) "
for j in eventCounts.Keys do
let count = eventCounts[j].Value
if count > 0 then $"| {j}: {count}" else ""
$"| hits: {hits}{hitRatio} "
}
|> String.concat ""
$"{running} {cache.DebuggerDisplay} {stats}{avgDuration}"
/// A drop-in replacement for AsyncMemoize that disables caching and just runs the computation every time.
[<DebuggerDisplay("{DebuggerDisplay}")>]
type internal AsyncMemoizeDisabled<'TKey, 'TVersion, 'TValue when 'TKey: equality and 'TVersion: equality>
(?keepStrongly, ?keepWeakly, ?name: string, ?cancelDuplicateRunningJobs: bool) =
do ignore (keepStrongly, keepWeakly, name, cancelDuplicateRunningJobs)
let mutable requests = 0
member _.Get(_key: ICacheKey<_, _>, computation) =
Interlocked.Increment &requests |> ignore
computation
member _.DebuggerDisplay = $"(disabled) requests: {requests}"