CLOVER🍀

That was when it all began.

Amazon DynamoDBローカル版とDocumentClientで、トランザクションを試す

これは、なにをしたくて書いたもの?

今回は、Amazon DynamoDBのトランザクションを試してみようかなと思います。

DynamoDB トランザクションで複雑なワークフローを管理する - Amazon DynamoDB

Amazon DynamoDBのトランザクション

Amazon DynamoDBにはトランザクションがあるようです。

DynamoDB トランザクションで複雑なワークフローを管理する - Amazon DynamoDB

ACIDが実現される、と言っています。

トランザクションによって DynamoDB に不可分性、一貫性、分離性、耐久性 (ACID) が実現されるため、アプリケーション内でのデータの精度を維持することができます。

とはいっても、RDBMSでいうトランザクションとは差はあるでしょう。

トランザクションにはTransactWriteItemsとTransactGetItemsの2種類の操作があり、書き込みおよび読み込みでグループ化
できるようです。

トランザクション書き込み API を使用して、複数の Put、Update、Delete、ConditionCheck の各アクションをグループ化できます。その後、アクションを単一の TransactWriteItems オペレーションとして送信できます。このオペレーションはユニットとして成功または失敗します。同じことが複数の Get アクションにも当てはまります。この場合、1 つの TransactGetItems オペレーションとしてグループ化し、送信できます。

もっと詳しい内容は、こちらのページに記載があります。

Amazon DynamoDB Transactions: 仕組み - Amazon DynamoDB

まずは、TransactWriteItems APIから。

Amazon DynamoDB Transactions: 仕組み / TransactWriteItems API

TransactWriteItemsは、最大25個の書き込み操作をまとめて行うAPIのようです。

TransactWriteItems は、最大 25 の書き込みアクションを 1 つのオールオアナッシングオペレーションにグループ化する、同期的でべき等な書き込みオペレーションです。これらのアクションは、同じ AWS アカウントおよび同じリージョン内の 1 つ以上の DynamoDB テーブルにある最大 25 個の異なる項目をターゲットできます。トランザクション内のアイテムの合計サイズは 4 MB を超えることはできません。すべて成功するかどれも成功しないかのどちらとなるように、アトミックに実行されます。

同一AWSアカウント、同一リージョンの制限、まとめて実行した時のデータ量にも制限があるようです。

こう書くとバッチ処理のようなイメージを受けますが、処理がアトミックに行われることがバッチ処理との差異のようです。

TransactWriteItems オペレーションは、含まれるすべてのアクションを正常に完了する必要があり、そうでない場合は変更がまったく行われないという点で BatchWriteItem オペレーションとは異なります。BatchWriteItem オペレーションでは、バッチ内の一部のアクションのみ成功し、他のアクションは成功しないことがあり得ます。

そもそも、バッチ処理用のAPIは別にありましたね…。

TransactWriteItemsトランザクションに含める操作の中に、同じアイテムを処理する操作を複数含むことはできません。

同じトランザクション内の複数のオペレーションが同じ項目をターゲットとすることはできません。たとえば、同じトランザクション内で同じ項目に対して ConditionCheck を実行し、Update アクションも実行することはできません。

TransactWriteItemsに含めることができる操作は、以下の4つです。

  • Put
  • Update
  • Delete
  • ConditionCheck

ConditionCheckという操作が見慣れなかったのですが、これはTransactWriteItemsで使える操作のようですね。

TransactWriteItemsについては、「クライアントトークン」というものをリクエストに含めることで、べき等であることを
確認できます。

TransactWriteItems 呼び出しを行ってリクエストがべき等であることを確認するとき、オプションでクライアントトークンを含めることができます。トランザクションをべき等にすると、接続のタイムアウトや他の接続の問題のために同じオペレーションが複数回送信された場合に、アプリケーションエラーを防ぐことができます。

元の TransactWriteItems 呼び出しが成功した場合、同じクライアントトークンを使用したその後の TransactWriteItems 呼び出しが変更を加えずに正常に結果を返します。

クライアントトークンは使用したリクエストから10分間有効で、10分経過すると新しいリクエストとして扱われます。
また、10分以内に同じクライアントトークンを使ってリクエストを繰り返す場合に、含まれるリクエストの内容を変更すると
Amazon DynamoDBよりIdempotentParameterMismatchエラーがスローされます。

