Guide · AWS AppSync · Conflict Detection
AppSync Conflict Detection for MCP Servers — VERSION Strategy, Optimistic Concurrency, and Lambda Resolution
AppSync conflict detection prevents lost updates when two clients mutate the same DynamoDB item concurrently — a realistic scenario when multiple agent processes update shared MCP server configuration records. AppSync implements conflict detection only for DynamoDB data sources and uses the _version field as the concurrency control token. When conflict detection is enabled, every DynamoDB mutation generated by AppSync includes a conditional expression that requires the item's current _version to match the value the client sent — a version mismatch means another writer already updated the item, and AppSync invokes the configured conflict handler to decide what to do. Three handlers are available: OPTIMISTIC_CONCURRENCY (reject the mutation with a conflict error — client must re-read and retry), AUTOMERGE (AppSync merges non-conflicting fields automatically using DynamoDB Amplify DataStore semantics — only suitable for Amplify-generated schemas), and LAMBDA (your Lambda receives the conflicting versions and returns the resolved item — full control for MCP config records with custom merge logic). The version field is auto-incremented by AppSync on every successful write — the client does NOT control the version value, only passes it back as a pre-condition.
TL;DR
Enable conflict detection on the DynamoDB data source (CDK: ConflictDetection.VERSION). AppSync adds _version, _lastChangedAt, and _deleted fields to every item it writes. Mutation inputs must include the current _version value (read from a prior query). If the version doesn't match, AppSync calls the conflict handler: OPTIMISTIC_CONCURRENCY returns a ConflictUnhandled error; LAMBDA receives both versions and returns the merged item (or null to reject). AUTOMERGE is designed for Amplify DataStore and not appropriate for hand-written MCP schemas. Clear _deleted and _version from client-visible GraphQL types using @deprecated or a transform layer to avoid leaking internal fields.
How _version, _lastChangedAt, and _deleted work
When conflict detection is enabled, AppSync manages three metadata fields on every DynamoDB item. These are implementation details of AppSync's conflict resolution, not domain fields — they should be included in your DynamoDB schema but typically hidden from the GraphQL schema or marked as system fields.
# GraphQL schema with conflict detection fields exposed
# (needed if clients need to read _version for mutation inputs)
type McpServerConfig {
serverId: ID!
alertWebhookUrl: String
alertThresholdMs: Int
slackChannel: String
# Conflict detection fields — clients must include _version in mutation inputs
_version: Int!
_lastChangedAt: AWSTimestamp!
_deleted: Boolean
}
input UpdateServerConfigInput {
serverId: ID!
alertWebhookUrl: String
alertThresholdMs: Int
slackChannel: String
# Client must pass the _version value it received from the last read
# AppSync checks: DynamoDB item's _version == input._version (else conflict)
_version: Int!
}
type Mutation {
updateServerConfig(input: UpdateServerConfigInput!): McpServerConfig
@aws_cognito_user_pools
}
# The _version value AppSync stores in DynamoDB after a successful write:
# new_version = old_version + 1
# _lastChangedAt = current epoch millis
# _deleted = false (or true for delete mutations)
When AppSync detects a conflict (the item's current _version in DynamoDB doesn't match the client's _version), it passes both the client's proposed item (newItem) and the current DynamoDB item (existingItem) to the conflict handler. The client's newItem contains the mutation input fields; the existingItem is the latest item from DynamoDB at the time of the conflict check.
OPTIMISTIC_CONCURRENCY: reject and let the client retry
OPTIMISTIC_CONCURRENCY is the simplest conflict handler: on version mismatch, AppSync rejects the mutation with a ConflictUnhandled error. The client must re-fetch the item to get the current _version, apply its intended changes on top of the latest state, and retry. This is appropriate for MCP server alert configuration where two simultaneous updates are uncommon and the cost of a retry is acceptable.
// Client-side retry pattern for OPTIMISTIC_CONCURRENCY
// Using Amplify GraphQL client (handles ConflictUnhandled automatically)
async function updateAlertConfig(serverId, changes) {
let retries = 0;
const maxRetries = 3;
while (retries < maxRetries) {
try {
// Step 1: Read current version
const current = await appsyncQuery(`
query GetConfig($serverId: ID!) {
getServerConfig(serverId: $serverId) {
serverId alertWebhookUrl alertThresholdMs slackChannel _version
}
}
`, { serverId });
const currentVersion = current.data.getServerConfig._version;
// Step 2: Apply changes and submit with current version
const result = await appsyncMutation(`
mutation UpdateConfig($input: UpdateServerConfigInput!) {
updateServerConfig(input: $input) {
serverId alertWebhookUrl alertThresholdMs _version
}
}
`, {
input: {
serverId,
...changes,
_version: currentVersion // must match DynamoDB's current _version
}
});
return result.data.updateServerConfig;
} catch (err) {
// AppSync returns ConflictUnhandled when _version mismatches
if (err.errors?.[0]?.errorType === 'ConflictUnhandled') {
retries++;
await sleep(100 * retries); // brief backoff before re-read
continue;
}
throw err; // non-conflict error — don't retry
}
}
throw new Error(`Failed to update after ${maxRetries} retries — too many concurrent writers`);
}
LAMBDA conflict handler: custom merge logic for MCP config records
When MCP server configuration fields are owned by different subsystems (one process updates alert thresholds, another updates webhook URLs), a Lambda conflict handler can merge the changes field-by-field rather than rejecting one writer. The Lambda receives both versions and returns the merged item — or null to reject the mutation (equivalent to OPTIMISTIC_CONCURRENCY behavior).
// Lambda conflict handler for AppSync
// Invoked when DynamoDB write fails due to _version mismatch
export const handler = async (event) => {
// event shape:
// {
// typeName: "McpServerConfig",
// fieldName: "updateServerConfig",
// operation: "UPDATE",
// newItem: { // what the client tried to write (merged with current values)
// serverId: "srv-123",
// alertWebhookUrl: "https://hooks.example.com/new",
// alertThresholdMs: 5000,
// slackChannel: "#mcp-alerts", // from client
// _version: 7 // client's version (now stale)
// },
// existingItem: { // the actual current DynamoDB item
// serverId: "srv-123",
// alertWebhookUrl: "https://hooks.example.com/old",
// alertThresholdMs: 3000,
// slackChannel: "#mcp-alerts-v2", // already updated by another writer
// _version: 8 // current version in DynamoDB
// }
// }
const { newItem, existingItem } = event;
// Field-level last-writer-wins merge:
// For non-null fields in newItem, take the new value
// For null fields in newItem, keep the existing value (this write didn't intend to change it)
const merged = {
...existingItem,
// Only overwrite fields the client explicitly set (non-null in newItem)
...(newItem.alertWebhookUrl != null && { alertWebhookUrl: newItem.alertWebhookUrl }),
...(newItem.alertThresholdMs != null && { alertThresholdMs: newItem.alertThresholdMs }),
...(newItem.slackChannel != null && { slackChannel: newItem.slackChannel }),
// DO NOT set _version — AppSync manages this
};
// Return merged item to accept with merged values
// AppSync will write this to DynamoDB and increment _version
return merged;
// Return null to reject (same as OPTIMISTIC_CONCURRENCY)
// return null;
};
// CDK: Lambda conflict handler on DynamoDB data source
const conflictLambda = new lambda.Function(this, 'ConflictHandler', {
runtime: lambda.Runtime.NODEJS_20_X,
handler: 'index.handler',
code: lambda.Code.fromAsset('lambda/conflict-handler')
});
const configTableDs = new appsync.DynamoDbDataSource(this, 'ConfigTableDs', {
api,
table: serverConfigTable,
serviceRole: configTableRole
});
// Enable conflict detection with Lambda handler on the data source
// (CDK: use L1 CfnDataSource for conflict handler configuration)
const cfnDs = configTableDs.node.defaultChild as appsync.CfnDataSource;
cfnDs.dynamoDbConfig = {
tableName: serverConfigTable.tableName,
awsRegion: this.region,
conflictDetection: 'VERSION',
conflictHandler: 'LAMBDA',
lambdaConflictHandlerArn: conflictLambda.functionArn
};
_version TTL cleanup and deleted items
When AppSync soft-deletes an item (via a delete mutation with conflict detection enabled), it sets _deleted: true on the item rather than removing it from DynamoDB. This allows clients using Amplify DataStore to detect deletions via delta sync. For hand-written MCP schemas where you don't use DataStore, these _deleted items accumulate permanently unless you add a DynamoDB TTL attribute to expire them.
// DynamoDB TTL on _deleted items — CDK setup
import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
const serverConfigTable = new dynamodb.Table(this, 'ServerConfigTable', {
partitionKey: { name: 'serverId', type: dynamodb.AttributeType.STRING },
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
// TTL attribute for soft-deleted items
timeToLiveAttribute: '_ttl'
});
// In the conflict handler or a DynamoDB Streams Lambda,
// set _ttl = current_epoch + 7 days when _deleted = true
// DynamoDB will expire and remove the item after that timestamp
// Alternatively, filter _deleted items in your GraphQL resolver:
// export function response(ctx) {
// if (ctx.result._deleted) return null; // treat as not found
// return ctx.result;
// }
// For list queries, filter _deleted items in the DynamoDB scan/query filter:
// FilterExpression: "attribute_not_exists(#d) OR #d = :false"
// ExpressionAttributeNames: { "#d": "_deleted" }
// ExpressionAttributeValues: { ":false": { BOOL: false } }
Failure modes reference
| Failure | Symptom | Fix |
|---|---|---|
Client omits _version from mutation input | AppSync writes without version check — conflict detection silently disabled for that call | Make _version required in the mutation input type (_version: Int!); validate in the resolver before executing the DynamoDB operation |
| Lambda conflict handler returns undefined | AppSync treats undefined as rejection — mutation fails with internal error | Return null explicitly to reject; return the merged item object to accept — never return undefined |
| AUTOMERGE on non-Amplify schema | Automerge silently discards fields that don't match Amplify DataStore type conventions | Use OPTIMISTIC_CONCURRENCY or LAMBDA for hand-written schemas; AUTOMERGE is only reliable with @model Amplify-generated types |
| Conflict handler Lambda not granted IAM invoke permission | Conflict resolution fails with access denied; all conflicting mutations return error | Grant lambda:InvokeFunction to the AppSync service role for the conflict handler Lambda ARN; check the DynamoDB data source's execution role |
_deleted items returned in list queries | Deleted server configs appear in results; clients see "ghost" entries | Add FilterExpression to exclude _deleted = true items in DynamoDB query resolvers; or filter in the resolver response handler |
| Version counter overflow (very long-lived items with many writes) | Theoretical: _version exceeds Int max (2,147,483,647) | Use Long / AWSTimestamp for _version if the item will receive >2B writes; in practice, reset _version by recreating the item for stable server configs |