From 5ec093040ff8b4a618d03909de97c6444c8e9f8f Mon Sep 17 00:00:00 2001 From: Ibrar Ahmed Date: Tue, 15 Sep 2026 17:29:20 +0500 Subject: [PATCH] Fix stale regression output, add API coverage, make CI diagnosable. expected/lolor.out was never regenerated after the lo_lseek, lo_tell and lo_truncate blocks were added, so every object count after them is wrong and the test fails on main. Regenerate it, and print a boolean instead of the per-run OID in the DROP-conflict test. Cover lo_put(), lo_tell64(), lo_truncate64(), page-boundary writes, unaligned chunked reads, seek variants and subtransaction cleanup. In CI, cap the job at 45 minutes and the test wait at 25, dumping activity, locks and subscription status on timeout; a hung tester previously ran to the six hour ceiling and was cancelled rather than failed. Set output_plugin_libraries where supported, without which spock_output cannot decode and no subscription syncs, and bound the entrypoint's peer wait. --- .github/workflows/workflow.yml | 60 +++++- .gitignore | 7 + docker/entrypoint.sh | 48 ++++- expected/lolor.out | 372 +++++++++++++++++++++++++++++++-- sql/lolor.sql | 120 ++++++++++- 5 files changed, 573 insertions(+), 34 deletions(-) diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml index 356d2d9..7852961 100644 --- a/.github/workflows/workflow.yml +++ b/.github/workflows/workflow.yml @@ -22,6 +22,12 @@ jobs: spock_ref: main runs-on: ${{ matrix.os }} + # Without this a hung tester burns the 6 hour job ceiling on every matrix + # entry, and the run is cancelled rather than failed, which skips the + # diagnostic steps below. Leave room for image build and container startup + # ahead of the 25 minute test wait, so the wait's own deadline is what fires + # and the diagnostics actually run. + timeout-minutes: 60 steps: - name: Checkout lolor @@ -115,14 +121,66 @@ jobs: id: test_step continue-on-error: true run: | + # Cap the wait so a test blocked on a lock or on sub_wait_for_sync() + # fails the step while the cluster is still up to be inspected. + deadline=$(( SECONDS + 25 * 60 )) while [ "$(docker inspect -f '{{.State.Running}}' tester)" == "true" ]; do + if [ "$SECONDS" -ge "$deadline" ]; then + echo "::error::Tests did not finish within 25 minutes; dumping cluster state" + # These run precisely because the cluster is wedged, so bound each + # one: "|| true" catches a bad exit status but not a hang, and a + # blocked query here would burn the job timeout and skip the + # failure-log step below. + PGV=${{ matrix.pgver }} + echo "===== tester output so far =====" + tail -n 200 tests/out.txt || true + for n in n1 n2 n3; do + echo "===== $n: sessions and what they are waiting on =====" + timeout --kill-after=5s 20s docker exec "$n" bash -c "source /home/pgedge/pgedge/pg${PGV}/pg${PGV}.env && psql -U admin -d demo -x -c \" + SELECT pid, state, wait_event_type, wait_event, now() - xact_start AS xact_age, + left(query, 400) AS query + FROM pg_stat_activity + WHERE datname = 'demo' AND pid <> pg_backend_pid() + ORDER BY xact_start\"" || true + echo "===== $n: ungranted locks =====" + timeout --kill-after=5s 20s docker exec "$n" bash -c "source /home/pgedge/pgedge/pg${PGV}/pg${PGV}.env && psql -U admin -d demo -c \" + SELECT l.pid, l.locktype, l.mode, l.granted, + coalesce(c.relname, l.classid::text) AS object + FROM pg_locks l LEFT JOIN pg_class c ON c.oid = l.relation + WHERE NOT l.granted OR l.mode LIKE 'Share%' OR l.mode LIKE '%Exclusive%' + ORDER BY l.granted, l.pid\"" || true + echo "===== $n: subscription status =====" + timeout --kill-after=5s 20s docker exec "$n" bash -c "source /home/pgedge/pgedge/pg${PGV}/pg${PGV}.env && psql -U admin -d demo -c 'SELECT * FROM spock.sub_show_status()'" || true + done + exit 1 + fi echo "Waiting for tests to complete..." sleep 1 done docker logs n1 docker logs n2 docker logs n3 - grep -Eq "FAIL|ERROR" tests/out.txt && exit 1 || exit 0 + # grep exits 2 when the file is missing, which "|| exit 0" would turn + # into a pass: a tester that died before writing anything would look + # green. Check for output first, then for failures in it. + if [ ! -s tests/out.txt ]; then + echo "::error::tests/out.txt is missing or empty; the tester produced no output" + exit 1 + fi + if grep -Eq "FAIL|ERROR" tests/out.txt; then + exit 1 + fi + + - name: Dump container logs when the tests failed + if: steps.test_step.outcome == 'failure' + run: | + cd docker + for c in n1 n2 n3 tester; do + echo "===== $c =====" + docker logs "$c" > /tmp/$c.log 2>&1 || true + echo "--- first 120 lines (setup) ---"; head -n 120 /tmp/$c.log || true + echo "--- last 120 lines ---"; tail -n 120 /tmp/$c.log || true + done - name: Upload Log File as Artifact uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 diff --git a/.gitignore b/.gitignore index 4c75984..228ad22 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,10 @@ *.so *.bc *.o +*.dylib +results/ +regression.diffs +regression.out +tmp_check/ +log/ +delete_old_cluster.sh diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index ac12080..37e9b2f 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -9,8 +9,18 @@ echo ". /home/pgedge/pgedge/pg${PG_VER}/pg${PG_VER}.env" >> /home/pgedge/.bashrc # deprecated pgedge CLI. initdb -D "$PGDATA" -U admin --encoding=UTF8 --locale=C +# Recent minors gate logical decoding on output_plugin_libraries, which does +# not list spock_output; without it every apply worker fails to create its +# slot and no subscription ever syncs. Older minors reject the parameter, so +# ask the binary whether it is supported. +OUTPUT_PLUGIN_LIBRARIES="" +if postgres --describe-config 2>/dev/null | grep -q '^output_plugin_libraries'; then + OUTPUT_PLUGIN_LIBRARIES="output_plugin_libraries = 'pgoutput, test_decoding, spock_output'" +fi + cat >> "$PGDATA/postgresql.conf" <<_EOF_ listen_addresses = '*' +$OUTPUT_PLUGIN_LIBRARIES wal_level = logical track_commit_timestamp = on max_worker_processes = 32 @@ -51,20 +61,38 @@ _EOF_ IFS=',' read -r -a peer_names <<< "$PEER_NAMES" +# Bounded: an unbounded wait leaves the container up with the temporary +# postgres still listening, so the health check keeps passing while setup +# never finishes and the test harness blocks with no indication of why. +# +# The bound is wall clock rather than a loop count, and psql gets its own +# connect and statement timeouts: an unresponsive peer can block psql itself, +# which would let a counted loop run far past its nominal limit. +# One budget for all peers, not 300s each: a node waits for two peers and the +# tester for three, so a per-peer deadline would let startup run for fifteen +# minutes while still claiming a 300s bound. +deadline=$(( SECONDS + 300 )) for PEER_HOSTNAME in "${peer_names[@]}"; do - while : + peer_ready=0 + while [ "$SECONDS" -lt "$deadline" ]; do + mapfile -t node_array < <(PGCONNECT_TIMEOUT=5 PGOPTIONS='-c statement_timeout=10s' \ + psql -A -t demo -h "$PEER_HOSTNAME" -c "SELECT node_name FROM spock.node;") + for element in "${node_array[@]}"; do - mapfile -t node_array < <(psql -A -t demo -h $PEER_HOSTNAME -c "SELECT node_name FROM spock.node;") - for element in "${node_array[@]}"; - do - if [[ "$element" == "$PEER_HOSTNAME" ]]; then - break 2 - fi - done - sleep 1 - echo "Waiting for $PEER_HOSTNAME..." + if [[ "$element" == "$PEER_HOSTNAME" ]]; then + peer_ready=1 + break + fi done + [ "$peer_ready" = "1" ] && break + sleep 1 + echo "Waiting for $PEER_HOSTNAME..." + done + if [ "$peer_ready" != "1" ]; then + echo "ERROR: peer $PEER_HOSTNAME did not register a spock node within the 300s startup budget" >&2 + exit 1 + fi done # spock.sub_create connects to the provider synchronously, and the peer diff --git a/expected/lolor.out b/expected/lolor.out index 0d5441b..e7853de 100644 --- a/expected/lolor.out +++ b/expected/lolor.out @@ -217,7 +217,7 @@ SELECT lo_close(:fd); END; DROP EXTENSION lolor; -NOTICE: migrated 1 large object(s) from lolor to native storage +NOTICE: migrated 4 large object(s) from lolor to native storage -- Check extension upgrade CREATE EXTENSION lolor VERSION '1.0'; SELECT lo_creat(-1) AS loid \gset @@ -251,25 +251,25 @@ NOTICE: migrated 1 large object(s) from lolor to native storage (1 row) SELECT lolor.migrate_from_native(); -- two objects -NOTICE: migrated 2 large object(s) (2 data page(s)) from native to lolor storage +NOTICE: migrated 5 large object(s) (5 data page(s)) from native to lolor storage migrate_from_native --------------------- - 2 + 5 (1 row) -- Repeat conversion cycle - should see the same two objects SELECT lolor.migrate_to_native(); -NOTICE: migrated 2 large object(s) from lolor to native storage +NOTICE: migrated 5 large object(s) from lolor to native storage migrate_to_native ------------------- - 2 + 5 (1 row) SELECT lolor.migrate_from_native(); -NOTICE: migrated 2 large object(s) (2 data page(s)) from native to lolor storage +NOTICE: migrated 5 large object(s) (5 data page(s)) from native to lolor storage migrate_from_native --------------------- - 2 + 5 (1 row) -- @@ -336,7 +336,7 @@ SELECT lolor.enable(); -- Check that no tails existing after the extension drop in both enabled and -- disabled states. DROP EXTENSION lolor; -NOTICE: migrated 3 large object(s) from lolor to native storage +NOTICE: migrated 6 large object(s) from lolor to native storage SELECT oid, proname FROM pg_proc WHERE proname IN ('lo_open_orig', 'lolor_lo_open'); oid | proname @@ -385,16 +385,16 @@ SELECT lo_from_bytea(0, 'Native object number two') AS native_oid2 \gset SELECT count(*) AS native_lo_count FROM pg_catalog.pg_largeobject_metadata; native_lo_count ----------------- - 6 + 9 (1 row) -- Install lolor and migrate native LOs into lolor storage CREATE EXTENSION lolor; SELECT lolor.migrate_from_native(); -NOTICE: migrated 6 large object(s) (6 data page(s)) from native to lolor storage +NOTICE: migrated 9 large object(s) (9 data page(s)) from native to lolor storage migrate_from_native --------------------- - 6 + 9 (1 row) -- After forward migration: expect 0 native objects @@ -407,7 +407,7 @@ SELECT count(*) AS native_after_migrate FROM pg_catalog.pg_largeobject_metadata; SELECT count(*) AS lolor_after_migrate FROM lolor.pg_largeobject_metadata; lolor_after_migrate --------------------- - 6 + 9 (1 row) -- Data integrity: expect "Native object number one" @@ -446,11 +446,11 @@ END; SELECT lo_from_bytea(0, 'Created directly in lolor') AS lolor_direct_oid \gset -- Reverse migration via DROP EXTENSION DROP EXTENSION lolor; -NOTICE: migrated 7 large object(s) from lolor to native storage +NOTICE: migrated 10 large object(s) from lolor to native storage SELECT count(*) AS native_after_drop FROM pg_catalog.pg_largeobject_metadata; native_after_drop ------------------- - 7 + 10 (1 row) -- After DROP: expect "Native object number one" @@ -495,14 +495,14 @@ SELECT lo_unlink(:'lolor_direct_oid'::oid); CREATE EXTENSION lolor; SELECT lolor.migrate_from_native(); -NOTICE: migrated 4 large object(s) (4 data page(s)) from native to lolor storage +NOTICE: migrated 7 large object(s) (7 data page(s)) from native to lolor storage migrate_from_native --------------------- - 4 + 7 (1 row) DROP EXTENSION lolor; -NOTICE: migrated 4 large object(s) from lolor to native storage +NOTICE: migrated 7 large object(s) from lolor to native storage -- -- Manual migrate_to_native (not via DROP EXTENSION) -- @@ -518,7 +518,7 @@ NOTICE: migrated 1 large object(s) from lolor to native storage SELECT count(*) AS native_after_manual FROM pg_catalog.pg_largeobject_metadata; native_after_manual --------------------- - 5 + 8 (1 row) SELECT count(*) AS lolor_after_manual FROM lolor.pg_largeobject_metadata; @@ -638,10 +638,11 @@ SELECT lolor.disable(); t (1 row) -SELECT lo_create(:'drop_conflict_oid'); - lo_create ------------ - 268385 +-- Print a stable boolean rather than the generated OID, which varies per run +SELECT lo_create(:'drop_conflict_oid') = :'drop_conflict_oid'::oid AS native_oid_honored; + native_oid_honored +-------------------- + t (1 row) SELECT lolor.enable(); @@ -689,3 +690,330 @@ SELECT lolor.enable(); -- Now DROP should succeed DROP EXTENSION lolor; NOTICE: migrated 1 large object(s) from lolor to native storage +-- +-- 64-bit interface and page-boundary I/O. lo_put(), lo_tell64() and +-- lo_truncate64() had no coverage. +-- +CREATE EXTENSION lolor; +SELECT current_setting('block_size')::int / 4 AS loblksize \gset +-- Write straddling a page boundary, then read the fragment back. +SELECT lo_create(0) AS span_oid \gset +SELECT lo_put(:span_oid, (:loblksize - 4)::bigint, '\x4142434445464748'::bytea); + lo_put +-------- + +(1 row) + +SELECT length(lo_get(:span_oid)) = :loblksize + 4 AS spans_two_pages; + spans_two_pages +----------------- + t +(1 row) + +SELECT count(*) = 2 AS two_data_pages FROM lolor.pg_largeobject WHERE loid = :span_oid; + two_data_pages +---------------- + t +(1 row) + +SELECT encode(lo_get(:span_oid, (:loblksize - 4)::bigint, 8), 'hex') AS across_boundary; + across_boundary +------------------ + 4142434445464748 +(1 row) + +-- lo_truncate64() extending past the end leaves a hole rather than pages. +BEGIN; +SELECT lo_open(:span_oid, x'60000'::int) AS fd \gset +SELECT lo_truncate64(:fd, (:loblksize * 4)::bigint); + lo_truncate64 +--------------- + 0 +(1 row) + +SELECT lo_lseek64(:fd, 0, 2) = (:loblksize * 4)::bigint AS seek_end_matches; + seek_end_matches +------------------ + t +(1 row) + +SELECT lo_tell64(:fd) = (:loblksize * 4)::bigint AS tell64_matches; + tell64_matches +---------------- + t +(1 row) + +SELECT lo_close(:fd); + lo_close +---------- + 0 +(1 row) + +END; +SELECT length(lo_get(:span_oid)) = :loblksize * 4 AS truncate64_extended; + truncate64_extended +--------------------- + t +(1 row) + +SELECT count(*) < 4 AS hole_not_materialised + FROM lolor.pg_largeobject WHERE loid = :span_oid; + hole_not_materialised +----------------------- + t +(1 row) + +-- Truncating back down releases the pages beyond the new length. +BEGIN; +SELECT lo_open(:span_oid, x'60000'::int) AS fd \gset +SELECT lo_truncate64(:fd, 10); + lo_truncate64 +--------------- + 0 +(1 row) + +SELECT lo_close(:fd); + lo_close +---------- + 0 +(1 row) + +END; +SELECT length(lo_get(:span_oid)) AS len_after_shrink; + len_after_shrink +------------------ + 10 +(1 row) + +SELECT lo_unlink(:span_oid); + lo_unlink +----------- + 1 +(1 row) + +-- +-- Subtransaction cleanup. A descriptor opened inside an aborted +-- subtransaction must be closed by the rollback, one opened in the enclosing +-- transaction must survive it, and data written in the aborted +-- subtransaction must not persist. +-- +BEGIN; +SELECT lo_from_bytea(0, 'outer') AS sub_oid \gset +SELECT lo_open(:sub_oid, x'60000'::int) AS outer_fd \gset +SAVEPOINT s1; +SELECT lo_open(:sub_oid, x'60000'::int) AS inner_fd \gset +SELECT lo_put(:sub_oid, 0, 'INNER'); + lo_put +-------- + +(1 row) + +ROLLBACK TO s1; +SAVEPOINT s2; +SELECT lo_tell(:inner_fd); +ERROR: invalid large-object descriptor: 1 +ROLLBACK TO s2; +SELECT lo_tell(:outer_fd) AS outer_descriptor_survives; + outer_descriptor_survives +--------------------------- + 0 +(1 row) + +SELECT lo_close(:outer_fd); + lo_close +---------- + 0 +(1 row) + +COMMIT; +SELECT convert_from(lo_get(:sub_oid), 'UTF8') AS subxact_write_rolled_back; + subxact_write_rolled_back +--------------------------- + outer +(1 row) + +SELECT lo_unlink(:sub_oid); + lo_unlink +----------- + 1 +(1 row) + +-- +-- A rolled back transaction must leave no trace in lolor storage. +-- +SELECT count(*) AS rows_before FROM lolor.pg_largeobject_metadata; + rows_before +------------- + 0 +(1 row) + +BEGIN; +SELECT lo_from_bytea(0, 'discarded') IS NOT NULL AS created_in_aborted_xact; + created_in_aborted_xact +------------------------- + t +(1 row) + +ROLLBACK; +SELECT count(*) AS rows_after FROM lolor.pg_largeobject_metadata; + rows_after +------------ + 0 +(1 row) + +-- +-- Seek variants and read/write edge cases. +-- +SELECT lo_from_bytea(0, '0123456789abcdef') AS seek_oid \gset +BEGIN; +SELECT lo_open(:seek_oid, x'60000'::int) AS fd \gset +SELECT lo_lseek(:fd, 4, 0) AS seek_set; + seek_set +---------- + 4 +(1 row) + +SELECT lo_lseek(:fd, 2, 1) AS seek_cur; + seek_cur +---------- + 6 +(1 row) + +SELECT lo_lseek(:fd, -3, 2) AS seek_end; + seek_end +---------- + 13 +(1 row) + +SELECT lo_tell(:fd) AS tell_after_seeks; + tell_after_seeks +------------------ + 13 +(1 row) + +SELECT convert_from(loread(:fd, 3), 'UTF8') AS read_tail; + read_tail +----------- + def +(1 row) + +-- A read at end of object returns nothing rather than failing. +SELECT length(loread(:fd, 100)) AS read_past_eof; + read_past_eof +--------------- + 0 +(1 row) + +-- Zero-length read and empty write are both no-ops. +SELECT lo_lseek(:fd, 0, 0); + lo_lseek +---------- + 0 +(1 row) + +SELECT length(loread(:fd, 0)) AS zero_length_read; + zero_length_read +------------------ + 0 +(1 row) + +SELECT lowrite(:fd, '') AS empty_write; + empty_write +------------- + 0 +(1 row) + +SELECT lo_close(:fd); + lo_close +---------- + 0 +(1 row) + +END; +SELECT length(lo_get(:seek_oid)) AS unchanged_length; + unchanged_length +------------------ + 16 +(1 row) + +-- lo_get with a fragment length beyond the end is clamped, not an error. +SELECT convert_from(lo_get(:seek_oid, 10, 1000), 'UTF8') AS clamped_fragment; + clamped_fragment +------------------ + abcdef +(1 row) + +SELECT lo_unlink(:seek_oid); + lo_unlink +----------- + 1 +(1 row) + +-- +-- Reading a multi-page object back in chunks that do not align with the page +-- size exercises the page-assembly path in lolor_inv_read(). +-- +SELECT lo_from_bytea(0, repeat('abcdefgh', (:loblksize * 3 / 8))::bytea) AS multi_oid \gset +SELECT length(lo_get(:multi_oid)) = :loblksize * 3 AS three_pages_written; + three_pages_written +--------------------- + t +(1 row) + +SELECT count(*) AS page_rows FROM lolor.pg_largeobject WHERE loid = :multi_oid; + page_rows +----------- + 3 +(1 row) + +BEGIN; +SELECT lo_open(:multi_oid, 262144) AS fd \gset +SELECT length(loread(:fd, 1000)) AS chunk1; + chunk1 +-------- + 1000 +(1 row) + +SELECT length(loread(:fd, 5000)) AS chunk2; + chunk2 +-------- + 5000 +(1 row) + +SELECT length(loread(:fd, 100000)) AS chunk_rest; + chunk_rest +------------ + 144 +(1 row) + +SELECT lo_close(:fd); + lo_close +---------- + 0 +(1 row) + +END; +SELECT md5(lo_get(:multi_oid)) = md5(repeat('abcdefgh', (:loblksize * 3 / 8))::bytea) + AS content_round_trips; + content_round_trips +--------------------- + t +(1 row) + +SELECT lo_unlink(:multi_oid); + lo_unlink +----------- + 1 +(1 row) + +-- +-- Error paths. +-- +SELECT lo_get(0); +ERROR: large object 0 does not exist +BEGIN; +SELECT lo_open(0, 262144); +ERROR: large object 0 does not exist +ROLLBACK; +DROP EXTENSION lolor; +NOTICE: no lolor large objects to migrate diff --git a/sql/lolor.sql b/sql/lolor.sql index d2c7509..6881c2d 100644 --- a/sql/lolor.sql +++ b/sql/lolor.sql @@ -269,7 +269,8 @@ CREATE EXTENSION lolor; SELECT lo_from_bytea(0, 'Drop conflict test') AS drop_conflict_oid \gset -- Create a native LO with the same OID to force conflict at DROP time SELECT lolor.disable(); -SELECT lo_create(:'drop_conflict_oid'); +-- Print a stable boolean rather than the generated OID, which varies per run +SELECT lo_create(:'drop_conflict_oid') = :'drop_conflict_oid'::oid AS native_oid_honored; SELECT lolor.enable(); -- DROP EXTENSION should ERROR to prevent data loss DROP EXTENSION lolor; @@ -283,3 +284,120 @@ SELECT lo_unlink(:'drop_conflict_oid'::oid); SELECT lolor.enable(); -- Now DROP should succeed DROP EXTENSION lolor; + +-- +-- 64-bit interface and page-boundary I/O. lo_put(), lo_tell64() and +-- lo_truncate64() had no coverage. +-- +CREATE EXTENSION lolor; +SELECT current_setting('block_size')::int / 4 AS loblksize \gset + +-- Write straddling a page boundary, then read the fragment back. +SELECT lo_create(0) AS span_oid \gset +SELECT lo_put(:span_oid, (:loblksize - 4)::bigint, '\x4142434445464748'::bytea); +SELECT length(lo_get(:span_oid)) = :loblksize + 4 AS spans_two_pages; +SELECT count(*) = 2 AS two_data_pages FROM lolor.pg_largeobject WHERE loid = :span_oid; +SELECT encode(lo_get(:span_oid, (:loblksize - 4)::bigint, 8), 'hex') AS across_boundary; + +-- lo_truncate64() extending past the end leaves a hole rather than pages. +BEGIN; +SELECT lo_open(:span_oid, x'60000'::int) AS fd \gset +SELECT lo_truncate64(:fd, (:loblksize * 4)::bigint); +SELECT lo_lseek64(:fd, 0, 2) = (:loblksize * 4)::bigint AS seek_end_matches; +SELECT lo_tell64(:fd) = (:loblksize * 4)::bigint AS tell64_matches; +SELECT lo_close(:fd); +END; +SELECT length(lo_get(:span_oid)) = :loblksize * 4 AS truncate64_extended; +SELECT count(*) < 4 AS hole_not_materialised + FROM lolor.pg_largeobject WHERE loid = :span_oid; + +-- Truncating back down releases the pages beyond the new length. +BEGIN; +SELECT lo_open(:span_oid, x'60000'::int) AS fd \gset +SELECT lo_truncate64(:fd, 10); +SELECT lo_close(:fd); +END; +SELECT length(lo_get(:span_oid)) AS len_after_shrink; +SELECT lo_unlink(:span_oid); + +-- +-- Subtransaction cleanup. A descriptor opened inside an aborted +-- subtransaction must be closed by the rollback, one opened in the enclosing +-- transaction must survive it, and data written in the aborted +-- subtransaction must not persist. +-- +BEGIN; +SELECT lo_from_bytea(0, 'outer') AS sub_oid \gset +SELECT lo_open(:sub_oid, x'60000'::int) AS outer_fd \gset +SAVEPOINT s1; +SELECT lo_open(:sub_oid, x'60000'::int) AS inner_fd \gset +SELECT lo_put(:sub_oid, 0, 'INNER'); +ROLLBACK TO s1; +SAVEPOINT s2; +SELECT lo_tell(:inner_fd); +ROLLBACK TO s2; +SELECT lo_tell(:outer_fd) AS outer_descriptor_survives; +SELECT lo_close(:outer_fd); +COMMIT; +SELECT convert_from(lo_get(:sub_oid), 'UTF8') AS subxact_write_rolled_back; +SELECT lo_unlink(:sub_oid); + +-- +-- A rolled back transaction must leave no trace in lolor storage. +-- +SELECT count(*) AS rows_before FROM lolor.pg_largeobject_metadata; +BEGIN; +SELECT lo_from_bytea(0, 'discarded') IS NOT NULL AS created_in_aborted_xact; +ROLLBACK; +SELECT count(*) AS rows_after FROM lolor.pg_largeobject_metadata; + +-- +-- Seek variants and read/write edge cases. +-- +SELECT lo_from_bytea(0, '0123456789abcdef') AS seek_oid \gset +BEGIN; +SELECT lo_open(:seek_oid, x'60000'::int) AS fd \gset +SELECT lo_lseek(:fd, 4, 0) AS seek_set; +SELECT lo_lseek(:fd, 2, 1) AS seek_cur; +SELECT lo_lseek(:fd, -3, 2) AS seek_end; +SELECT lo_tell(:fd) AS tell_after_seeks; +SELECT convert_from(loread(:fd, 3), 'UTF8') AS read_tail; +-- A read at end of object returns nothing rather than failing. +SELECT length(loread(:fd, 100)) AS read_past_eof; +-- Zero-length read and empty write are both no-ops. +SELECT lo_lseek(:fd, 0, 0); +SELECT length(loread(:fd, 0)) AS zero_length_read; +SELECT lowrite(:fd, '') AS empty_write; +SELECT lo_close(:fd); +END; +SELECT length(lo_get(:seek_oid)) AS unchanged_length; +-- lo_get with a fragment length beyond the end is clamped, not an error. +SELECT convert_from(lo_get(:seek_oid, 10, 1000), 'UTF8') AS clamped_fragment; +SELECT lo_unlink(:seek_oid); + +-- +-- Reading a multi-page object back in chunks that do not align with the page +-- size exercises the page-assembly path in lolor_inv_read(). +-- +SELECT lo_from_bytea(0, repeat('abcdefgh', (:loblksize * 3 / 8))::bytea) AS multi_oid \gset +SELECT length(lo_get(:multi_oid)) = :loblksize * 3 AS three_pages_written; +SELECT count(*) AS page_rows FROM lolor.pg_largeobject WHERE loid = :multi_oid; +BEGIN; +SELECT lo_open(:multi_oid, 262144) AS fd \gset +SELECT length(loread(:fd, 1000)) AS chunk1; +SELECT length(loread(:fd, 5000)) AS chunk2; +SELECT length(loread(:fd, 100000)) AS chunk_rest; +SELECT lo_close(:fd); +END; +SELECT md5(lo_get(:multi_oid)) = md5(repeat('abcdefgh', (:loblksize * 3 / 8))::bytea) + AS content_round_trips; +SELECT lo_unlink(:multi_oid); + +-- +-- Error paths. +-- +SELECT lo_get(0); +BEGIN; +SELECT lo_open(0, 262144); +ROLLBACK; +DROP EXTENSION lolor;