postgresqllockingcommon-table-expressiondatabase-cursor

Do explicit locks in postgresql CTEs lock every row even with a cursor?


Let's say I have a table

CREATE TABLE mytable (
  user_id bigint,
  someval text
)

In my application, I want to lock and fetch every row for some given user_id e.g. 123 to do some additional processing.

But I don't want to fetch everything at once because of the volumetry.

In Jooq in my java application, I'd use something like

dslContext.select(MYTABLE.SOMEVAL)
          .from(MYTABLE)
          .where(MYTABLE.USER_ID.eq(123))
          .orderBy(MY_TABLE.SOMEVAL)
          .forUpdate()
          .fetchSize(...)
          .fetchLazy()
          ...

But the postgresql documentation states that

if a locking clause is used in a cursor's query, only rows actually fetched or stepped past by the cursor will be locked

So I instead might do something like

var cte = name("cte").as(
    select(MYTABLE.SOMEVAL)
    .from(MYTABLE)
    .where(MYTABLE.USER_ID.eq(123))
    .orderBy(MY_TABLE.SOMEVAL)
    .forUpdate()
);
dslContext.with(cte)
          .select(cte.field(MYTABLE.SOMEVAL))
          .orderBy(cte.field(MY_TABLE.SOMEVAL))
          .fetchSize(...)
          .fetchLazy()
          ...

But I don't know if this actually locks all the rows from the CTE before the main query, or if it might do some optimization and still only lock the rows actually fetched