TransactWriteItemsが失敗する条件はいくつかあるようですが、以下あたりは注意でしょうか。

  • あるTransactWriteItemsリクエスト内の1つ以上のアイテムに対する処理が、他に継続中のTransactWriteItemsオペレーションと競合する
    • この場合、TransactionCanceledExceptionエラーがスローされる
  • トランザクションを完了するプロビジョンドキャパシティーが足りない
  • 項目サイズが大きくなりすぎる (400 KB 超)、ローカルセカンダリインデックス (LSI) が大きくなりすぎる、またはトランザクションにより変更が加えられたためにこの条件を満たしてしまう場合

トランザクションの競合については、詳しくは以下に書いてあります。

Amazon DynamoDB Transactions: 仕組み / DynamoDB でのトランザクション競合の処理

PutItemやUpdateItem、DeleteItemがトランザクションと競合して拒否された場合は、TransactionConflictExceptionが
スローされるようです。

次は、TransactGetItems APIについて。

Amazon DynamoDB Transactions: 仕組み / TransactGetItems API

TransactGetItemsは、TransactWriteItemsの読み込み操作版という感じですね。

TransactGetItems は、最大 25 個の Get アクションをまとめてグループ化する同期読み取りオペレーションです。これらのアクションは、同じ AWS アカウントおよびリージョン内の 1 つ以上の DynamoDB テーブルにある最大 25 個の異なる項目をターゲットにすることができます。トランザクション内の項目の合計サイズは 4 MB を超えることはできません。

Amazon DynamoDBはトランザクションとして、ReadとWriteは完全に分離されるようですね。

まあ、RDBMSのトランザクションのイメージよりも、バッチ処理に近い印象をやはり持ってしまうのですが。

TransactGetItemsに含めることができる操作は、Getのみです。

TransactGetItemsは、操作対象のアイテムに対する処理が実行中のTransactWriteItemsに含まれる操作と競合する場合、
TransactionCanceledExceptionで失敗するようです。

TransactGetItems リクエストが、TransactWriteItems リクエスト内の 1 つ以上の項目に対する継続中の TransactGetItems オペレーションと競合する場合。この場合、リクエストは TransactionCanceledException で失敗します。

トランザクションの分離レベル。

Amazon DynamoDB Transactions: 仕組み / DynamoDB トランザクションの分離レベル

TransactWriteItemsおよびTransactGetItemsのトランザクション分離レベルは、Serializableのようです。

なので、トランザクションは複数同時には実行できないことになります。

直列化可能分離レベルでは、複数の同時オペレーションの結果は、前のオペレーションが完了するまでオペレーションが開始されない場合と同じになります。

ロック対象は明記されていませんが、ドキュメント内の例を見ているとアイテム単位、ということになるのでしょうか?

Amazon DynamoDBの各種操作とトランザクション分離レベルのマッピングは、以下になるようです。

操作 トランザクション分離レベル
DeleteItem Serializable
PutItem Serializable
UpdateItem Serializable
GetItem Serializable
BatchGetItem Read Committed
BatchWriteItem NOT Serializable
Query Read Committed
Scan Read Committed
その他 Serializable

BatchWriteItemがよくわからないな?と思ったのですが、説明を見るとBatchWriteItemに含まれる個々のアイテムに対する操作は
Serializableだけれども、BatchWriteItem全体はSerializableではない、ということみたいです。

トランザクションオペレーション間と BatchWriteItem オペレーション内の個々の標準書き込み間には直列化可能分離がありますが、トランザクションとユニットとしての BatchWriteItem オペレーションの間には直列化可能分離はありません。

BatchGetItemがRead Committedなのも似た理由ですね。

Read Committedの説明は、このようになっています。

コミット済み読み取り分離では、読み取りオペレーションが常に項目のコミット済み値を返します。コミット済み読み取り分離では、読み取りオペレーションの直後に項目の変更が防止されません。

トランザクションのベストプラクティスは、こちら。

Amazon DynamoDB Transactions: 仕組み / トランザクションのベストプラクティス

このあたりがポイントみたいですね。

説明はこれくらいにして、実際に使っていきましょう。

確認には、Amazon DynamoDBのローカル版を使います。

