Autogenerated sequences and PRIMARY KEY values in CrateDB

As you begin working with CrateDB, you might be puzzled why CrateDB does not have a built-in, auto-incrementing “serial” data type as PostgreSQL or MySQL.

As a distributed database, designed to scale horizontally, CrateDB needs as many operations as possible to complete independently on each node without any coordination between nodes.

Maintaining a global auto-increment value requires that a node checks with other nodes before allocating a new value. This bottleneck would be hindering our ability to achieve extremely fast ingestion speeds.

That said, there are many alternatives available and we can also implement true consistent/synchronized sequences if we want to.

Using a timestamp as a primary key

This option involves declaring a column as follows:

BIGINT DEFAULT now() PRIMARY KEY
Pros:

Always increasing number - ideal if we need to timestamp records creation anyway

Cons:

gaps between the numbers, not suitable if we may have more than one record on the same millisecond

Using UUIDs

This option involves declaring a column as follows:

TEXT DEFAULT gen_random_text_uuid() PRIMARY KEY
Pros:

Globally unique, no risk of conflicts if merging things from different tables/environments

Cons:

No order guarantee. Not as human-friendly as numbers. String format may not be applicable to cover all scenarios. Range queries are not possible.

Use UUIDv7 identifiers

Version 7 UUIDs are a relatively new kind of UUIDs which feature a time-ordered value. We can use these in CrateDB with an UDF with the code from UUIDv7 in N languages.

Pros:

Same as gen_random_text_uuid above but almost sequential, which enables range queries.

Cons:

not as human-friendly as numbers and slight performance impact from UDF use

Use IDs from an external system

In cases where data is imported into CrateDB from external systems that employ identifier governance, CrateDB does not need to generate any identifier values and primary key values can be inserted as-is from the source system.

See Replicating data from other databases to CrateDB with Debezium and Kafka for an example.

Implement sequences

This approach involves a table to keep the latest values that have been consumed and client side code to keep it up-to-date in a way that guarantees unique values even when many ingestion processes run in parallel.

Pros:

Can have any arbitrary type of sequences, (we may for instance want to increment values by 10 instead of 1 - prefix values with a year number - combine numbers and letters - etc)

Cons:

Need logic for the optimistic update implemented client-side, the sequences table becomes a bottleneck so not suitable for high-velocity ingestion scenarios

We will first create a table to keep the latest values for our sequences:

CREATE TABLE sequences (
        name TEXT PRIMARY KEY,
        last_value BIGINT
) CLUSTERED INTO 1 SHARDS;

We will then initialize it with one new sequence at 0:

INSERT INTO sequences (name,last_value)
VALUES ('mysequence',0);

And we are going to do an example with a new table defined as follows:

CREATE TABLE mytable (
        id BIGINT PRIMARY KEY,
        field1 TEXT
);

The Python code below reads the last value used from the sequences table, and then attempts an optimistic UPDATE with a RETURNING clause, if a contending process already consumed the identity nothing will be returned so our process will retry until a value is returned, then it uses that value as the new ID for the record we are inserting into the mytable table.

# /// script
# requires-python = ">=3.8"
# dependencies = [
#     "records",
#     "sqlalchemy-cratedb",
# ]
# ///

import time

import records

db = records.Database("crate://")
sequence_name = "mysequence"

max_retries = 5
base_delay = 0.1  # 100 milliseconds

for attempt in range(max_retries):
        select_query = """
   SELECT last_value,
                   _seq_no,
                   _primary_term
   FROM sequences
   WHERE name = :sequence_name;
   """
        row = db.query(select_query, sequence_name=sequence_name).first()
        new_value = row.last_value + 1

        update_query = """
                                           UPDATE sequences
                                           SET last_value = :new_value
                                           WHERE name = :sequence_name
                                                 AND _seq_no = :seq_no
                                                 AND _primary_term = :primary_term
                                           RETURNING last_value;
                           """
        if (
                str(
                        db.query(
                                update_query,
                                new_value=new_value,
                                sequence_name=sequence_name,
                                seq_no=row._seq_no,
                                primary_term=row._primary_term,
                        ).all()
                )
                != "[]"
        ):
                break

        delay = base_delay * (2**attempt)
        print(f"Attempt {attempt + 1} failed. Retrying in {delay:.1f} seconds...")
        time.sleep(delay)
else:
        raise Exception(f"Failed after {max_retries} retries with exponential backoff")

insert_query = "INSERT INTO mytable (id, field1) VALUES (:id, :field1)"
db.query(insert_query, id=new_value, field1="abc")
db.close()