Skip to content

Commit d3b99ab

Browse files
committed
Add Cosmos support
1 parent d3d6338 commit d3b99ab

13 files changed

+307
-42
lines changed

equinox-fc/Domain.Tests/Fixtures.fs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,23 @@ module EnvVar =
88

99
let tryGet k = Environment.GetEnvironmentVariable k |> Option.ofObj
1010

11+
module Cosmos =
12+
13+
open Equinox.Cosmos
14+
let connect () =
15+
match EnvVar.tryGet "EQUINOX_COSMOS_CONNECTION", EnvVar.tryGet "EQUINOX_COSMOS_DATABASE", EnvVar.tryGet "EQUINOX_COSMOS_CONTAINER" with
16+
| Some s, Some d, Some c ->
17+
let appName = "Domain.Tests"
18+
let discovery = Discovery.FromConnectionString s
19+
let connector = Connector(TimeSpan.FromSeconds 5., 5, TimeSpan.FromSeconds 5., Serilog.Log.Logger)
20+
let connection = connector.Connect(appName, discovery) |> Async.RunSynchronously
21+
let context = Context(connection, d, c)
22+
let cache = Equinox.Cache (appName, 10)
23+
context, cache
24+
| s, d, c ->
25+
failwithf "Connection, Database and Container EQUINOX_COSMOS_* Environment variables are required (%b,%b,%b)"
26+
(Option.isSome s) (Option.isSome d) (Option.isSome c)
27+
1128
module EventStore =
1229

1330
open Equinox.EventStore

equinox-fc/Domain.Tests/LocationTests.fs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,20 @@ let [<Property>] ``MemoryStore properties`` epochLen args =
6060
let service = Location.MemoryStore.create (zero, cf, sc) store
6161
run service args
6262

63+
type Cosmos(testOutput) =
64+
65+
let log = TestOutputLogger.create testOutput
66+
do Serilog.Log.Logger <- log
67+
68+
let context, cache = Cosmos.connect ()
69+
70+
let [<Property(MaxTest=5, MaxFail=1)>] properties epochLen args =
71+
let epochLen, idsWindow = max 1 epochLen, 5
72+
let zero, cf, sc = Epoch.zeroBalance, Epoch.toBalanceCarriedForward idsWindow, Epoch.shouldClose epochLen
73+
74+
let service = Location.Cosmos.create (zero, cf, sc) (context, cache, 50)
75+
run service args
76+
6377
type EventStore(testOutput) =
6478

6579
let log = TestOutputLogger.create testOutput

equinox-fc/Domain/Domain.fsproj

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,15 @@
1111
<Compile Include="LocationSeries.fs" />
1212
<Compile Include="LocationEpoch.fs" />
1313
<Compile Include="Location.fs" />
14+
<Compile Include="InventorySeries.fs" />
1415
<Compile Include="InventoryEpoch.fs" />
1516
<Compile Include="Inventory.fs" />
1617
<Compile Include="StockTransaction.fs" />
1718
<Compile Include="StockProcessManager.fs" />
1819
</ItemGroup>
1920
<ItemGroup>
21+
<PackageReference Include="Equinox.Cosmos" Version="2.1.0" />
2022
<PackageReference Include="Equinox.EventStore" Version="2.1.0" />
21-
<PackageReference Include="FsCodec.NewtonsoftJson" Version="2.1.0" />
2223
</ItemGroup>
2324

2425
</Project>

equinox-fc/Domain/Infrastructure.fs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,13 @@ module InventoryId =
2121
let parse (value : string) : InventoryId = %value
2222
let toString (value : InventoryId) : string = %value
2323

24+
type InventoryEpochId = int<inventoryEpochId>
25+
and [<Measure>] inventoryEpochId
26+
module InventoryEpochId =
27+
let parse (value : int) : InventoryEpochId = %value
28+
let next (value : InventoryEpochId) : InventoryEpochId = % (%value + 1)
29+
let toString (value : InventoryEpochId) : string = string %value
30+
2431
type InventoryTransactionId = string<inventoryTransactionId>
2532
and [<Measure>] inventoryTransactionId
2633
module InventoryTransactionId =

equinox-fc/Domain/Inventory.fs

Lines changed: 46 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,48 +1,64 @@
11
namespace Fc.Domain.Inventory
22