環境

今回の環境は、こちら。

$ aws --version
aws-cli/2.4.20 Python/3.8.8 Linux/5.4.0-100-generic exe/x86_64.ubuntu.20 prompt/off


$ java --version
openjdk 17.0.1 2021-10-19
OpenJDK Runtime Environment (build 17.0.1+12-Ubuntu-120.04)
OpenJDK 64-Bit Server VM (build 17.0.1+12-Ubuntu-120.04, mixed mode, sharing)

ローカル版のAmazon DynamoDBの情報。

$ grep -A 2 'Release Notes' README.txt
Release Notes
-----------------------------
2022-1-10 (1.18.0)

インメモリーで起動させておきます。

$ java -Djava.library.path=./DynamoDBLocal_lib -jar DynamoDBLocal.jar -inMemory

ローカル版のAmazon DynamoDBに、AWS CLIでアクセスするためのクレデンシャル。

$ export AWS_ACCESS_KEY_ID=fakeMyKeyId
$ export AWS_SECRET_ACCESS_KEY=fakeSecretAccessKey
$ export AWS_DEFAULT_REGION=ap-northeast-1

確認は、AWS SDK for JavaScriptを使って、Node.jsで行います。

$ node --version
v16.14.0


$ npm --version
8.3.1

準備

Node.jsプロジェクトを作成します。テストコードで確認することにします。

$ npm init -y
$ npm i -D typescript
$ npm i -D -E prettier
$ npm i -D jest @types/jest
$ npm i -D esbuild esbuild-jest
$ mkdir src test

Node.jsの型宣言とAWS SDK for JavaScript v2をインストール。

$ npm i -D @types/node@v16
$ npm i aws-sdk

依存関係は、このようになりました。

  "devDependencies": {
    "@types/jest": "^27.4.0",
    "@types/node": "^16.11.25",
    "esbuild": "^0.14.23",
    "esbuild-jest": "^0.5.0",
    "jest": "^27.5.1",
    "prettier": "2.5.1",
    "typescript": "^4.5.5"
  },
  "dependencies": {
    "aws-sdk": "^2.1079.0"
  }

設定はこちら。

tsconfig.json

{
  "compilerOptions": {
    "target": "esnext",
    "module": "commonjs",
    "lib": ["esnext"],
    "baseUrl": "./src",
    "outDir": "dist",
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "noFallthroughCasesInSwitch": true,
    "noImplicitOverride": true,
    "noImplicitReturns": true,
    "noPropertyAccessFromIndexSignature": true,
    "esModuleInterop": true
  },
  "include": [
    "src"
  ]
}

tsconfig.typecheck.conf

{
  "extends": "./tsconfig",
  "compilerOptions": {
    "baseUrl": "./",
    "noEmit": true
  },
  "include": [
    "src", "test"
  ]
}

.prettierrc.json

{
  "singleQuote": true
}

jest.config.js

module.exports = {
  testEnvironment: 'node',
  transform: {
    "^.+\\.tsx?$": "esbuild-jest"
  }
};

今回は、ドキュメントインターフェースを使っていくことにします。

ドキュメントインターフェイス - Amazon DynamoDB

APIはこちら。

Class: AWS.DynamoDB.DocumentClient — AWS SDK for JavaScript

使うテーブルですが、2つテーブルを作成することにしました。

$ aws dynamodb create-table \
    --endpoint-url http://localhost:8000 \
    --table-name People \
    --attribute-definitions \
        AttributeName=familyId,AttributeType=N \
        AttributeName=firstName,AttributeType=S \
    --key-schema \
        AttributeName=familyId,KeyType=HASH \
        AttributeName=firstName,KeyType=RANGE \
    --provisioned-throughput \
        ReadCapacityUnits=10,WriteCapacityUnits=5 \
    --table-class STANDARD


$ aws dynamodb create-table \
    --endpoint-url http://localhost:8000 \
    --table-name Books \
    --attribute-definitions \
        AttributeName=isbn,AttributeType=S \
    --key-schema \
        AttributeName=isbn,KeyType=HASH \
    --provisioned-throughput \
        ReadCapacityUnits=10,WriteCapacityUnits=5 \
    --table-class STANDARD

人と書籍ですね。