Solution

  • I don't know if this actually locks all the rows from the CTE before the main query

    It does not. Your assumption that it might get optimised away, is correct. Even if you somehow convinced jOOQ to explicitly materialize the CTE, which doesn't seem supported, it still would not affect the cursor locking behaviour.

    What you can do, is fire a separate row-locking select beforehand:

    select from mytable where user_id=123 for update;
    

    Note that I'm not selecting any columns, which means this command will only respond with a command tag with affected row count. I'm not sure how to do that through jOOQ as it interprets empty .select() param list as an asterisk select *, which is the opposite of what I'm doing. You could try to use its emulated (otherwise unsupported by PostgreSQL) * except() clause to remove all columns.

    If you want it all in one query, you could also try and build one like this:

    declare cursor1 cursor for
    with cte as(
      select someval 
      from mytable 
      where user_id=123 
      for update)
    ,cte2 as(select any_value(0) 
             from cte)
    select someval
    from cte
    cross join cte2
    order by someval;
    

    It forces the evaluation of the whole locking scan before exposing the results. any_value() is a cheap way to flatten the uncorrelated subquery, but you could just as well use count(*) - once it does its job, it gets discarded.

    Another idea would be to swap out cursor-based pagination for keyset pagination. You'd have to rethink your query but it's potentially cheaper, lighter, faster and doesn't have to keep your transaction open (or save a snapshot of the table, like with hold cursors do).


    You can check pg_locks and pageinspect to see all the locks for yourself:
    demo at db<>fiddle

    create extension pageinspect;
    
    create table mytable (
      user_id bigint,
      someval text
    );
    
    insert into mytable
    values (123,'a')
          ,(123,'b')
          ,(123,'c')
          ,(456,'d')
          ,(789,'e');
    
    select relation::regclass,locktype,mode,virtualxid,transactionid from pg_locks;
    select m.ctid,user_id,someval,t_xmax,t_infomask2,t_infomask,raw_flags 
    from mytable as m
    join heap_page_item_attrs(get_raw_page('mytable','main',0),'mytable')
      on t_ctid=m.ctid
    cross join heap_tuple_infomask_flags(t_infomask, t_infomask2);
    

    This is before locking:

    relation locktype mode virtualxid transactionid
    pg_locks relation AccessShareLock null null
    null virtualxid ExclusiveLock 4/7 null
    ctid user_id someval t_xmax t_infomask2 t_infomask raw_flags
    (0,1) 123 a 0 2 2050 {HEAP_HASVARWIDTH,HEAP_XMAX_INVALID}
    (0,2) 123 b 0 2 2050 {HEAP_HASVARWIDTH,HEAP_XMAX_INVALID}
    (0,3) 123 c 0 2 2050 {HEAP_HASVARWIDTH,HEAP_XMAX_INVALID}
    (0,4) 456 d 0 2 2050 {HEAP_HASVARWIDTH,HEAP_XMAX_INVALID}
    (0,5) 789 e 0 2 2050 {HEAP_HASVARWIDTH,HEAP_XMAX_INVALID}

    Open the cursor, fetch something and see only the current and past rows are locked (table got a RowShareLock, only the first two rows' infomasks got modified):

    begin;
    
    declare cursor1 cursor for
    select someval
    from mytable
    where user_id=123
    order by someval
    for update;
    
    fetch next from cursor1;
    fetch next from cursor1;
    
    relation locktype mode virtualxid transactionid
    mytable relation AccessShareLock null null
    mytable relation RowShareLock null null
    pg_locks relation AccessShareLock null null
    null virtualxid ExclusiveLock 4/7 null
    null transactionid ExclusiveLock null 739
    ctid user_id someval t_xmax t_infomask2 t_infomask raw_flags
    (0,1) 123 a 739 8194 450 {HEAP_HASVARWIDTH,HEAP_XMAX_EXCL_LOCK,HEAP_XMAX_LOCK_ONLY,HEAP_XMIN_COMMITTED,HEAP_KEYS_UPDATED}
    (0,2) 123 b 739 8194 450 {HEAP_HASVARWIDTH,HEAP_XMAX_EXCL_LOCK,HEAP_XMAX_LOCK_ONLY,HEAP_XMIN_COMMITTED,HEAP_KEYS_UPDATED}
    (0,3) 123 c 0 2 2306 {HEAP_HASVARWIDTH,HEAP_XMIN_COMMITTED,HEAP_XMAX_INVALID}
    (0,4) 456 d 0 2 2306 {HEAP_HASVARWIDTH,HEAP_XMIN_COMMITTED,HEAP_XMAX_INVALID}
    (0,5) 789 e 0 2 2306 {HEAP_HASVARWIDTH,HEAP_XMIN_COMMITTED,HEAP_XMAX_INVALID}

    Wrapping the locking statement in a CTE, even a materialized one, changes nothing:

    commit and chain; 
    
    declare cursor1 cursor for
    with cte as materialized(
      select someval
      from mytable
      where user_id=123
      order by someval
      for update)
    select someval from cte;
    
    fetch next from cursor1;
    fetch next from cursor1;
    
    relation locktype mode virtualxid transactionid
    pg_locks relation AccessShareLock null null
    mytable relation RowShareLock null null
    null virtualxid ExclusiveLock 4/8 null
    null transactionid ExclusiveLock null 740
    ctid user_id someval t_xmax t_infomask2 t_infomask raw_flags
    (0,1) 123 a 740 8194 450 {HEAP_HASVARWIDTH,HEAP_XMAX_EXCL_LOCK,HEAP_XMAX_LOCK_ONLY,HEAP_XMIN_COMMITTED,HEAP_KEYS_UPDATED}
    (0,2) 123 b 740 8194 450 {HEAP_HASVARWIDTH,HEAP_XMAX_EXCL_LOCK,HEAP_XMAX_LOCK_ONLY,HEAP_XMIN_COMMITTED,HEAP_KEYS_UPDATED}
    (0,3) 123 c 0 2 2306 {HEAP_HASVARWIDTH,HEAP_XMIN_COMMITTED,HEAP_XMAX_INVALID}
    (0,4) 456 d 0 2 2306 {HEAP_HASVARWIDTH,HEAP_XMIN_COMMITTED,HEAP_XMAX_INVALID}
    (0,5) 789 e 0 2 2306 {HEAP_HASVARWIDTH,HEAP_XMIN_COMMITTED,HEAP_XMAX_INVALID}