Skip to content

Commit

Permalink
Transaction documentation
Browse files Browse the repository at this point in the history
Also tightened up some types, and did some internal renaming.
  • Loading branch information
mikebroberts committed Sep 27, 2023
1 parent 0adbcfa commit 90fd353
Show file tree
Hide file tree
Showing 6 changed files with 219 additions and 39 deletions.
183 changes: 182 additions & 1 deletion documentation/TransactionalOperations.md
Original file line number Diff line number Diff line change
@@ -1 +1,182 @@
Coming soon!
# Chapter 8 - Transactional Operations

DynamoDB transactions allow you to group multiple _actions_ into one transactional operation.
They're more limited than what some people may be used to from transactions in relational databases, but here are a couple of examples of when I've used DynamoDB transactions:

* When I want to put two related items, but I only want to put both items if both satisfy a condition check
* When I want to get two related items in a fast moving system, and know for sure that both items represented the same point in time

The AWS docs have a [section devoted to DynamoDB transactions](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/transactions.html) so I recommend you start with them if you're new to this area.

DynamoDB supports two different types of transactional operation - `TransactWriteItems` and `TransactGetItems`.
_DynamoDB Entity Store_ supports both types.

Transactions often involve multiple types of entity, and one of the powerful aspects of DynamoDB transactional operations is that they support multiple tables in one operation.
Because of these points _DynamoDB Entity Store_ transactional operations support multiple entities, **and** support multiple tables.

I start by explaining 'get' transactions since they're more simple, and then I move on to 'write' transactions.

## Get Transactions

Here's an example of using Entity Store's Get Transaction support:

```typescript
const entityStore = createStore(createStandardSingleTableConfig('AnimalsTable'))
const response = await store.transactions
.buildGetTransaction(SHEEP_ENTITY)
.get({ breed: 'merino', name: 'shaun'})
.get({ breed: 'alpaca', name: 'alison' })
.get({ breed: 'merino', name: 'bob' })
.nextEntity(CHICKEN_ENTITY)
.get({breed: 'sussex', name: 'ginger'})
.execute()
```

Which results in an object like this:

```typescript
{
itemsByEntityType: {
sheep: [{ breed: 'merino', name: 'shaun', ageInYears: 3 }, null, { breed: 'merino', name: 'bob', ageInYears: 4 }]
chicken: [{breed: 'sussex', name: 'ginger', dateOfBirth: '2021-07-01', coop: 'bristol'}]
}
}
```