データはこんな感じで。

Peopleテーブルはサザエさん。この中のfamilyIdをパーティションキーに、firstNameをソートキーにしています。
データは全部で14件あります。

test/people.json

[
  {
    "familyId": 1,
    "lastName": "フグ田",
    "firstName": "サザエ",
    "age": 24
  },
  {
    "familyId": 1,
    "lastName": "フグ田",
    "firstName": "マスオ",
    "age": 28
  },
  {
    "familyId": 1,
    "lastName": "磯野",
    "firstName": "波平",
    "age": 54
  },
  {
    "familyId": 1,
    "lastName": "磯野",
    "firstName": "フネ",
    "age": 50
  },
  {
    "familyId": 1,
    "lastName": "磯野",
    "firstName": "カツオ",
    "age": 11
  },
  {
    "familyId": 1,
    "lastName": "磯野",
    "firstName": "ワカメ",
    "age": 9
  },
  {
    "familyId": 1,
    "lastName": "フグ田",
    "firstName": "タラオ",
    "age": 3
  },
  {
    "familyId": 2,
    "lastName": "波野",
    "firstName": "ノリスケ",
    "age": 26
  },
  {
    "familyId": 2,
    "lastName": "波野",
    "firstName": "タイコ",
    "age": 22
  },
  {
    "familyId": 2,
    "lastName": "波野",
    "firstName": "イクラ",
    "age": 1
  },
  {
    "familyId": 3,
    "lastName": "伊佐坂",
    "firstName": "難物",
    "age": 60
  },
  {
    "familyId": 3,
    "lastName": "伊佐坂",
    "firstName": "お軽",
    "age": 50
  },
  {
    "familyId": 3,
    "lastName": "伊佐坂",
    "firstName": "甚六",
    "age": 20
  },
  {
    "familyId": 3,
    "lastName": "伊佐坂",
    "firstName": "浮江",
    "age": 16
  }
]

次は、書籍。ハッシュキーはisbnで、データは全部で12件です。

test/books.json

[
  {
    "isbn": "978-4815607654",
    "title": "AWSコンテナ設計・構築[本格]入門",
    "price": 3300,
    "publicationDate": "2021-10-21"
  },
  {
    "isbn": "978-4295006657",
    "title": "Amazon Web Servicesインフラサービス活用大全 システム構築/自動化、データストア、高信頼化 (impress top gear)",
    "price": 5060,
    "publicationDate": "2019-09-05"
  },
  {
    "isbn": "978-4797392579",
    "title": "Amazon Web Services パターン別構築・運用ガイド 改訂第2版",
    "price": 3740,
    "publicationDate": "2018-03-23"
  },
  {
    "isbn": "978-4797392562",
    "title": "Amazon Web Services 業務システム設計・移行ガイド",
    "price": 3520,
    "publicationDate": "2018-01-20"
  },
  {
    "isbn": "978-4774176734",
    "title": "Amazon Web Services実践入門 (WEB+DB PRESS plus)",
    "price": 2838,
    "publicationDate": "2015-11-10"
  },
  {
    "isbn": "978-4822277376",
    "title": "Amazon Web Services クラウドデザインパターン設計ガイド 改訂版",
    "price": 2970,
    "publicationDate": "2015-05-28"
  },
  {
    "isbn": "978-4822277369",
    "title": "Amazon Web Services クラウドデザインパターン実装ガイド 改訂版",
    "price": 4180,
    "publicationDate": "2015-03-09"
  },
  {
    "isbn": "978-4822292508",
    "title": "Amazon Web Services 定番業務システム14パターン 設計ガイド",
    "price": 2750,
    "publicationDate": "2018-09-28"
  },
  {
    "isbn": "978-4863543140",
    "title": "基礎から学ぶ サーバーレス開発",
    "price": 3058,
    "publicationDate": "2020-07-22"
  },
  {
    "isbn": "978-4839964566",
    "title": "Amazon Web Servicesを使ったサーバーレスアプリケーション開発ガイド",
    "price": 3300,
    "publicationDate": "2018-03-16"
  },
  {
    "isbn": "978-4297113292",
    "title": "みんなのAWS 〜AWSの基本を最新アーキテクチャでまるごと理解!",
    "price": 2618,
    "publicationDate": "2020-04-17"
  },
  {
    "isbn": "978-4798144696",
    "title": "Amazon Web Servicesではじめる新米プログラマのためのクラウド超入門",
    "price": 3278,
    "publicationDate": "2016-06-16" 
  }
]

