Integrationsstorage
Amazon DynamoDB storage
Keep Mayura's workflows, jobs, memory, budgets and API keys in one Amazon DynamoDB table with @mayurajs/storage-dynamodb.
- Package
- @mayurajs/storage-dynamodb
- Install
- npm install mayura @mayurajs/storage-dynamodb
- Vendor
- Amazon Web Services ↗
Amazon DynamoDB is AWS's serverless key-value and document database.
@mayurajs/storage-dynamodb is a Mayura store in one DynamoDB table. It holds everything the SQL
stores hold: run records and their events (the server's submission journal and run records), durable workflows with
their graphs, trees, waits and discovery, the leased job scheduler, durable budgets, native memory and
API keys. It talks to DynamoDB through the official
@aws-sdk/client-dynamodb, pinned at 3.1141.0, its only dependency, and sends the SDK's requests with fetch rather
than Node's HTTP stack.
Install
npm install mayura @mayurajs/storage-dynamodbCredentials
Create an IAM user or role for the store in the IAM console, with an access key, or give it temporary credentials from your own STS call. The store uses these DynamoDB actions on its table:
dynamodb:DescribeTable, atinitialize();dynamodb:GetItem,dynamodb:BatchGetItemanddynamodb:Queryto read (strongly consistent);dynamodb:PutItem,dynamodb:UpdateItem,dynamodb:DeleteItemanddynamodb:ConditionCheckItem, which aTransactWriteItemscommit needs;dynamodb:CreateTable, only withcreateTable: true.
Nothing is read from the environment, AWS config files or instance metadata: pass the region and credentials yourself,
for example from AWS_REGION, AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY, and the table name from
DYNAMODB_TABLE.
Use it
import { createNativeMemory } from 'mayura/memory';
import { createDynamoStore } from '@mayurajs/storage-dynamodb';
const store = createDynamoStore({
table: process.env.DYNAMODB_TABLE ?? 'mayura',
region: process.env.AWS_REGION ?? 'us-east-1',
credentials: { accessKeyId: process.env.AWS_ACCESS_KEY_ID ?? '', secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY ?? '' },
});
await store.initialize(); // checks the table's key schema
await store.memory.initialize();
const memory = createNativeMemory({
store,
scope: { principalId: 'customer-42', projectId: 'support' },
permissions: { allow: ['memory:read', 'memory:write'] },
});
// Hand `store` to workflow runtimes, durable budgets, the key manager and your server too.
await store.close();credentials may also be a function that returns fresh ones, called when the SDK needs them:
import { createDynamoStore } from '@mayurajs/storage-dynamodb';
const store = createDynamoStore({
table: 'mayura',
region: 'eu-west-1',
credentials: async () => ({ accessKeyId: 'ASIA...', secretAccessKey: '...', sessionToken: '...', expiration: new Date(Date.now() + 3_600_000) }),
});To use a DynamoDBClient you configure yourself, pass it as client instead of region and credentials. The store
never destroys a client you gave it.
Options
| Option | Default | Meaning |
|---|---|---|
table |
required | The table: 3 to 255 letters, digits, _, - and ., with string partition key p and string sort key s. |
region |
required, or client |
The AWS region, such as us-east-1. |
credentials |
required, or client |
{ accessKeyId, secretAccessKey, sessionToken? }, or a function returning them. |
endpoint |
https://dynamodb.<region>.amazonaws.com |
Another endpoint, such as a VPC endpoint: https only, or http on a loopback address (DynamoDB Local). No credentials, query or fragment. |
fetch |
globalThis.fetch |
The fetch that sends requests, for tests and proxies. |
client |
none | A DynamoDBClient you create and own, instead of region, credentials, endpoint and fetch. |
createTable |
false |
Create the table with on-demand capacity when it does not exist. Creating tables is a grant you give deliberately. |
retryForMs |
30,000 | How long a transaction keeps running again after conflicts before it fails, from 0 to 600,000 ms. |
The store's own client signs with SigV4, makes up to 3 attempts per request in the SDK's standard retry mode, and
uses neither FIPS nor dual-stack endpoints. Any other option fails with INVALID_INPUT when you call
createDynamoStore().
Setup and migrations
initialize() checks that the table has a string partition key p and a string sort key s. When the table does not
exist, it fails unless you passed createTable: true; then it creates the table with on-demand billing and waits up to
a minute for it to become active. Every document lives in that one table, so each feature's own initialize() only
marks it ready. See Migrations for how layout changes ship.
Transactions and concurrency
DynamoDB has no interactive transactions. The store runs Mayura's state machines as optimistic transactions:
- A transaction reads with strongly consistent reads and holds its writes.
- It commits one
TransactWriteItemsin which every action expects the version its transaction saw, so it applies whole or not at all. - If a condition fails or DynamoDB reports a transaction conflict, the transaction runs again with fresh reads after a
short random pause, for up to
retryForMs. Two writers of one record never both succeed. - DynamoDB has no clock to read, so leases and deadlines use your hosts' clocks: keep them synchronized.
Under heavy contention on one record, transactions run again rather than wait; different records never contend. Every call is a round trip, so run your service in the table's region. Memory search scans a scope's term index and ranks it in your process, so it grows with the number of records in the scope. See D1 and DynamoDB.
Limits
- An item is at most 400 KB, so a document larger than 350,000 bytes is split into chunk items stored beside it and read back in one snapshot.
- One transaction writes at most 100 items, chunk items included. An operation that needs more fails whole with
LIMIT_EXCEEDEDinstead of partly applying. The largest operations, such as a graph that waits on 128 different runs, can reach that limit.
Errors
Failures reach you as a StorageError with a storageCode; messages never contain the SDK's text or your credentials.
See Errors for every code.
storageCode |
When |
|---|---|
INVALID_INPUT |
An option is missing, unknown or malformed, such as a region that is not one, an http:// endpoint to another host, or both client and credentials; or the table's key schema is not p and s. |
CONFLICT |
A record changed after it was read, or an id or idempotency key already holds other content. |
LIMIT_EXCEEDED |
One operation would write more than 100 items in a transaction. |
STORAGE_UNAVAILABLE |
The table does not exist (and createTable is off) or did not become active, concurrent writers kept changing the same records for longer than retryForMs, or DynamoDB failed. |
STORE_NOT_INITIALIZED |
A call came before await store.initialize(). |
STORE_CLOSED |
A call came after close(). |
How it is tested
- Unit tests run the store against a fake DynamoDB that records every request: it signs with the credentials and region
given, sends to the endpoint given, accepts http only on a loopback address, reports a failing service without its
text, and reads nothing from
AWS_*environment variables. - In CI, against DynamoDB Local in a container (
MAYURA_TEST_DYNAMODB_URL), each fixture in a table of its own: every storage, memory and workflow conformance suite the SQL stores pass, including crash tests that kill a process just before or after a commit. That is records, the key manager, identity integrity, scheduled bounds, memory and native memory, workflows, the scheduler, scheduled workflows, graphs, graph discovery and coordination, execution waits, durable budgets and workflow trees. - It has not been run against a hosted AWS account yet.
Related
- Storage: how D1 and DynamoDB run optimistic transactions.
- Cloudflare D1: the same document store on D1.
- Files: files in S3 with the same kind of explicit credentials.
- Durable workflows: what runs on the store.