You start a get transaction by calling [`.transactions.buildGetTransaction(firstEntity)`](https://symphoniacloud.github.io/dynamodb-entity-store/interfaces/TransactionOperations.html#buildGetTransaction) on the top level store object.
This provides a "builder" object that you can use to provide the item keys you want to get, and finally you call `.execute()` to execute the transaction request.

The builder object works as follows.

### First entity and `nextEntity()`

Like the single entity operations, `.buildGetTransaction()` performs actions on an entity-by-entity basis.
In other words all the get-actions you specify are in the context of one specific entity.
To kick things off you specify the entity for your first get action.

`buildGetTransaction()` takes one required parameter - an `Entity`.
Once you've specified all the get-actions for one entity you can then, if necessary, specify actions for a different entity by calling [`nextEntity()`](https://symphoniacloud.github.io/dynamodb-entity-store/interfaces/GetTransactionBuilder.html#nextEntity).
This also takes one required parameter - another instance of `Entity`.
You use the result of `nextEntity()` to add the next actions, and to add more Entity Types if necessary.

DynamoDB Entity Store transactions support multiple tables, which means a couple of things:
* You can use transactions in single or multi-table configurations
* Each of the entities you specify in one `buildGetTransaction()` operation can be for one or multiple tables

Furthermore, **unlike** the [multi-entity collection operations](QueryingAndScanningMultipleEntities.md), you aren't required to have an _entityType_ attribute on your table(s).
If your configuration works for regular single-entity `get` operations, it will work for transactional gets too.

### `.get()`

Once you've specified an entity - either the first entity when you call `.buildGetTransaction()`, or subsequent entities by calling `nextEntity()` - you can specify "get-actions" which are in the context of **the most recently specified entity**.

Each get-action is specified by one call to [`.get()`](https://symphoniacloud.github.io/dynamodb-entity-store/interfaces/GetTransactionBuilder.html#get), which takes one argument - a `keySource` which is used along with the entity to generate the key for desired object.
This uses precisely the same logic as `.getOrThrow()` or `.getOrUndefined()` as described in [chapter 3](GettingStartedWithOperations.md).

Because the library uses a builder pattern for transactions make sure to use the result of each `.get()` for whatever you do next.

DynamoDB's _TransactGetItems_ logic takes an ordered array of up to 100 get actions, and so you can specify up to 100 actions with a single call to `buildGetTransaction()`.

Further, since the list of actions is ordered then if necessary you can switch back to a previously specified entity as part of setting up a transaction.
E.g. the following call is valid:

```typescript
await store.transactions
.buildGetTransaction(SHEEP_ENTITY)
.get({ breed: 'merino', name: 'shaun'})
.nextEntity(CHICKEN_ENTITY)
.get({breed: 'sussex', name: 'ginger'})
.nextEntity(SHEEP_ENTITY)
.get({ breed: 'alpaca', name: 'alison' })
.nextEntity(CHICKEN_ENTITY)
.get({breed: 'sussex', name: 'babs'})
.execute()
```

### `.execute()`, and response

When you've specified all the get-actions you call [`.execute()`](https://symphoniacloud.github.io/dynamodb-entity-store/interfaces/GetTransactionBuilder.html#execute) to perform the operation.

`.execute()` has an optional `options` parameter, which allows you to request capacity metadata (with the `consumedCapacity` field).
This works in the same way as was described in [chapter 6](AdvancedSingleEntityOperations.md).

If `.execute()` is successful it returns an object of type [`GetTransactionResponse`](https://symphoniacloud.github.io/dynamodb-entity-store/interfaces/GetTransactionResponse.html).
This has one required field - `itemsByEntityType`.
As shown in the example above, `itemsByEntityType` is a map from entity type to the parsed result of each get-action.
Note that the entity type comes solely from the entity object that was in scope when each action was specified - the underlying table items **do not** need an entity-type attribute.

Each array of parsed items (per entity type) is in the same order as was originally created in the `buildGetTransaction()` chain.
Further, if a particular item didn't exist in the table then it is represented in the result array with a `null`.

If you specified options on the call to `.execute()` then look for metadata on the `.metadata` field in the way described in [chapter 6](AdvancedSingleEntityOperations.md).

## Write Transactions

Write Transactions in Entity Store work very similarly to Get Transactions.
The main differences are each action is more complicated, and there's not much interesting on the response.
Here's an example:

```typescript
const entityStore = createStore(createStandardSingleTableConfig('AnimalsTable'))
await store.transactions
.buildWriteTransaction(SHEEP_ENTITY)
.put({ breed: 'merino', name: 'shaun', ageInYears: 3 },
{ conditionExpression: 'attribute_not_exists(PK)' })
.put({ breed: 'merino', name: 'bob', ageInYears: 4 })
.nextEntity(CHICKEN_ENTITY)
.put({ breed: 'sussex', name: 'ginger', dateOfBirth: '2021-07-01', coop: 'bristol' },
{ conditionExpression: 'attribute_not_exists(PK)' })
.execute()
```

Write transactions have the same pattern as get transactions, specifically:

* Start specifying a transaction by calling `buildWriteTransaction(firstEntity)`, passing the entity for the first action
* This returns a builder-object you can use for specifying the rest of the operation
* Use action specifiers
* Call `.nextEntity(entity)` to change the entity context for the next action(s)
* Call `.execute()` to finalize the operation, and make the request to DynamoDB

Just like get transactions, write transactions support multiple entities and multiple tables.

### Action specifiers

Each write transaction consists of an ordered list of one or more actions.
There are four different action types, each of which have their own function on the transaction builder.

* [`.put()`](https://symphoniacloud.github.io/dynamodb-entity-store/interfaces/WriteTransactionBuilder.html#put)
* [`.update()`](https://symphoniacloud.github.io/dynamodb-entity-store/interfaces/WriteTransactionBuilder.html#update)
* [`.delete()`](https://symphoniacloud.github.io/dynamodb-entity-store/interfaces/WriteTransactionBuilder.html#delete)
* [`.conditionCheck()`](https://symphoniacloud.github.io/dynamodb-entity-store/interfaces/WriteTransactionBuilder.html#conditionCheck)

`.put()`, `.update()`, `.delete()` all work in almost exactly the same way as their _standard_ single-item equivalent operations, so if in doubt see
[chapter 3](GettingStartedWithOperations.md) for what this means.

The only difference is that they can each take an additional field on their options argument - `returnValuesOnConditionCheckFailure`.
See [the AWS docs](https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_PutItem.html#DDB-PutItem-request-ReturnValuesOnConditionCheckFailure) for an explanation.

For transactions you'll often be using condition expressions for some / all of these actions, and expression specification works the same way for transactions as it does for single-item operations.

`.conditionCheck()` is specific for write transactions.
Its parameters are the same as those for `.delete()` with two differences:

* The second parameter - `options` - is required
* The `conditionExpression` field on the options parameter is required

DynamoDB interprets a condition check in the same way as it does a delete, except it doesn't actually make any data changes.
For more details, see the [_TransactWriteItems_ docs](https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_TransactWriteItems.html).

### `.execute()`

[`.execute()`](https://symphoniacloud.github.io/dynamodb-entity-store/interfaces/WriteTransactionBuilder.html#execute) works in mostly the same way as it does for get-expressions in that it builds the full transaction request, and makes the call to DynamoDB.

You may specify `returnConsumedCapacity` and `returnItemCollectionMetrics` fields on `.execute()`'s options to retrieve diagnostic metadata on the response.

You may also specify a write-transaction specific option - `clientRequestToken`.
This is passed to DynamoDB, and you can read more about it in the [AWS Docs](https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_TransactWriteItems.html#API_TransactWriteItems_RequestParameters).

`.execute()` returns an empty response, unless you specify either/both of the metadata options, in which case the response will have a `.metadata` field.

## Congratulations!

You've made it to the end of the manual! That's it, no more to see! If you have any questions [drop me a line](mailto:mike@symphonia.io), or use the issues in the GitHub project.
4 changes: 2 additions & 2 deletions src/lib/internal/transactions/conditionCheckOperation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export interface ConditionCheckParams {
ExpressionAttributeValues?: DynamoDBValues
}

export function createTransactionConditionCheckItem<
export function createTransactionConditionCheck<
TItem extends TPKSource & TSKSource,
TKeySource extends TPKSource & TSKSource,
TPKSource,
Expand All @@ -26,7 +26,7 @@ export function createTransactionConditionCheckItem<
options: TransactionConditionCheckOptions
): ConditionCheckParams {
return {
ConditionExpression: options?.conditionExpression,
ConditionExpression: options.conditionExpression,
...tableNameParam(context),
...keyParamFromSource(context, keySource),
...expressionAttributeParamsFromOptions(options)
Expand Down
28 changes: 14 additions & 14 deletions src/lib/internal/transactions/tableBackedGetTransactionBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
GetTransactionResponse
} from '../../transactionOperations'

interface GetTransactionItem {
interface GetTransactionAction {
Get: {
Key: DynamoDBValues
TableName: string
Expand All @@ -26,9 +26,9 @@ interface GetTransactionItem {
export class TableBackedGetTransactionBuilder<TItem extends TPKSource & TSKSource, TPKSource, TSKSource>
implements GetTransactionBuilder<TItem, TPKSource, TSKSource>
{
private readonly requests: GetTransactionItem[]
private readonly actions: GetTransactionAction[]
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private readonly contextsPerRequest: EntityContext<any, any, any>[]
private readonly contextsPerAction: EntityContext<any, any, any>[]
private readonly tableConfigResolver: (entityType: string) => EntityContextParams
private readonly context: EntityContext<TItem, TPKSource, TSKSource>

Expand All @@ -37,43 +37,43 @@ export class TableBackedGetTransactionBuilder<TItem extends TPKSource & TSKSourc
currentEntity: Entity<TItem, TPKSource, TSKSource>,
{
contexts,
requests
}: { contexts: EntityContext<unknown, unknown, unknown>[]; requests: GetTransactionItem[] } = {
actions
}: { contexts: EntityContext<unknown, unknown, unknown>[]; actions: GetTransactionAction[] } = {
contexts: [],
requests: []
actions: []
}
) {
this.tableConfigResolver = tableConfigResolver
this.requests = requests
this.contextsPerRequest = contexts
this.actions = actions
this.contextsPerAction = contexts
this.context = createEntityContext(tableConfigResolver(currentEntity.type), currentEntity)
}

get<TKeySource extends TPKSource & TSKSource>(
keySource: TKeySource
): GetTransactionBuilder<TItem, TPKSource, TSKSource> {
this.requests.push({
this.actions.push({
Get: {
...tableNameParam(this.context),
...keyParamFromSource(this.context, keySource)
}
})
this.contextsPerRequest.push(this.context)
this.contextsPerAction.push(this.context)
return this
}

nextEntity<TNextItem extends TNextPKSource & TNextSKSource, TNextPKSource, TNextSKSource>(
nextEntity: Entity<TNextItem, TNextPKSource, TNextSKSource>
): GetTransactionBuilder<TNextItem, TNextPKSource, TNextSKSource> {
return new TableBackedGetTransactionBuilder(this.tableConfigResolver, nextEntity, {
contexts: this.contextsPerRequest,
requests: this.requests
contexts: this.contextsPerAction,
actions: this.actions
})
}

async execute(options?: GetTransactionOptions): Promise<GetTransactionResponse> {
const transactionParams: TransactGetCommandInput = {
TransactItems: this.requests,
TransactItems: this.actions,
...returnConsumedCapacityParam(options)
}

Expand All @@ -85,7 +85,7 @@ export class TableBackedGetTransactionBuilder<TItem extends TPKSource & TSKSourc
this.context.logger.debug(`Get transaction result`, { result })
}

return parseResponse(this.contextsPerRequest, result)
return parseResponse(this.contextsPerAction, result)
}
}

Expand Down
24 changes: 12 additions & 12 deletions src/lib/internal/transactions/tableBackedWriteTransactionBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
} from '@aws-sdk/lib-dynamodb'
import { isDebugLoggingEnabled } from '../../util/logger'
import { Mandatory } from '../../util/types'
import { ConditionCheckParams, createTransactionConditionCheckItem } from './conditionCheckOperation'
import { ConditionCheckParams, createTransactionConditionCheck } from './conditionCheckOperation'
import { returnConsumedCapacityParam, returnItemCollectionMetricsParam } from '../common/operationsCommon'
import {
TransactionConditionCheckOptions,
Expand All @@ -23,7 +23,7 @@ import { putParams } from '../common/putCommon'
import { deleteParams } from '../common/deleteCommon'
import { createUpdateParams } from '../common/updateCommon'

type WriteTransactionRequest =
type WriteTransactionAction =
| {
Put: PutCommandInput
}
Expand All @@ -40,60 +40,60 @@ type WriteTransactionRequest =
export class TableBackedWriteTransactionBuilder<TItem extends TPKSource & TSKSource, TPKSource, TSKSource>
implements WriteTransactionBuilder<TItem, TPKSource, TSKSource>
{
private readonly requests: WriteTransactionRequest[]
private readonly actions: WriteTransactionAction[]
private readonly tableConfigResolver: (entityType: string) => EntityContextParams
private readonly context: EntityContext<TItem, TPKSource, TSKSource>

constructor(
tableConfigResolver: (entityType: string) => EntityContextParams,
currentEntity: Entity<TItem, TPKSource, TSKSource>,
requests?: WriteTransactionRequest[]
actions?: WriteTransactionAction[]
) {
this.tableConfigResolver = tableConfigResolver
this.requests = requests ?? []
this.actions = actions ?? []
this.context = createEntityContext(tableConfigResolver(currentEntity.type), currentEntity)
}

nextEntity<TNextItem extends TNextPKSource & TNextSKSource, TNextPKSource, TNextSKSource>(
nextEntity: Entity<TNextItem, TNextPKSource, TNextSKSource>
): WriteTransactionBuilder<TNextItem, TNextPKSource, TNextSKSource> {
return new TableBackedWriteTransactionBuilder(this.tableConfigResolver, nextEntity, this.requests)
return new TableBackedWriteTransactionBuilder(this.tableConfigResolver, nextEntity, this.actions)
}

put(item: TItem, options?: TransactionPutOptions): WriteTransactionBuilder<TItem, TPKSource, TSKSource> {
this.requests.push({ Put: putParams(this.context, item, options) })
this.actions.push({ Put: putParams(this.context, item, options) })
return this
}

update<TKeySource extends TPKSource & TSKSource>(
keySource: TKeySource,
options: TransactionUpdateOptions
): WriteTransactionBuilder<TItem, TPKSource, TSKSource> {
this.requests.push({ Update: createUpdateParams(this.context, keySource, options) })
this.actions.push({ Update: createUpdateParams(this.context, keySource, options) })
return this
}

delete<TKeySource extends TPKSource & TSKSource>(
keySource: TKeySource,
options?: TransactionDeleteOptions
): WriteTransactionBuilder<TItem, TPKSource, TSKSource> {
this.requests.push({ Delete: deleteParams(this.context, keySource, options) })
this.actions.push({ Delete: deleteParams(this.context, keySource, options) })
return this
}

conditionCheck<TKeySource extends TPKSource & TSKSource>(
keySource: TKeySource,
options: TransactionConditionCheckOptions
): WriteTransactionBuilder<TItem, TPKSource, TSKSource> {
this.requests.push({
ConditionCheck: createTransactionConditionCheckItem(this.context, keySource, options)
this.actions.push({
ConditionCheck: createTransactionConditionCheck(this.context, keySource, options)
})
return this
}

async execute(options?: WriteTransactionOptions): Promise<WriteTransactionResponse> {
const transactionParams: TransactWriteCommandInput = {
TransactItems: this.requests,
TransactItems: this.actions,
...returnConsumedCapacityParam(options),
...returnItemCollectionMetricsParam(options),
...(options?.clientRequestToken ? { ClientRequestToken: options.clientRequestToken } : {})
Expand Down
Loading

0 comments on commit 90fd353

Please sign in to comment.