つまり、テストデータは計26件あります。トランザクションで扱えるリクエストの個数の上限は25個なので、この確認にも使いたいと
思います。

トランザクションを使ってみる

では、トランザクションを使うプログラムを作成します。

データにマッピングするクラスを作成。

src/person.ts

export class Person {
  familyId: number;
  lastName: string;
  firstName: string;
  age: number;

  constructor(
    familyId: number,
    lastName: string,
    firstName: string,
    age: number
  ) {
    this.familyId = familyId;
    this.lastName = lastName;
    this.firstName = firstName;
    this.age = age;
  }
}

src/book.ts

export class Book {
  isbn: string;
  title: string;
  price: number;
  publicationDate: string;

  constructor(
    isbn: string,
    title: string,
    price: number,
    publicationDate: string
  ) {
    this.isbn = isbn;
    this.title = title;
    this.price = price;
    this.publicationDate = publicationDate;
  }
}

テストコードのimport部分と、DocumentClientの作成。

test/transaction.test.ts

import { DocumentClient } from 'aws-sdk/clients/dynamodb';
import fs from 'fs';
import { Book } from '../src/book';
import { Person } from '../src/person';

const dynamodb = new DocumentClient({
  credentials: {
    accessKeyId: 'fakeMyKeyId',
    secretAccessKey: 'fakeSecretAccessKey',
  },
  region: 'ap-northeast-1',
  endpoint: 'http://localhost:8000',
});

// ここに、テストを書く!

まずは、トランザクションを使って書き込みを行ってみます。

使うのは、DocumentClient#transactWriteメソッドです。

Class: AWS.DynamoDB.DocumentClient / transactWrite

test('write transaction getting started', async () => {
  const people = JSON.parse(
    await fs.promises.readFile(`${__dirname}/people.json`, 'utf8')
  ) as Person[];

  const katsuo = people[4];
  const wakame = people[5];

  const books = JSON.parse(
    await fs.promises.readFile(`${__dirname}/books.json`, 'utf8')
  );

  const book1 = books[0];
  const book2 = books[1];

  const params: DocumentClient.TransactWriteItemsInput = {
    TransactItems: [
      {
        Put: {
          TableName: 'People',
          Item: katsuo,
        },
      },
      {
        Put: {
          TableName: 'People',
          Item: wakame,
        },
      },
      {
        Put: {
          TableName: 'Books',
          Item: book1,
        },
      },
      {
        Put: {
          TableName: 'Books',
          Item: book2,
        },
      },
    ],
  };

  const result = await dynamodb.transactWrite(params).promise();
  expect(result).not.toBeNull();
  expect(result).toEqual({});
});

最初に各テーブル用にデータを2アイテムずつ取ってきて

  const people = JSON.parse(
    await fs.promises.readFile(`${__dirname}/people.json`, 'utf8')
  ) as Person[];

  const katsuo = people[4];
  const wakame = people[5];

  const books = JSON.parse(
    await fs.promises.readFile(`${__dirname}/books.json`, 'utf8')
  );

  const book1 = books[0];
  const book2 = books[1];

これをトランザクションのPut用のリクエストとしてまとめます。

  const params: DocumentClient.TransactWriteItemsInput = {
    TransactItems: [
      {
        Put: {
          TableName: 'People',
          Item: katsuo,
        },
      },
      {
        Put: {
          TableName: 'People',
          Item: wakame,
        },
      },
      {
        Put: {
          TableName: 'Books',
          Item: book1,
        },
      },
      {
        Put: {
          TableName: 'Books',
          Item: book2,
        },
      },
    ],
  };

あとはDocumentClient#transactWriteメソッドで書き込んで終了です。

  const result = await dynamodb.transactWrite(params).promise();
  expect(result).not.toBeNull();
  expect(result).toEqual({});

Put以外に指定できるのは、UPdate、Delete、ConditionCheckです。

次は、たった今書き込んだデータに対して、トランザクションを使って読み込みを行ってみましょう。

使うのはDocumentClient#transactGetメソッドです。