33
open Equinox.Core // we use Equinox's AsyncCacheCell helper below
4+
open FSharp.UMX
45

56
type internal IdsCache<'Id>() =
67
let all = System.Collections.Concurrent.ConcurrentDictionary<'Id, unit>() // Bounded only by relatively low number of physical pick tickets IRL
78
static member Create init = let x = IdsCache() in x.Add init; x
89
member __.Add ids = for x in ids do all.[x] <- ()
910
member __.Contains id = all.ContainsKey id
1011

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) =
1415

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<_>
1621

1722
// 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) ] }
2128

2229
// TransactionIds cache - used to maintain a list of transactions that have already been ingested in order to avoid db round-trips
2330
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) }
2634

2735
let tryIngest events = async {
2836
let! previousIds = previousIds.AwaitValue()
37+
let initialEpochId = %activeEpochId
2938

30-
let rec aux totalIngested items = async {
39+
let rec aux epochId totalIngested items = async {
3140
let SeqPartition f = Seq.toArray >> Array.partition f
3241
let dup, fresh = items |> SeqPartition (Epoch.Events.chooseInventoryTransactionId >> Option.exists previousIds.Contains)
3342
let fullCount = List.length items
3443
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)
3645
if Array.isEmpty fresh then
3746
return totalIngested
3847
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)
4150
// The adding is potentially redundant; we don't care
4251
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
4357
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
4662
}
4763

4864
/// 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) =
5369

5470
module internal Helpers =
5571

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)
5884

5985
module EventStore =
6086

61-
let create inventoryId (context, cache) =
87+
let create inventoryId (maxTransactionsPerEpoch, lookBackLimit) (context, cache) =
88+
let series = Series.EventStore.create (context, cache)
6289
let epochs = Epoch.EventStore.create (context, cache)
63-
Helpers.create inventoryId epochs
90+
Helpers.create inventoryId (maxTransactionsPerEpoch, lookBackLimit) (series, epochs)

equinox-fc/Domain/InventoryEpoch.fs

Lines changed: 46 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,68 +1,102 @@
11
/// Manages the ingestion (and deduplication based on a TransactionId) of events reflecting transfers or stock adjustments
22
/// that have been effected across a given set of Inventory
3-
/// See Inventory.Service for surface level API which manages the ingestion
3+
/// See Inventory.Service for surface level API which manages the ingestion, including transitioning to a new Epoch when an epoch reaches 'full' state
44
module Fc.Domain.Inventory.Epoch
55

66
let [<Literal>] Category = "InventoryEpoch"
7-
let streamName inventoryId = FsCodec.StreamName.compose Category [InventoryId.toString inventoryId; "0"]
7+
let streamName (inventoryId, epochId) = FsCodec.StreamName.compose Category [InventoryId.toString inventoryId; InventoryEpochId.toString epochId]
88

99
// NB - these types and the union case names reflect the actual storage formats and hence need to be versioned with care
1010
[<RequireQualifiedAccess>]
1111
module Events =
1212

1313
type TransactionRef = { transactionId : InventoryTransactionId }
14+
type Snapshotted = { closed: bool; ids : InventoryTransactionId[] }
1415

1516
type Event =
1617
| Adjusted of TransactionRef
1718
| Transferred of TransactionRef
19+
| Closed
20+
| Snapshotted of Snapshotted
1821
interface TypeShape.UnionContract.IUnionContract
1922
let codec = FsCodec.NewtonsoftJson.Codec.Create<Event>()
2023

2124
/// Used for deduplicating input events
2225
let chooseInventoryTransactionId = function
2326
| Adjusted { transactionId = id } | Transferred { transactionId = id } -> Some id
27+
| Closed | Snapshotted _ -> None
2428

2529
module Fold =
2630

2731
type State = { closed : bool; ids : Set<InventoryTransactionId> }
2832
let initial = { closed = false; ids = Set.empty }
2933
let evolve state = function
3034
| (Events.Adjusted e | Events.Transferred e) -> { state with ids = Set.add e.transactionId state.ids }
35+
| Events.Closed -> { state with closed = true }
36+
| Events.Snapshotted e -> { closed = e.closed; ids = Set.ofArray e.ids }
3137
let fold : State -> Events.Event seq -> State = Seq.fold evolve
38+
let isOrigin = function Events.Snapshotted _ -> true | _ -> false
39+
let snapshot s = Events.Snapshotted { closed = s.closed; ids = Array.ofSeq s.ids }
3240

