Avoid detoasting failure after COMMIT inside a plpgsql FOR loop.
authorTom Lane <tgl@sss.pgh.pa.us>
Thu, 20 May 2021 22:32:37 +0000 (18:32 -0400)
committerTom Lane <tgl@sss.pgh.pa.us>
Thu, 20 May 2021 22:32:37 +0000 (18:32 -0400)
exec_for_query() normally tries to prefetch a few rows at a time
from the query being iterated over, so as to reduce executor
entry/exit overhead.  Unfortunately this is unsafe if we have
COMMIT or ROLLBACK within the loop, because there might be
TOAST references in the data that we prefetched but haven't
yet examined.  Immediately after the COMMIT/ROLLBACK, we have
no snapshots in the session, meaning that VACUUM is at liberty
to remove recently-deleted TOAST rows.

This was originally reported as a case triggering the "no known
snapshots" error in init_toast_snapshot(), but even if you miss
hitting that, you can get "missing toast chunk", as illustrated
by the added isolation test case.

To fix, just disable prefetching in non-atomic contexts.  Maybe
there will be performance complaints prompting us to work harder
later, but it's not clear at the moment that this really costs
much, and I doubt we'd want to back-patch any complicated fix.

In passing, adjust that error message in init_toast_snapshot()
to be a little clearer about the likely cause of the problem.

Patch by me, based on earlier investigation by Konstantin Knizhnik.

Per bug #15990 from Andreas Wicht.  Back-patch to v11 where
intra-procedure COMMIT was added.

Discussion: https://postgr.es/m/15990-eee2ac466b11293d@postgresql.org

src/backend/access/heap/tuptoaster.c
src/pl/plpgsql/src/pl_exec.c
src/test/isolation/expected/plpgsql-toast.out
src/test/isolation/specs/plpgsql-toast.spec

index da878a734830a671902fa4e2dab90d91e6eaef02..ae6a3fa0ec2fb6edf147ea1009ee70d5272a012d 100644 (file)
@@ -2406,8 +2406,21 @@ init_toast_snapshot(Snapshot toast_snapshot)
 {
    Snapshot    snapshot = GetOldestSnapshot();
 
+   /*
+    * GetOldestSnapshot returns NULL if the session has no active snapshots.
+    * We can get that if, for example, a procedure fetches a toasted value
+    * into a local variable, commits, and then tries to detoast the value.
+    * Such coding is unsafe, because once we commit there is nothing to
+    * prevent the toast data from being deleted.  Detoasting *must* happen in
+    * the same transaction that originally fetched the toast pointer.  Hence,
+    * rather than trying to band-aid over the problem, throw an error.  (This
+    * is not very much protection, because in many scenarios the procedure
+    * would have already created a new transaction snapshot, preventing us
+    * from detecting the problem.  But it's better than nothing, and for sure
+    * we shouldn't expend code on masking the problem more.)
+    */
    if (snapshot == NULL)
-       elog(ERROR, "no known snapshots");
+       elog(ERROR, "cannot fetch toast data without an active snapshot");
 
    InitToastSnapshot(*toast_snapshot, snapshot->lsn, snapshot->whenTaken);
 }
index 19da13678aeabf3cd2817cba14768fe877593839..7c5baff10a863bd1c7c29f654117ccbb8df2cfc0 100644 (file)
@@ -5951,6 +5951,17 @@ exec_for_query(PLpgSQL_execstate *estate, PLpgSQL_stmt_forq *stmt,
     */
    PinPortal(portal);
 
+   /*
+    * In a non-atomic context, we dare not prefetch, even if it would
+    * otherwise be safe.  Aside from any semantic hazards that that might
+    * create, if we prefetch toasted data and then the user commits the
+    * transaction, the toast references could turn into dangling pointers.
+    * (Rows we haven't yet fetched from the cursor are safe, because the
+    * PersistHoldablePortal mechanism handles this scenario.)
+    */
+   if (!estate->atomic)
+       prefetch_ok = false;
+
    /*
     * Fetch the initial tuple(s).  If prefetching is allowed then we grab a
     * few more rows to avoid multiple trips through executor startup
index fc557da5e777297d76ef6c990645e2cf08925739..4f216b94b62281d921fe30e3b44a10d647ac5bba 100644 (file)
@@ -192,3 +192,46 @@ pg_advisory_unlock
 t              
 s1: NOTICE:  length(r) = 6002
 step assign5: <... completed>
+
+starting permutation: lock assign6 vacuum unlock
+pg_advisory_unlock_all
+
+               
+pg_advisory_unlock_all
+
+               
+step lock: 
+    SELECT pg_advisory_lock(1);
+
+pg_advisory_lock
+
+               
+step assign6: 
+do $$
+  declare
+    r record;
+  begin
+    insert into test1 values (2, repeat('bar', 3000));
+    insert into test1 values (3, repeat('baz', 4000));
+    for r in select test1.b from test1 loop
+      delete from test1;
+      commit;
+      perform pg_advisory_lock(1);
+      raise notice 'length(r) = %', length(r::text);
+    end loop;
+  end;
+$$;
+ <waiting ...>
+step vacuum: 
+    VACUUM test1;
+
+step unlock: 
+    SELECT pg_advisory_unlock(1);
+
+pg_advisory_unlock
+
+t              
+s1: NOTICE:  length(r) = 6002
+s1: NOTICE:  length(r) = 9002
+s1: NOTICE:  length(r) = 12002
+step assign6: <... completed>
index fe7090addbbcef5b7133c5e36cefbbf2f4b964cb..d360f8fccbf9fd113ee64c64941c87d51d1a8cae 100644 (file)
@@ -112,6 +112,25 @@ do $$
 $$;
 }
 
+# FOR loop must not hold any fetched-but-not-detoasted values across commit
+step "assign6"
+{
+do $$
+  declare
+    r record;
+  begin
+    insert into test1 values (2, repeat('bar', 3000));
+    insert into test1 values (3, repeat('baz', 4000));
+    for r in select test1.b from test1 loop
+      delete from test1;
+      commit;
+      perform pg_advisory_lock(1);
+      raise notice 'length(r) = %', length(r::text);
+    end loop;
+  end;
+$$;
+}
+
 session "s2"
 setup
 {
@@ -135,3 +154,4 @@ permutation "lock" "assign2" "vacuum" "unlock"
 permutation "lock" "assign3" "vacuum" "unlock"
 permutation "lock" "assign4" "vacuum" "unlock"
 permutation "lock" "assign5" "vacuum" "unlock"
+permutation "lock" "assign6" "vacuum" "unlock"