Class: AWS.DynamoDB.DocumentClient / transactGet

test('read transaction, getting started', async () => {
  const params: DocumentClient.TransactGetItemsInput = {
    TransactItems: [
      {
        Get: {
          TableName: 'People',
          Key: {
            familyId: 1,
            firstName: 'カツオ',
          },
        },
      },
      {
        Get: {
          TableName: 'People',
          Key: {
            familyId: 1,
            firstName: 'ワカメ',
          },
        },
      },
      {
        Get: {
          TableName: 'Books',
          Key: {
            isbn: '978-4815607654',
          },
        },
      },
      {
        Get: {
          TableName: 'Books',
          Key: {
            isbn: '978-4295006657',
          },
        },
      },
    ],
  };

  const result = await dynamodb.transactGet(params).promise();

  if (result.Responses) {
    const responses = result.Responses;

    const katsuo = responses[0].Item as Person;
    expect(katsuo.familyId).toBe(1);
    expect(katsuo.lastName).toBe('磯野');
    expect(katsuo.firstName).toBe('カツオ');
    expect(katsuo.age).toBe(11);

    const wakame = responses[1].Item as Person;
    expect(wakame.familyId).toBe(1);
    expect(wakame.lastName).toBe('磯野');
    expect(wakame.firstName).toBe('ワカメ');
    expect(wakame.age).toBe(9);

    const awsContainerBook = responses[2].Item as Book;
    expect(awsContainerBook.isbn).toBe('978-4815607654');
    expect(awsContainerBook.title).toBe('AWSコンテナ設計・構築[本格]入門');
    expect(awsContainerBook.price).toBe(3300);
    expect(awsContainerBook.publicationDate).toBe('2021-10-21');

    const awsInfraBook = responses[3].Item as Book;
    expect(awsInfraBook.isbn).toBe('978-4295006657');
    expect(awsInfraBook.title).toBe(
      'Amazon Web Servicesインフラサービス活用大全 システム構築/自動化、データストア、高信頼化 (impress top gear)'
    );

    expect(awsInfraBook.price).toBe(5060);
    expect(awsInfraBook.publicationDate).toBe('2019-09-05');
  } else {
    throw new Error('test failed');
  }
});

Get用のリクエストをまとめて

  const params: DocumentClient.TransactGetItemsInput = {
    TransactItems: [
      {
        Get: {
          TableName: 'People',
          Key: {
            familyId: 1,
            firstName: 'カツオ',
          },
        },
      },
      {
        Get: {
          TableName: 'People',
          Key: {
            familyId: 1,
            firstName: 'ワカメ',
          },
        },
      },
      {
        Get: {
          TableName: 'Books',
          Key: {
            isbn: '978-4815607654',
          },
        },
      },
      {
        Get: {
          TableName: 'Books',
          Key: {
            isbn: '978-4295006657',
          },
        },
      },
    ],
  };

DocumentClient#transactGetメソッドで実行します。

  const result = await dynamodb.transactGet(params).promise();

