If you’ve been following the Aurora DSQL story (Marc Bowes’ Circle of Life, Marc Brooker’s Inside DSQL Writes, the DSQL paper), you know the basics of what DSQL offers. DSQL’s query processors execute transactions independently, adjudicators perform optimistic concurrency control at commit time, and committed transactions flow through the journal to storage.

This architecture gives DSQL its performance characteristics and reduces the burden on developers to manage their database, but it also raises a question: how do you enforce referential integrity in a system with no locks? Previously, we recommended that customers write these referential integrity checks themselves. As of August 27th, DSQL developers can now use foreign key constraints to maintain referential integrity. In this post I walk through how we implement this in our shared-nothing, highly scalable architecture.

Snapshot verification

Every transaction in DSQL runs against a consistent snapshot of the database taken at its start time. When you insert or delete a row, DSQL reads the referenced row(s) at your transaction’s consistent snapshot time to confirm referential integrity is not violated by the modification. In the schema below, inserting into orders triggers an existence check against the transaction’s snapshot.

CREATE TABLE products (
    product_id integer PRIMARY KEY,
    name text,
    price numeric
);

CREATE TABLE orders (
    order_id integer PRIMARY KEY,
    product_id integer REFERENCES products,
    quantity integer
);

INSERT INTO products VALUES (1, 'Widget', 9.99);

-- Implicitly read `products` at the transaction's snapshot time
-- to confirm product_id = 1 exists.
INSERT INTO orders VALUES (100, 1, 5);

This is fast and lock-free, but doesn’t answer the question of what happens when a concurrent transaction is modifying that row. Between your transaction’s start time and commit time, another transaction could delete the referenced row you depend on, or insert a reference to a row that you’re about to remove.

Adjudication

DSQL’s adjudicators can express existence dependencies, which is how we ensure that foreign key constraints in DSQL properly maintain referential integrity. When a transaction inserts a referencing row, DSQL implicitly marks a KEY SHARE dependency on the referenced row. At commit time, the adjudicator checks if any concurrent transaction deleted that referenced row or modified its key columns between t-start and t-commit. If so, the adjudicator rejects the transaction with a serialization error. Let’s look at how this works in practice with two examples:

Conflict: concurrent delete and insert

-- Session A
BEGIN;
DELETE FROM products WHERE product_id = 1;

-- Session B
BEGIN;
INSERT INTO orders VALUES (100, 1, 5);

-- Session A
COMMIT;  -- succeeds

-- Session B
COMMIT;  -- fails: OC000 serialization error

The adjudicator detects the conflict correctly: Session B depends on product_id 1’s existence, and fails to commit since Session A deleted that product.

No conflict: non-key column update

-- Session A
BEGIN;
UPDATE products SET name = 'Super Widget' WHERE product_id = 1;

-- Session B
BEGIN;
INSERT INTO orders VALUES (101, 1, 3);

-- Session A
COMMIT;  -- succeeds

-- Session B
COMMIT;  -- succeeds

Session A updated name, which is a non-key column, while Session B referenced product_id, a key column. The adjudicator sees no conflict and both of the transactions commit successfully.

How this maps to PostgreSQL’s behavior

If you’re familiar with PostgreSQL’s row-level locking, you’ll recognize the concepts above. PostgreSQL defines four row-level lock modes: FOR UPDATE, FOR NO KEY UPDATE, FOR SHARE, and FOR KEY SHARE, from most to least restrictive. In the context of foreign keys, the critical modes are the KEY SHARE and NO KEY UPDATE modes, as they are what allow concurrent existence checks and non-key updates to referenced rows to execute without conflicts. DSQL only exposes FOR UPDATE and FOR KEY SHARE as explicit clauses on SELECTs, but DML operations implicitly use the NO KEY UPDATE mechanism for non-key-column updates. DSQL respects this conflict matrix in its optimistic concurrency control protocol, allowing adjudication to properly identify conflicts between existence checks and other DML.

To cascade or not to cascade

DSQL supports all referential action types defined in the SQL standard: NO ACTION, RESTRICT, CASCADE, SET NULL, and SET DEFAULT. When you define a foreign key constraint with ON DELETE CASCADE, deleting a referenced row automatically deletes all referencing rows. With SET NULL or SET DEFAULT, the referencing rows’ foreign key columns are set to null or their default value instead.

Consider a products table with a million orders referencing a single popular product. If we want to get rid of that product, it would cause the executing transaction to scan the orders table and delete a million rows. In DSQL, this would fail because every transaction is subject to a modification limit of 3,000 rows.

DSQL’s architecture is optimized for transactions that do bounded amounts of work. Our recommendation is to perform cascading actions bottom-up. Instead of relying on ON DELETE CASCADE, delete the referencing rows first, then the referenced row:

-- Use a CTE with a LIMIT clause to stay within the row limit
WITH batch AS (
    SELECT order_id FROM orders WHERE product_id = 1 LIMIT 3000
)
DELETE FROM orders WHERE order_id IN (SELECT order_id FROM batch);

-- Repeat until no referencing rows remain, then delete the referenced row:
DELETE FROM products WHERE product_id = 1;

This approach keeps each transaction within DSQL’s limits and makes the impact of the operation visible in your application rather than being hidden by a constraint’s action type.

Wrapping up

Foreign key constraints in DSQL leverage the adjudicator’s ability to express existence dependencies without introducing pessimistic locking. The same operations that would conflict under PostgreSQL’s row-level locking semantics conflict with DSQL’s optimistic concurrency control.

For more information on how foreign key constraints work in DSQL, check out the AWS documentation on foreign key constraints and concurrency control in Aurora DSQL. For an interactive view of how DSQL transactions work, check out Marc Brooker’s tool here.