3341
type Result =
34-
{ /// Count of items added to this epoch. May be less than requested due to removal of duplicates and/or rejected items
42+
{ /// Indicates whether this epoch is closed (either previously or as a side-effect this time)
43+
isClosed : bool
44+
/// Count of items added to this epoch. May be less than requested due to removal of duplicates and/or rejected items
3545
added : int
46+
/// residual items that [are not duplicates and] were not accepted into this epoch
47+
rejected : Events.Event list
3648
/// identifiers for all items in this epoch
3749
transactionIds : Set<InventoryTransactionId> }
3850

39-
let decideSync events (state : Fold.State) : Result * Events.Event list =
51+
let decideSync capacity events (state : Fold.State) : Result * Events.Event list =
4052
let isFresh = function
4153
| Events.Adjusted { transactionId = id }
4254
| Events.Transferred { transactionId = id } -> (not << state.ids.Contains) id
55+
| Events.Closed | Events.Snapshotted _ -> false
4356
let news = events |> Seq.filter isFresh |> List.ofSeq
44-
let newCount = List.length news
45-
let events = [ if newCount <> 0 then yield! news ]
57+
let closed, allowing, markClosed, residual =
58+
let newCount = List.length news
59+
if state.closed then
60+
true, 0, false, news
61+
else
62+
let capacityNow = capacity state
63+
let accepting = min capacityNow newCount
64+
let closing = accepting = capacityNow
65+
let residual = List.skip accepting news
66+
closing, accepting, closing, residual
67+
let events =
68+
[ if allowing <> 0 then yield! news
69+
if markClosed then yield Events.Closed ]
4670
let state' = Fold.fold state events
47-
{ added = newCount; transactionIds = state'.ids }, events
71+
{ isClosed = closed; added = allowing; rejected = residual; transactionIds = state'.ids }, events
4872

49-
type Service internal (resolve : InventoryId -> Equinox.Stream<Events.Event, Fold.State>) =
73+
type Service internal (resolve : InventoryId * InventoryEpochId -> Equinox.Stream<Events.Event, Fold.State>) =
5074

5175
/// Attempt ingestion of `events` into the cited Epoch.
5276
/// - None will be accepted if the Epoch is `closed`
5377
/// - The `capacity` function will be passed a non-closed `state` in order to determine number of items that can be admitted prior to closing
5478
/// - If the computed capacity result is >= the number of items being submitted (which may be 0), the Epoch will be marked Closed
5579
/// NOTE the result may include rejected items (which the caller is expected to feed into a successor epoch)
56-
member __.TryIngest(inventoryId, events) : Async<Result> =
57-
let stream = resolve inventoryId
58-
stream.Transact(decideSync events)
80+
member __.TryIngest(inventoryId, epochId, capacity, events) : Async<Result> =
81+
let stream = resolve (inventoryId, epochId)
82+
stream.Transact(decideSync capacity events)
5983

6084
let create resolve =
6185
let resolve ids =
6286
let stream = resolve (streamName ids)
63-
Equinox.Stream(Serilog.Log.ForContext<Service>(), stream, maxAttempts=2)
87+
Equinox.Stream(Serilog.Log.ForContext<Service>(), stream, maxAttempts = 2)
6488
Service(resolve)
6589

90+
module Cosmos =
91+
92+
open Equinox.Cosmos
93+
94+
let accessStrategy = AccessStrategy.Snapshot (Fold.isOrigin, Fold.snapshot)
95+
let create (context, cache) =
96+
let cacheStrategy = CachingStrategy.SlidingWindow (cache, System.TimeSpan.FromMinutes 20.)
97+
let resolver = Resolver(context, Events.codec, Fold.fold, Fold.initial, cacheStrategy, accessStrategy)
98+
create resolver.Resolve
99+
66100
module EventStore =
67101

68102
open Equinox.EventStore

equinox-fc/Domain/InventorySeries.fs

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
/// Manages a) the ingestion epoch id b) the current checkpointed read position for a long-running Inventory Series
2+
/// See InventoryEpoch for the logic managing the actual events logged within a given epoch
3+
/// See Inventory.Service for the surface API which manages the writing
4+
module Fc.Domain.Inventory.Series
5+
6+
let [<Literal>] Category = "InventorySeries"
7+
let streamName inventoryId = FsCodec.StreamName.create Category (InventoryId.toString inventoryId)
8+
9+
// NB - these types and the union case names reflect the actual storage formats and hence need to be versioned with care
10+
[<RequireQualifiedAccess>]
11+
module Events =
12+
13+
type Started = { epoch : InventoryEpochId }
14+
type Event =
15+
| Started of Started
16+
interface TypeShape.UnionContract.IUnionContract
17+
let codec = FsCodec.NewtonsoftJson.Codec.Create<Event>()
18+
19+
module Fold =
20+
21+
type State = InventoryEpochId option
22+
let initial = None
23+
let evolve _state = function
24+
| Events.Started e -> Some e.epoch
25+
let fold : State -> Events.Event seq -> State = Seq.fold evolve
26+
27+
let queryActiveEpoch state = state |> Option.defaultValue (InventoryEpochId.parse 0)
28+
29+
let interpretAdvanceIngestionEpoch epochId (state : Fold.State) =
30+
if queryActiveEpoch state >= epochId then []
31+
else [Events.Started { epoch = epochId }]
32+
33+
type Service internal (resolve : InventoryId -> Equinox.Stream<Events.Event, Fold.State>) =
34+
35+
member __.ReadIngestionEpoch(inventoryId) : Async<InventoryEpochId> =
36+
let stream = resolve inventoryId
37+
stream.Query queryActiveEpoch
38+
39+
member __.AdvanceIngestionEpoch(inventoryId, epochId) : Async<unit> =
40+
let stream = resolve inventoryId
41+
stream.Transact(interpretAdvanceIngestionEpoch epochId)
42+
43+
let create resolve =
44+
// For this stream, we uniformly use stale reads as:
45+
// a) we don't require any information from competing writers
46+
// b) while there are competing writers [which might cause us to have to retry a Transact], this should be infrequent
47+
let opt = Equinox.ResolveOption.AllowStale
48+
let resolve locationId =
49+
let stream = resolve (streamName locationId, opt)
50+
Equinox.Stream(Serilog.Log.ForContext<Service>(), stream, maxAttempts = 2)
51+
Service(resolve)
52+
53+
module Cosmos =
54+
55+
open Equinox.Cosmos
56+
57+
let accessStrategy = AccessStrategy.LatestKnownEvent
58+
let create (context, cache) =
59+
let cacheStrategy = CachingStrategy.SlidingWindow (cache, System.TimeSpan.FromMinutes 20.)
60+
let resolver = Resolver(context, Events.codec, Fold.fold, Fold.initial, cacheStrategy, accessStrategy)
61+
create <| fun (id, opt) -> resolver.Resolve(id, opt)
62+
63+
module EventStore =
64+
65+
open Equinox.EventStore
66+
67+
let accessStrategy = AccessStrategy.LatestKnownEvent
68+
let create (context, cache) =
69+
let cacheStrategy = CachingStrategy.SlidingWindow (cache, System.TimeSpan.FromMinutes 20.)
70+
let resolver = Resolver(context, Events.codec, Fold.fold, Fold.initial, cacheStrategy, accessStrategy)
71+
create <| fun (id, opt) -> resolver.Resolve(id, opt)

equinox-fc/Domain/Location.fs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,13 @@ module Helpers =
4040
let create (zeroBalance, toBalanceCarriedForward, shouldClose) (series, epochs) =
4141
Service(zeroBalance, toBalanceCarriedForward, shouldClose, series, epochs)
4242

43+
module Cosmos =
44+
45+
let create (zeroBalance, toBalanceCarriedForward, shouldClose) (context, cache, maxAttempts) =
46+
let series = Series.Cosmos.create (context, cache, maxAttempts)
47+
let epochs = Epoch.Cosmos.create (context, cache, maxAttempts)
48+
create (zeroBalance, toBalanceCarriedForward, shouldClose) (series, epochs)
49+
4350
module EventStore =
4451

4552
let create (zeroBalance, toBalanceCarriedForward, shouldClose) (context, cache, maxAttempts) =

0 commit comments

Comments
 (0)