結果は、Responsesに配列として入っています。実際の値はItemという要素にマッピングされていますが。

  if (result.Responses) {
    const responses = result.Responses;

    const katsuo = responses[0].Item as Person;
    expect(katsuo.familyId).toBe(1);
    expect(katsuo.lastName).toBe('磯野');
    expect(katsuo.firstName).toBe('カツオ');
    expect(katsuo.age).toBe(11);

    const wakame = responses[1].Item as Person;
    expect(wakame.familyId).toBe(1);
    expect(wakame.lastName).toBe('磯野');
    expect(wakame.firstName).toBe('ワカメ');
    expect(wakame.age).toBe(9);

    const awsContainerBook = responses[2].Item as Book;
    expect(awsContainerBook.isbn).toBe('978-4815607654');
    expect(awsContainerBook.title).toBe('AWSコンテナ設計・構築[本格]入門');
    expect(awsContainerBook.price).toBe(3300);
    expect(awsContainerBook.publicationDate).toBe('2021-10-21');

    const awsInfraBook = responses[3].Item as Book;
    expect(awsInfraBook.isbn).toBe('978-4295006657');
    expect(awsInfraBook.title).toBe(
      'Amazon Web Servicesインフラサービス活用大全 システム構築/自動化、データストア、高信頼化 (impress top gear)'
    );

    expect(awsInfraBook.price).toBe(5060);
    expect(awsInfraBook.publicationDate).toBe('2019-09-05');

DocumentClient#transactGetでは、指定できるのはGetのみです。

この時点で、各テーブルに2アイテムずつあります。

test('current records', async () => {
  const peopleScanParams: DocumentClient.ScanInput = {
    TableName: 'People',
    ConsistentRead: true,
  };

  const peopleScanResult = await dynamodb.scan(peopleScanParams).promise();
  expect(peopleScanResult.Count).toBe(2);

  const booksScanParams: DocumentClient.ScanInput = {
    TableName: 'Books',
    ConsistentRead: true,
  };

  const booksScanResult = await dynamodb.scan(booksScanParams).promise();
  expect(booksScanResult.Count).toBe(2);
});

失敗するケースを試してみる

まずは簡単に試してみたので、次に失敗するようなケースを試してみます。

今回、用意したデータ26件全部Putしてみます。

test('transaction fail (over 25 records)', async () => {
  const people = JSON.parse(
    await fs.promises.readFile(`${__dirname}/people.json`, 'utf8')
  );

  const books = JSON.parse(
    await fs.promises.readFile(`${__dirname}/books.json`, 'utf8')
  );

  const allTransactionItems = people
    .map((p: Person) => ({ Put: { TableName: 'People', Item: p } }))
    .concat(books.map((b: Book) => ({ Put: { TableName: 'Books', Item: b } })));

  const params: DocumentClient.TransactWriteItemsInput = {
    TransactItems: allTransactionItems,
  };

  try {
    await dynamodb.transactWrite(params).promise();
    throw new Error('test failed');
  } catch (e) {
    const error = e as Error;
    expect(error.name).toBe('ValidationException');
    expect(error.message).toBe(
      'Member must have length less than or equal to 25'
    );
  }

  const peopleScanParams: DocumentClient.ScanInput = {
    TableName: 'People',
    ConsistentRead: true,
  };

  const peopleScanResult = await dynamodb.scan(peopleScanParams).promise();
  expect(peopleScanResult.Count).toBe(2);

  const booksScanParams: DocumentClient.ScanInput = {
    TableName: 'Books',
    ConsistentRead: true,
  };

  const booksScanResult = await dynamodb.scan(booksScanParams).promise();
  expect(booksScanResult.Count).toBe(2);
});

Peopleテーブルに14件、Booksテーブルに12件ですね。これをひとつのリクエストにまとめます。

  const people = JSON.parse(
    await fs.promises.readFile(`${__dirname}/people.json`, 'utf8')
  );

  const books = JSON.parse(
    await fs.promises.readFile(`${__dirname}/books.json`, 'utf8')
  );

  const allTransactionItems = people
    .map((p: Person) => ({ Put: { TableName: 'People', Item: p } }))
    .concat(books.map((b: Book) => ({ Put: { TableName: 'Books', Item: b } })));

  const params: DocumentClient.TransactWriteItemsInput = {
    TransactItems: allTransactionItems,
  };

これでDocumentClient#transactWriteを実行すると、25件を超えているということでエラーになります。

  try {
    await dynamodb.transactWrite(params).promise();
    throw new Error('test failed');
  } catch (e) {
    const error = e as Error;
    expect(error.name).toBe('ValidationException');
    expect(error.message).toBe(
      'Member must have length less than or equal to 25'
    );
  }

この時、データが中途半端に入ったりしないところがポイントですね。

  const peopleScanParams: DocumentClient.ScanInput = {
    TableName: 'People',
    ConsistentRead: true,
  };

  const peopleScanResult = await dynamodb.scan(peopleScanParams).promise();
  expect(peopleScanResult.Count).toBe(2);

  const booksScanParams: DocumentClient.ScanInput = {
    TableName: 'Books',
    ConsistentRead: true,
  };

  const booksScanResult = await dynamodb.scan(booksScanParams).promise();
  expect(booksScanResult.Count).toBe(2);

25件で止めておけば、問題なく成功します。

test('limit records(25) write', async () => {
  const people = JSON.parse(
    await fs.promises.readFile(`${__dirname}/people.json`, 'utf8')
  );

  const books = JSON.parse(
    await fs.promises.readFile(`${__dirname}/books.json`, 'utf8')
  );

  const allTransactionItems = people
    .map((p: Person) => ({ Put: { TableName: 'People', Item: p } }))
    .concat(books.map((b: Book) => ({ Put: { TableName: 'Books', Item: b } })));

  const sliced = allTransactionItems.slice(0, 25);

  const params: DocumentClient.TransactWriteItemsInput = {
    TransactItems: sliced,
  };

  await dynamodb.transactWrite(params).promise();

  const peopleScanParams: DocumentClient.ScanInput = {
    TableName: 'People',
    ConsistentRead: true,
  };

  const peopleScanResult = await dynamodb.scan(peopleScanParams).promise();
  expect(peopleScanResult.Count).toBe(14);

  const booksScanParams: DocumentClient.ScanInput = {
    TableName: 'Books',
    ConsistentRead: true,
  };

  const booksScanResult = await dynamodb.scan(booksScanParams).promise();
  expect(booksScanResult.Count).toBe(11);
});

同じアイテムに対して、Put、Updateをかけてみます。

test('same records operation', async () => {
  const params: DocumentClient.TransactWriteItemsInput = {
    TransactItems: [
      {
        Put: {
          TableName: 'Books',
          Item: {
            isbn: '978-4815607654',
            title: 'AWSコンテナ設計・構築[本格]入門',
            price: 3300,
            publicationDate: '2021-10-21',
          },
        },
      },
      {
        Update: {
          TableName: 'Books',
          Key: {
            isbn: '978-4815607654',
          },
          UpdateExpression: 'set price = :price',
          ExpressionAttributeValues: {
            ':price': 6600,
          },
        },
      },
    ],
  };

  try {
    await dynamodb.transactWrite(params).promise();
    throw new Error('test failed');
  } catch (e) {
    const error = e as Error;
    expect(error.name).toBe('ValidationException');
    expect(error.message).toBe(
      'Transaction request cannot include multiple operations on one item'
    );
  }
});

トランザクション内で同じアイテムに対して操作することは許されていないので、失敗します。

  try {
    await dynamodb.transactWrite(params).promise();
    throw new Error('test failed');
  } catch (e) {
    const error = e as Error;
    expect(error.name).toBe('ValidationException');
    expect(error.message).toBe(
      'Transaction request cannot include multiple operations on one item'
    );
  }

最後に、トランザクションを使ってデータを削除しておしまいにします。

test('delete all records', async () => {
  const people = JSON.parse(
    await fs.promises.readFile(`${__dirname}/people.json`, 'utf8')
  ) as Person[];

  const peopleTransactWriteParams: DocumentClient.TransactWriteItemsInput = {
    TransactItems: people.map((p) => ({
      Delete: {
        TableName: 'People',
        Key: {
          familyId: p.familyId,
          firstName: p.firstName,
        },
      },
    })),
  };

  await dynamodb.transactWrite(peopleTransactWriteParams).promise();

  const books = JSON.parse(
    await fs.promises.readFile(`${__dirname}/books.json`, 'utf8')
  ) as Book[];

  const booksTransactWriteParams: DocumentClient.TransactWriteItemsInput = {
    TransactItems: books.map((b) => ({
      Delete: {
        TableName: 'Books',
        Key: {
          isbn: b.isbn,
        },
      },
    })),
  };

  await dynamodb.transactWrite(booksTransactWriteParams).promise();

  const peopleScanParams: DocumentClient.ScanInput = {
    TableName: 'People',
    ConsistentRead: true,
  };

  const peopleScanResult = await dynamodb.scan(peopleScanParams).promise();
  expect(peopleScanResult.Count).toBe(0);

  const booksScanParams: DocumentClient.ScanInput = {
    TableName: 'Books',
    ConsistentRead: true,
  };

  const booksScanResult = await dynamodb.scan(booksScanParams).promise();
  expect(booksScanResult.Count).toBe(0);
});

まとめ

Amazon DynamoDBのトランザクションを簡単に試してみました。

ざっくり言うと、アトミックなバッチ的な処理という感じですね。その他にもいろいろ特徴があるようですが。

なんとなく概要はわかったので、よしとしましょう。