A self-contained pattern for giving each tenant its own independent incrementing
block_number, without global sequences, using an atomic upsert counter table
and a BEFORE INSERT trigger.
CREATE TABLE tenant_block_sequences (
tenant_id uuid NOT NULL,
last_value bigint NOT NULL DEFAULT 0,
updated_at timestamp NOT NULL DEFAULT current_timestamp,
CONSTRAINT tenant_block_sequences_pk PRIMARY KEY (tenant_id)
);
CREATE TABLE transaction_blocks (
id uuid NOT NULL,
tenant_id uuid NOT NULL,
block_number bigint,
block_payload jsonb,
created_at timestamp NOT NULL DEFAULT current_timestamp,
last_updated_at timestamp NOT NULL DEFAULT current_timestamp,
CONSTRAINT transaction_blocks_pk PRIMARY KEY (id),
CONSTRAINT transaction_blocks_tenant_block_number_unq UNIQUE (tenant_id, block_number)
);
CREATE OR REPLACE FUNCTION next_tenant_block_number(p_tenant_id uuid)
RETURNS bigint AS $$
DECLARE
next_value bigint;
BEGIN
IF p_tenant_id IS NULL THEN
RAISE EXCEPTION 'tenant_id is required to allocate a tenant block number';
END IF;
INSERT INTO tenant_block_sequences (tenant_id, last_value)
VALUES (p_tenant_id, 1)
ON CONFLICT (tenant_id)
DO UPDATE SET
last_value = tenant_block_sequences.last_value + 1,
updated_at = current_timestamp
RETURNING last_value INTO next_value;
RETURN next_value;
END;
$$ LANGUAGE plpgsql;
CREATE OR REPLACE FUNCTION assign_transaction_block_number()
RETURNS trigger AS $$
BEGIN
IF NEW.tenant_id IS NULL THEN
RAISE EXCEPTION 'tenant_id is required to allocate a tenant block number';
END IF;
IF NEW.block_number IS NULL THEN
NEW.block_number = next_tenant_block_number(NEW.tenant_id);
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER transaction_blocks_assign_block_number
BEFORE INSERT ON transaction_blocks
FOR EACH ROW
EXECUTE FUNCTION assign_transaction_block_number();DROP TRIGGER IF EXISTS transaction_blocks_assign_block_number ON transaction_blocks;
DROP FUNCTION IF EXISTS assign_transaction_block_number();
DROP FUNCTION IF EXISTS next_tenant_block_number(uuid);
DROP TABLE IF EXISTS transaction_blocks;
DROP TABLE IF EXISTS tenant_block_sequences;tenant_block_sequencesholds one row per tenant. Each insert atomically incrementslast_valueusingON CONFLICT DO UPDATE, guaranteeing no duplicate numbers under concurrent load.next_tenant_block_number(uuid)is the allocation function — safe to call directly if you need to reserve a number before inserting a block.assign_transaction_block_numberis aBEFORE INSERTtrigger that calls the function automatically, so application code never needs to manage the counter.- If you supply an explicit
block_numberon insert, the trigger leaves it unchanged; the unique constraint still protects against duplicates.
This document shows how to query and write data for:
tenant_block_sequencestransaction_blocks
The goal is to have an independent incremental block_number per tenant_id.
block_number is assigned by trigger transaction_blocks_assign_block_number.
INSERT INTO transaction_blocks (
id,
tenant_id,
block_payload
)
VALUES (
'11111111-1111-1111-1111-111111111111',
'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
'{"tx":"payment","amount":1250}'::jsonb
)
RETURNING id, tenant_id, block_number, created_at;Each insert increments tenant-local sequence by 1.
INSERT INTO transaction_blocks (id, tenant_id, block_payload)
VALUES
('11111111-1111-1111-1111-111111111112', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', '{"tx":"invoice"}'::jsonb),
('11111111-1111-1111-1111-111111111113', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', '{"tx":"refund"}'::jsonb)
RETURNING tenant_id, block_number;SELECT
id,
tenant_id,
block_number,
block_payload,
created_at
FROM transaction_blocks
WHERE tenant_id = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'
ORDER BY block_number ASC;SELECT
id,
block_number,
block_payload,
created_at
FROM transaction_blocks
WHERE tenant_id = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'
ORDER BY block_number DESC
LIMIT 1;Useful if the application needs the number first.
SELECT next_tenant_block_number('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa') AS reserved_block_number;If you provide block_number, trigger leaves it unchanged. Unique constraint still applies.
INSERT INTO transaction_blocks (
id,
tenant_id,
block_number,
block_payload
)
VALUES (
'11111111-1111-1111-1111-111111111120',
'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
9001,
'{"tx":"manual-replay"}'::jsonb
);INSERT INTO transaction_blocks (
id,
tenant_id,
block_number,
block_payload,
last_updated_at
)
VALUES (
'11111111-1111-1111-1111-111111111130',
'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
42,
'{"tx":"settlement"}'::jsonb,
current_timestamp
)
ON CONFLICT (tenant_id, block_number)
DO UPDATE SET
block_payload = EXCLUDED.block_payload,
last_updated_at = current_timestamp
RETURNING id, tenant_id, block_number, last_updated_at;SELECT
tenant_id,
last_value,
updated_at
FROM tenant_block_sequences
ORDER BY updated_at DESC;Quick consistency check.
SELECT
s.tenant_id,
s.last_value AS sequence_last_value,
COALESCE(MAX(b.block_number), 0) AS highest_block_number
FROM tenant_block_sequences s
LEFT JOIN transaction_blocks b
ON b.tenant_id = s.tenant_id
GROUP BY s.tenant_id, s.last_value
ORDER BY s.tenant_id;Typical write in a transaction block.
BEGIN;
INSERT INTO transaction_blocks (
id,
tenant_id,
block_payload
)
VALUES (
gen_random_uuid(),
'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
'{"tx":"batch-posting"}'::jsonb
)
RETURNING id, tenant_id, block_number;
COMMIT;- Sequence is scoped by
tenant_id, not global. - Allocation is atomic because of
INSERT ... ON CONFLICT DO UPDATE. - If you use explicit
block_number, you are responsible for avoiding conflicts.