skip to Main Content

Say we have this table:

create table if not exists template (
       id serial primary key, 
       label text not null, 
       version integer not null default 1,
       created_at timestamp not null default current_timestamp,
       unique(label, version)
);

The logic is to insert new record, incrementing version value in case of the equal label value. First intention is to do something like this:

with v as (
  select coalesce(max(version), 0) + 1 as new_version 
  from template t where label = 'label1'
)
insert into template (label, version) 
values ('label1', (select new_version from v))
returning *;

Although it works, I’m pretty sure it wouldn’t be safe in case of the simultaneous inserts. Am I right?

If I am, should I wrap this query in a transaction?
Or is there a better way to implement this kind of versioning?

2

Answers


  1. Yes, there could be collisions with simultaneous inserts.

    Transactions could lead to locks, as you want to keep the state if the table till the insert occurs with the new version number. appently, postgres would create a deadlock with multiple concurrent inserts.

    You can use a before i8nsert trigger , which would guarantee, that every insert gets a higher version number, as it would go row by row.

    But you have to remember, that cpus and the sql server can rearrange computation order, so that the rule first come first serve, may not be applied.

    Login or Signup to reply.
  2. Gap-less serial IDs per label are hard to come by. Your simple approach can easily fail with concurrent writes due to inherent race conditions. And "value-locking" is not generally implemented in Postgres.

    But there is a way. Introduce a parent table label – if you don’t already have one – and take a lock on the parent row. This keeps locking to a minimum and should avoid excessive costs from lock contention.

    CREATE TABLE label (
      label text PRIMARY KEY
    );
    
    CREATE TABLE version (
      id           serial PRIMARY KEY
    , label        text NOT NULL REFERENCES label
    , version      integer NOT NULL DEFAULT 1
    , created_at   timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP
    , UNIQUE(label, version)
    );
    

    Then, in a single transaction:

    BEGIN;
    
    INSERT INTO label (label)
    VALUES ('label1')
    ON     CONFLICT (label) DO UPDATE
    SET    label = NULL WHERE false;  -- never executed, but still locks the row
    RETURNING *;                      -- optional
    
    INSERT INTO version (label, version)
    SELECT 'label1', coalesce(max(v.version), 0) + 1
    FROM   version v
    WHERE  v.label = 'label1'
    RETURNING *;
    
    COMMIT;
    

    The first UPSERT inserts a new label if it’s not there, yet, or locks the row if it is. Either way, the transaction now holds a lock on that label, excluding concurrent writes.

    The second INSERT adds a new version, or the first one if there are none, yet.

    You could also move the UPSERT into a CTE attached to the INSERT, thus making it a single command and hence always a single transaction implicitly. But the CTE is not needed per se.

    This is safe under concurrent write load and works for all corner cases. You just have to make sure that all possibly competing write access takes the same route.

    You might wrap this into a function. This …

    • … ensures a single transaction
    • … simplifies the call, with a single mention of the label value
    • … allows to revoke write privileges from the tables and only grant it to this function if desired, enforcing the right access pattern.
    CREATE FUNCTION f_new_label (_label text)
      RETURNS TABLE (label text, version int)
      LANGUAGE sql STRICT AS
    $func$
    INSERT INTO label (label)
    VALUES (_label)
    ON     CONFLICT (label) DO UPDATE
    SET    label = NULL WHERE false;  -- never executed, but still locks the row
    
    INSERT INTO version AS v (label, version)
    SELECT _label, coalesce(max(v1.version), 0) + 1
    FROM   version v1
    WHERE  v1.label = _label
    RETURNING v.label, v.version;
    $func$;
    

    Call:

    SELECT * FROM f_new_label('label1');
    

    fiddle

    Related:

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search