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;