1
1
namespace Fc.Domain.Inventory
2
2
3
3
open Equinox.Core // we use Equinox's AsyncCacheCell helper below
4
+ open FSharp.UMX
4
5
5
6
type internal IdsCache < 'Id >() =
6
7
let all = System.Collections.Concurrent.ConcurrentDictionary< 'Id, unit>() // Bounded only by relatively low number of physical pick tickets IRL
7
8
static member Create init = let x = IdsCache() in x.Add init; x
8
9
member __.Add ids = for x in ids do all.[ x] <- ()
9
10
member __.Contains id = all.ContainsKey id
10
11
11
- /// Ingests items into a log of items, making a best effort at deduplicating as it writes
12
- /// Prior to first add, reads recent ids , in order to minimize the number of duplicated Ids we ingest
13
- type Service internal ( inventoryId , epochs : Epoch.Service ) =
12
+ /// Maintains active Epoch Id in a thread-safe manner while ingesting items into the `series` of `epochs`
13
+ /// Prior to first add, reads `lookBack` epochs to seed the cache , in order to minimize the number of duplicated Ids we ingest
14
+ type Service internal ( inventoryId , series : Series.Service , epochs : Epoch.Service , lookBack , capacity ) =
14
15
15
- static let log = Serilog.Log.ForContext< Service>()
16
+ let log = Serilog.Log.ForContext< Service>()
17
+
18
+ // Maintains what we believe to be the currently open EpochId
19
+ // Guaranteed to be set only after `previousIds.AwaitValue()`
20
+ let mutable activeEpochId = Unchecked.defaultof<_>
16
21
17
22
// We want max one request in flight to establish the pre-existing Events from which the TransactionIds cache will be seeded
18
- let previousIds : AsyncCacheCell < Set < InventoryTransactionId >> =
19
- let read = async { let! r = epochs.TryIngest( inventoryId, Seq.empty) in return r.transactionIds }
20
- AsyncCacheCell read
23
+ let previousEpochs = AsyncCacheCell< AsyncCacheCell< Set< InventoryTransactionId>> list> <| async {
24
+ let! startingId = series.ReadIngestionEpoch( inventoryId)
25
+ activeEpochId <- % startingId
26
+ let read epochId = async { let! r = epochs.TryIngest( inventoryId, epochId, ( fun _ -> 1 ), Seq.empty) in return r.transactionIds }
27
+ return [ for epoch in ( max 0 (% startingId - lookBack)) .. (% startingId - 1 ) -> AsyncCacheCell( read % epoch) ] }
21
28
22
29
// TransactionIds cache - used to maintain a list of transactions that have already been ingested in order to avoid db round-trips
23
30
let previousIds : AsyncCacheCell < IdsCache < _ >> = AsyncCacheCell <| async {
24
- let! previousIds = previousIds.AwaitValue()
25
- return IdsCache.Create( previousIds) }
31
+ let! previousEpochs = previousEpochs.AwaitValue()
32
+ let! ids = seq { for x in previousEpochs -> x.AwaitValue() } |> Async.Parallel
33
+ return IdsCache.Create( Seq.concat ids) }
26
34
27
35
let tryIngest events = async {
28
36
let! previousIds = previousIds.AwaitValue()
37
+ let initialEpochId = % activeEpochId
29
38
30
- let rec aux totalIngested items = async {
39
+ let rec aux epochId totalIngested items = async {
31
40
let SeqPartition f = Seq.toArray >> Array.partition f
32
41
let dup , fresh = items |> SeqPartition ( Epoch.Events.chooseInventoryTransactionId >> Option.exists previousIds.Contains)
33
42
let fullCount = List.length items
34
43
let dropping = fullCount - Array.length fresh
35
- if dropping <> 0 then log.Information( " Ignoring {count}/{fullCount} duplicate ids: {ids}" , dropping, fullCount, dup)
44
+ if dropping <> 0 then log.Information( " Ignoring {count}/{fullCount} duplicate ids: {ids} for {epochId} " , dropping, fullCount, dup, epochId )
36
45
if Array.isEmpty fresh then
37
46
return totalIngested
38
47
else
39
- let! res = epochs.TryIngest( inventoryId, fresh)
40
- log.Information( " Added {count} items to {inventoryId:l}" , res.added, inventoryId)
48
+ let! res = epochs.TryIngest( inventoryId, epochId , capacity , fresh)
49
+ log.Information( " Added {count} items to {inventoryId:l}/{epochId} " , res.added, inventoryId, epochId )
41
50
// The adding is potentially redundant; we don't care
42
51
previousIds.Add res.transactionIds
52
+ // Any writer noticing we've moved to a new epoch shares the burden of marking it active
53
+ if not res.isClosed && activeEpochId < % epochId then
54
+ log.Information( " Marking {inventoryId:l}/{epochId} active" , inventoryId, epochId)
55
+ do ! series.AdvanceIngestionEpoch( inventoryId, epochId)
56
+ System.Threading.Interlocked.CompareExchange(& activeEpochId, % epochId, activeEpochId) |> ignore
43
57
let totalIngestedTransactions = totalIngested + res.added
44
- return totalIngestedTransactions }
45
- return ! aux 0 events
58
+ match res.rejected with
59
+ | [] -> return totalIngestedTransactions
60
+ | rej -> return ! aux ( InventoryEpochId.next epochId) totalIngestedTransactions rej }
61
+ return ! aux initialEpochId 0 events
46
62
}
47
63
48
64
/// Upon startup, we initialize the TransactionIds cache with recent epochs; we want to kick that process off before our first ingest
@@ -53,11 +69,22 @@ type Service internal (inventoryId, epochs : Epoch.Service) =
53
69
54
70
module internal Helpers =
55
71
56
- let create inventoryId epochs =
57
- Service( inventoryId, epochs)
72
+ let create inventoryId ( maxTransactionsPerEpoch , lookBackLimit ) ( series , epochs ) =
73
+ let remainingEpochCapacity ( state : Epoch.Fold.State ) =
74
+ let currentLen = state.ids.Count
75
+ max 0 ( maxTransactionsPerEpoch - currentLen)
76
+ Service( inventoryId, series, epochs, lookBack= lookBackLimit, capacity= remainingEpochCapacity)
77
+
78
+ module Cosmos =
79
+
80
+ let create inventoryId ( maxTransactionsPerEpoch , lookBackLimit ) ( context , cache ) =
81
+ let series = Series.Cosmos.create ( context, cache)
82
+ let epochs = Epoch.Cosmos.create ( context, cache)
83
+ Helpers.create inventoryId ( maxTransactionsPerEpoch, lookBackLimit) ( series, epochs)
58
84
59
85
module EventStore =
60
86
61
- let create inventoryId ( context , cache ) =
87
+ let create inventoryId ( maxTransactionsPerEpoch , lookBackLimit ) ( context , cache ) =
88
+ let series = Series.EventStore.create ( context, cache)
62
89
let epochs = Epoch.EventStore.create ( context, cache)
63
- Helpers.create inventoryId epochs
90
+ Helpers.create inventoryId ( maxTransactionsPerEpoch , lookBackLimit ) ( series , epochs)
0 commit comments