Skip to content

Commit

Permalink
Merge pull request #81 from fsprojects/multi-transaction-changes
Browse files Browse the repository at this point in the history
Multi transaction API changes
  • Loading branch information
samritchie authored Nov 14, 2024
2 parents 3a77a31 + 3b9a883 commit c4f7d1d
Show file tree
Hide file tree
Showing 5 changed files with 59 additions and 77 deletions.
13 changes: 7 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -269,25 +269,26 @@ let doesntExistCondition = compile <@ fun t -> NOT_EXISTS t.Value @>
let existsCondition = compile <@ fun t -> EXISTS t.Value @>
let key = TableKey.Combined(hashKey, rangeKey)
let transaction = Transaction()
let transaction = table.CreateTransaction()
transaction.Check(table, key, doesntExistCondition)
transaction.Put(table, item2, None)
transaction.Put(table, item3, Some existsCondition)
transaction.Delete (table ,table.Template.ExtractKey item5, None)
transaction.Delete(table, table.Template.ExtractKey item5, None)
do! transaction.TransactWriteItems()
```

Failed preconditions (or `TransactWrite.Check`s) are signalled as per the underlying API: via a `TransactionCanceledException`.
Use `TransactWriteItemsRequest.TransactionCanceledConditionalCheckFailed` to trap such conditions:
Failed preconditions (or `Check`s) are signalled as per the underlying API: via a `TransactionCanceledException`.
Use `Transaction.TransactionCanceledConditionalCheckFailed` to trap such conditions:

```fsharp
try do! transaction.TransactWriteItems()
return Some result
with TransactWriteItemsRequest.TransactionCanceledConditionalCheckFailed -> return None
with Transaction.TransactionCanceledConditionalCheckFailed -> return None
```

See [`TransactWriteItems tests`](./tests/FSharp.AWS.DynamoDB.Tests/SimpleTableOperationTests.fs#130) for more details and examples.
See [`TransactWriteItems tests`](./tests/FSharp.AWS.DynamoDB.Tests/SimpleTableOperationTests.fs#156) for more details and examples.

It generally costs [double or more the Write Capacity Units charges compared to using precondition expressions](https://zaccharles.medium.com/calculating-a-dynamodb-items-size-and-consumed-capacity-d1728942eb7c)
on individual operations.
Expand Down
3 changes: 3 additions & 0 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
### 0.12.2-beta
* (breaking) Revised multi-table transaction API (thanks @bartelink)

### 0.12.1-beta
* Added support for `defaultArg` in update expressions on the same attribute, allowing SET if_not_exists semantics (eg { record with OptionalValue = Some (defaultArg record.OptionalValue "Default") })
* Allow empty strings in non-key attributes (thanks @purkhusid)
Expand Down
52 changes: 18 additions & 34 deletions src/FSharp.AWS.DynamoDB/TableContext.fs
Original file line number Diff line number Diff line change
Expand Up @@ -1318,22 +1318,17 @@ type TableContext<'TRecord>
else
t.VerifyTableAsync()

member t.Transaction() =
match metricsCollector with
| Some metricsCollector -> Transaction(metricsCollector = metricsCollector)
| None -> Transaction()
/// <summary>Creates a new `Transaction`, using the DynamoDB client and metricsCollector configured for this `TableContext`</summary>
member _.CreateTransaction() =
Transaction(client, ?metricsCollector = metricsCollector)

/// <summary>
/// Represents a transactional set of operations to be applied atomically to a arbitrary number of DynamoDB tables.
/// </summary>
/// <param name="client">DynamoDB client instance</param>
/// <param name="metricsCollector">Function to receive request metrics.</param>
and Transaction(?metricsCollector: (RequestMetrics -> unit)) =
and Transaction(client: IAmazonDynamoDB, ?metricsCollector: (RequestMetrics -> unit)) =
let transactionItems = ResizeArray<TransactWriteItem>()
let mutable (dynamoDbClient: IAmazonDynamoDB) = null

let setClient client =
if dynamoDbClient = null then
dynamoDbClient <- client

let reportMetrics collector (tableName: string) (operation: Operation) (consumedCapacity: ConsumedCapacity list) (itemCount: int) =
collector
Expand All @@ -1353,35 +1348,30 @@ and Transaction(?metricsCollector: (RequestMetrics -> unit)) =
/// <param name="tableContext">Table context to operate on.</param>
/// <param name="item">Item to be put.</param>
/// <param name="precondition">Optional precondition expression.</param>
member this.Put<'TRecord>
member _.Put<'TRecord>
(
tableContext: TableContext<'TRecord>,
item: 'TRecord,
?precondition: ConditionExpression<'TRecord>
) : Transaction =
setClient tableContext.Client
) =
let req = Put(TableName = tableContext.TableName, Item = tableContext.Template.ToAttributeValues item)
precondition
|> Option.iter (fun cond ->
let writer = AttributeWriter(req.ExpressionAttributeNames, req.ExpressionAttributeValues)
req.ConditionExpression <- cond.Conditional.Write writer)
transactionItems.Add(TransactWriteItem(Put = req))
this

/// <summary>
/// Adds a ConditionCheck operation to the transaction.
/// </summary>
/// <param name="tableContext">Table context to operate on.</param>
/// <param name="key">Key of item to check.</param>
/// <param name="condition">Condition to check.</param>
member this.Check(tableContext: TableContext<'TRecord>, key: TableKey, condition: ConditionExpression<'TRecord>) : Transaction =
setClient tableContext.Client

member _.Check(tableContext: TableContext<'TRecord>, key: TableKey, condition: ConditionExpression<'TRecord>) =
let req = ConditionCheck(TableName = tableContext.TableName, Key = tableContext.Template.ToAttributeValues key)
let writer = AttributeWriter(req.ExpressionAttributeNames, req.ExpressionAttributeValues)
req.ConditionExpression <- condition.Conditional.Write writer
transactionItems.Add(TransactWriteItem(ConditionCheck = req))
this

/// <summary>
/// Adds an Update operation to the transaction.
Expand All @@ -1390,44 +1380,38 @@ and Transaction(?metricsCollector: (RequestMetrics -> unit)) =
/// <param name="key">Key of item to update.</param>
/// <param name="updater">Update expression.</param>
/// <param name="precondition">Optional precondition expression.</param>
member this.Update
member _.Update
(
tableContext: TableContext<'TRecord>,
key: TableKey,
updater: UpdateExpression<'TRecord>,
?precondition: ConditionExpression<'TRecord>

) : Transaction =
setClient tableContext.Client

) =
let req = Update(TableName = tableContext.TableName, Key = tableContext.Template.ToAttributeValues key)
let writer = AttributeWriter(req.ExpressionAttributeNames, req.ExpressionAttributeValues)
req.UpdateExpression <- updater.UpdateOps.Write(writer)
precondition |> Option.iter (fun cond -> req.ConditionExpression <- cond.Conditional.Write writer)
transactionItems.Add(TransactWriteItem(Update = req))
this

/// <summary>
/// Adds a Delete operation to the transaction.
/// </summary>
/// <param name="tableContext">Table context to operate on.</param>
/// <param name="key">Key of item to delete.</param>
/// <param name="precondition">Optional precondition expression.</param>
member this.Delete
member _.Delete
(
tableContext: TableContext<'TRecord>,
key: TableKey,
precondition: option<ConditionExpression<'TRecord>>
) : Transaction =
setClient tableContext.Client

?precondition: ConditionExpression<'TRecord>
) =
let req = Delete(TableName = tableContext.TableName, Key = tableContext.Template.ToAttributeValues key)
precondition
|> Option.iter (fun cond ->
let writer = AttributeWriter(req.ExpressionAttributeNames, req.ExpressionAttributeValues)
req.ConditionExpression <- cond.Conditional.Write writer)
transactionItems.Add(TransactWriteItem(Delete = req))
this

/// <summary>
/// Atomically applies a set of 1-100 operations to the table.<br/>
Expand All @@ -1436,13 +1420,13 @@ and Transaction(?metricsCollector: (RequestMetrics -> unit)) =
/// </summary>
/// <param name="clientRequestToken">The <c>ClientRequestToken</c> to supply as an idempotency key (10 minute window).</param>
member _.TransactWriteItems(?clientRequestToken) : Async<unit> = async {
if (Seq.length transactionItems) = 0 || (Seq.length transactionItems) > 100 then
if transactionItems.Count = 0 || transactionItems.Count > 100 then
raise
<| System.ArgumentOutOfRangeException(nameof transactionItems, "must be between 1 and 100 items.")
let req = TransactWriteItemsRequest(ReturnConsumedCapacity = returnConsumedCapacity, TransactItems = (ResizeArray transactionItems))
let req = TransactWriteItemsRequest(ReturnConsumedCapacity = returnConsumedCapacity, TransactItems = transactionItems)
clientRequestToken |> Option.iter (fun x -> req.ClientRequestToken <- x)
let! ct = Async.CancellationToken
let! response = dynamoDbClient.TransactWriteItemsAsync(req, ct) |> Async.AwaitTaskCorrect
let! response = client.TransactWriteItemsAsync(req, ct) |> Async.AwaitTaskCorrect
maybeReport
|> Option.iter (fun r ->
response.ConsumedCapacity
Expand Down Expand Up @@ -2150,8 +2134,8 @@ module Scripting =
let spec = Throughput.Provisioned provisionedThroughput
t.UpdateTableIfRequiredAsync(spec) |> Async.Ignore |> Async.RunSynchronously

/// Helpers for working with <c>TransactWriteItemsRequest</c>
module TransactWriteItemsRequest =
/// Helpers for working with <c>Transaction</c>
module Transaction =
/// <summary>Exception filter to identify whether a <c>TransactWriteItems</c> call has failed due to
/// one or more of the supplied <c>precondition</c> checks failing.</summary>
let (|TransactionCanceledConditionalCheckFailed|_|): exn -> unit option =
Expand Down
15 changes: 7 additions & 8 deletions tests/FSharp.AWS.DynamoDB.Tests/MetricsCollectorTests.fs
Original file line number Diff line number Diff line change
Expand Up @@ -106,10 +106,9 @@ type Tests(fixture: TableFixture) =
collector.Clear()

let item = mkItem (guid ()) (guid ()) 0
do!
Transaction(collector.Collect)
.Put(sut, item, compile <@ fun t -> NOT_EXISTS t.RangeKey @>)
.TransactWriteItems()
let transaction = Transaction(sut.Client, collector.Collect)
transaction.Put(sut, item, compile <@ fun t -> NOT_EXISTS t.RangeKey @>)
do! transaction.TransactWriteItems()

test
<@
Expand All @@ -131,14 +130,14 @@ type Tests(fixture: TableFixture) =
let sut = rawTable.WithMetricsCollector(collector.Collect)

let item = mkItem (guid ()) (guid ()) 0
let transaction = rawTable.CreateTransaction()
transaction.Put(sut, item, compile <@ fun t -> EXISTS t.RangeKey @>)
let mutable failed = false
try
do!
// The check will fail, which triggers a throw from the underlying AWS SDK; there's no way to extract the consumption info in that case
Transaction()
.Put(sut, item, compile <@ fun t -> EXISTS t.RangeKey @>)
.TransactWriteItems()
with TransactWriteItemsRequest.TransactionCanceledConditionalCheckFailed ->
transaction.TransactWriteItems()
with Transaction.TransactionCanceledConditionalCheckFailed ->
failed <- true
true =! failed
[] =! collector.Metrics
Expand Down
53 changes: 24 additions & 29 deletions tests/FSharp.AWS.DynamoDB.Tests/SimpleTableOperationTests.fs
Original file line number Diff line number Diff line change
Expand Up @@ -182,10 +182,9 @@ type ``TransactWriteItems tests``(table1: TableFixture, table2: TableFixture) =
[<Fact>]
let ``Minimal happy path`` () = async {
let item = mkItem ()
do!
Transaction()
.Put(table1, item, doesntExistConditionTable1)
.TransactWriteItems()
let transaction = table1.CreateTransaction()
transaction.Put(table1, item, doesntExistConditionTable1)
do! transaction.TransactWriteItems()

let! itemFound = table1.ContainsKeyAsync(table1.Template.ExtractKey item)
true =! itemFound
Expand All @@ -196,11 +195,10 @@ type ``TransactWriteItems tests``(table1: TableFixture, table2: TableFixture) =
let item = mkItem ()
let compatibleItem = mkCompatibleItem ()

do!
Transaction()
.Put(table1, item, doesntExistConditionTable1)
.Put(table2, compatibleItem, doesntExistConditionTable2)
.TransactWriteItems()
let transaction = table1.CreateTransaction()
transaction.Put(table1, item, doesntExistConditionTable1)
transaction.Put(table2, compatibleItem, doesntExistConditionTable2)
do! transaction.TransactWriteItems()

let! itemFound = table1.ContainsKeyAsync(table1.Template.ExtractKey item)
true =! itemFound
Expand All @@ -213,13 +211,12 @@ type ``TransactWriteItems tests``(table1: TableFixture, table2: TableFixture) =
let ``Minimal Canceled path`` () = async {
let item = mkItem ()

let transaction = table1.CreateTransaction()
transaction.Put(table1, item, existsConditionTable1)
let mutable failed = false
try
do!
Transaction()
.Put(table1, item, existsConditionTable1)
.TransactWriteItems()
with TransactWriteItemsRequest.TransactionCanceledConditionalCheckFailed ->
do! transaction.TransactWriteItems()
with Transaction.TransactionCanceledConditionalCheckFailed ->
failed <- true

true =! failed
Expand All @@ -233,18 +230,17 @@ type ``TransactWriteItems tests``(table1: TableFixture, table2: TableFixture) =
let item, item2 = mkItem (), mkItem ()
let! key = table1.PutItemAsync item

let transaction =
if shouldFail then
Transaction().Check(table1, key, doesntExistConditionTable1)
else
Transaction()
.Check(table1, key, existsConditionTable1)
.Put(table1, item2)
let transaction = table1.CreateTransaction()
if shouldFail then
transaction.Check(table1, key, doesntExistConditionTable1)
else
transaction.Check(table1, key, existsConditionTable1)
transaction.Put(table1, item2)

let mutable failed = false
try
do! transaction.TransactWriteItems()
with TransactWriteItemsRequest.TransactionCanceledConditionalCheckFailed ->
with Transaction.TransactionCanceledConditionalCheckFailed ->
failed <- true

failed =! shouldFail
Expand All @@ -257,14 +253,14 @@ type ``TransactWriteItems tests``(table1: TableFixture, table2: TableFixture) =
let ``All paths`` shouldFail = async {
let item, item2, item3, item4, item5, item6, item7 = mkItem (), mkItem (), mkItem (), mkItem (), mkItem (), mkItem (), mkItem ()
let! key = table1.PutItemAsync item
let transaction = Transaction()
let transaction = table1.CreateTransaction()

let requests =
[ transaction.Update(table1, key, compileUpdateTable1 <@ fun t -> { t with Value = 42 } @>, existsConditionTable1)
transaction.Put(table1, item2)
transaction.Put(table1, item3, doesntExistConditionTable1)
transaction.Delete(table1, table1.Template.ExtractKey item4, Some doesntExistConditionTable1)
transaction.Delete(table1, table1.Template.ExtractKey item5, None)
transaction.Delete(table1, table1.Template.ExtractKey item4, doesntExistConditionTable1)
transaction.Delete(table1, table1.Template.ExtractKey item5)
transaction.Check(
table1,
table1.Template.ExtractKey item6,
Expand All @@ -281,7 +277,7 @@ type ``TransactWriteItems tests``(table1: TableFixture, table2: TableFixture) =
let mutable failed = false
try
do! transaction.TransactWriteItems()
with TransactWriteItemsRequest.TransactionCanceledConditionalCheckFailed ->
with Transaction.TransactionCanceledConditionalCheckFailed ->
failed <- true
failed =! shouldFail

Expand Down Expand Up @@ -310,13 +306,12 @@ type ``TransactWriteItems tests``(table1: TableFixture, table2: TableFixture) =

[<Fact>]
let ``Empty request list is rejected with AORE`` () =
shouldBeRejectedWithArgumentOutOfRangeException (Transaction())
shouldBeRejectedWithArgumentOutOfRangeException (Transaction(table1.Client))
|> Async.RunSynchronously
|> ignore

[<Fact>]
let ``Over 100 writes are rejected with AORE`` () =
let Transaction = Transaction()
let Transaction = Transaction(table1.Client)
for _x in 1..101 do
Transaction.Put(table1, mkItem ()) |> ignore

Expand Down

0 comments on commit c4f7d1d

Please sign in to comment.