From 11a814fb3adb4303c0bdc70e41a7d65e4a564079 Mon Sep 17 00:00:00 2001 From: Ibrar Ahmed Date: Fri, 9 Oct 2026 21:25:16 +0500 Subject: [PATCH] Rewrite large object migration as a direct relation copy. Reverse migration rewrote every object through the large object API, on the premise that inserting into pg_catalog.pg_largeobject was not allowed. It is, for the superuser both migration functions already require. Both directions become a tuple copy between the two relations, in C: pages are copied verbatim so sparse objects stay sparse, ownership and ACLs are recorded in pg_shdepend rather than set by a raw catalog UPDATE that DROP ROLE could not see, comments survive the round trip in a parking table, native objects are removed through the same deletion path DROP uses, and the layouts are verified against the running server rather than assumed. Migration no longer goes through the renamed _orig functions, so migrate_to_native() works while lolor is disabled. The copy holds ShareRowExclusiveLock on both stores until commit. Ordinary large object access takes RowExclusiveLock, which does not conflict with itself, so a concurrent lo_write() could otherwise commit between the copy of a page and the emptying of the source store and be lost with no error. Each row gets its own memory context, so millions of pages do not exhaust the backend. Migration refuses up front while any object refers to a dropped role, instead of dying part-way with "role N was concurrently dropped"; it refuses while other sessions are connected or when run from anything but a client session; and migrate_from_native(peer_oids) turns the documented cross-node OID collision into a pre-flight refusal, with lolor.digest() and lolor.native_lo_oids() for checking convergence. --- Makefile | 3 +- README.md | 24 +- docs/index.md | 14 +- docs/lolor_release_notes.md | 19 +- expected/lolor.out | 593 +++++++++++++++++++++++++-- lolor--1.2.2--1.3.0.sql | 516 +++++++++++------------ sql/lolor.sql | 240 ++++++++++- src/lolor.c | 53 ++- src/lolor.h | 8 + src/lolor_largeobject.c | 33 ++ src/lolor_migrate.c | 798 ++++++++++++++++++++++++++++++++++++ t/009_migration.pl | 205 +++++++++ t/010_migration_locking.pl | 139 +++++++ 13 files changed, 2317 insertions(+), 328 deletions(-) create mode 100644 src/lolor_migrate.c create mode 100644 t/009_migration.pl create mode 100644 t/010_migration_locking.pl diff --git a/Makefile b/Makefile index caa2ca0..58bb356 100644 --- a/Makefile +++ b/Makefile @@ -8,7 +8,8 @@ DATA = lolor--1.0.sql \ lolor--1.2.2--1.3.0.sql PGFILEDESC = "lolor - drop in large objects replacement for logical replication" -OBJS = src/lolor.o src/lolor_fsstubs.o src/lolor_inv_api.o src/lolor_largeobject.o +OBJS = src/lolor.o src/lolor_fsstubs.o src/lolor_inv_api.o src/lolor_largeobject.o \ + src/lolor_migrate.o REGRESS = lolor TAP_TESTS = 1 diff --git a/README.md b/README.md index 07861a9..1ab4708 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,23 @@ SELECT lolor.migrate_to_native(); -- manual DROP EXTENSION lolor; -- automatic ``` -Both directions preserve original OIDs, owners, ACLs, and data. +Both directions preserve original OIDs, owners, ACLs, comments and data, and +copy pages verbatim so sparse large objects stay sparse. Moving an object to +native storage also reinstates its `pg_shdepend` entries, so `DROP ROLE`, +`REASSIGN OWNED` and `DROP OWNED` continue to see it. + +`migrate_to_native()` does not require lolor to be enabled; it operates on the +catalogs directly. + +Security labels on large objects cannot be represented in lolor storage and +cannot be reinstated without their label provider, so `migrate_from_native()` +refuses rather than discarding them. Remove them first if you hit this. + +A helper supports verifying a migration across nodes: + +```sql +SELECT * FROM lolor.digest(); -- per-object checksum, compare across nodes +``` When the spock extension is installed, both migration functions run under `spock.repair_mode()`, so the row-shuffling migration DML is **not** @@ -101,6 +117,7 @@ Even with spock, migration is refused if a non-spock logical replication slot own output plugin and those consumers would still decode the migration DML: `migrate_to_native()` raises an `ERROR`, while `migrate_from_native()` warns and returns -1 without doing anything. Drop the offending slots before migrating. +The check identifies spock's slots by their `spock_output` plugin. Without spock, the migration DML cannot be excluded from logical decoding, so both functions refuse to migrate while logical replication slots exist in the @@ -123,5 +140,6 @@ database user. Upgrading to 1.3.0 revokes the privilege; see the release notes. ### Limitations - Native large object functionality cannot be used while you are using the lolor extension. -- lolor does not support the following statements: `ALTER LARGE OBJECT`, `GRANT ON LARGE OBJECT`, `COMMENT ON LARGE OBJECT`, and `REVOKE ON LARGE OBJECT`. -- Large object migration is node-local. Native large objects live in `pg_catalog.pg_largeobject`, which is never replicated, so each node holds an independent set and `migrate_from_native()` migrates only the local node's objects; with spock installed, the migration DML runs in repair mode and is not replicated. Run the migration on every node that holds native large objects — for example with `spock.replicate_ddl('SELECT lolor.migrate_from_native()')`, which queues the command so that each node executes it locally. Migrated objects keep their original native OIDs, which are not node-encoded: if different nodes hold different objects under the same OID, the nodes' lolor contents will diverge and later replicated changes to those objects can conflict. Newly created large objects are collision-free, since new OIDs are node-encoded via `lolor.node` and checked against existing rows. +- lolor does not support the following statements against objects held in lolor storage: `ALTER LARGE OBJECT`, `GRANT ON LARGE OBJECT`, `COMMENT ON LARGE OBJECT`, and `REVOKE ON LARGE OBJECT`. Owners, ACLs and comments set while an object was in native storage are preserved across migration in both directions. +- Large object migration is node-local. Native large objects live in `pg_catalog.pg_largeobject`, which is never replicated, so each node holds an independent set and `migrate_from_native()` migrates only the local node's objects; with spock installed, the migration DML runs in repair mode and is not replicated. Run the migration on every node that holds native large objects, connecting to each node directly. It refuses to run inside an apply worker, so sending it through DDL replication fails on every subscriber. Migrated objects keep their original native OIDs, which are not node-encoded: if different nodes hold different objects under the same OID, the nodes' lolor contents will diverge and later replicated changes to those objects can conflict. Newly created large objects are collision-free, since new OIDs are node-encoded via `lolor.node` and checked against existing rows. To make that hazard an error rather than silent divergence, collect the other nodes' OIDs with `lolor.native_lo_oids()` and pass them in: `SELECT lolor.migrate_from_native(peer_oids => ARRAY[...])` refuses when any of them collide. After migrating every node, compare `lolor.digest()` across nodes to confirm they converged. +- `lolor.migrate_from_native()` and `lolor.migrate_to_native()` refuse while any other session is connected to the database, and refuse to run from anything but a client session, so replicated DDL cannot execute them inside an apply worker. diff --git a/docs/index.md b/docs/index.md index 0232b6f..605172e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -56,7 +56,14 @@ installed: SELECT lolor.migrate_to_native(); ``` -Both paths preserve OIDs, owners, ACLs, and data. +Both paths preserve OIDs, owners, ACLs, comments and data, and copy pages +verbatim so sparse large objects stay sparse. + +A helper supports verifying a migration across nodes: + +```sql +SELECT * FROM lolor.digest(); -- per-object checksum, compare across nodes +``` When the spock extension is installed, both migration functions run under `spock.repair_mode()`, so the row-shuffling migration DML is **not** replicated @@ -82,5 +89,6 @@ retains the changes and delivers them when replication resumes. ## Limitations - Native Postgres large object functionality cannot be used while you are using the lolor extension. -- lolor does not support the following statements: `ALTER LARGE OBJECT`, `GRANT ON LARGE OBJECT`, `COMMENT ON LARGE OBJECT`, and `REVOKE ON LARGE OBJECT`. -- Large object migration is node-local. Native large objects live in `pg_catalog.pg_largeobject`, which is never replicated, so each node holds an independent set and `migrate_from_native()` migrates only the local node's objects; with spock installed, the migration DML runs in repair mode and is not replicated. Run the migration on every node that holds native large objects — for example with `spock.replicate_ddl('SELECT lolor.migrate_from_native()')`, which queues the command so that each node executes it locally. Migrated objects keep their original native OIDs, which are not node-encoded: if different nodes hold different objects under the same OID, the nodes' lolor contents will diverge and later replicated changes to those objects can conflict. Newly created large objects are collision-free, since new OIDs are node-encoded via `lolor.node` and checked against existing rows. +- lolor does not support the following statements against objects held in lolor storage: `ALTER LARGE OBJECT`, `GRANT ON LARGE OBJECT`, `COMMENT ON LARGE OBJECT`, and `REVOKE ON LARGE OBJECT`. Owners, ACLs and comments set while an object was in native storage are preserved across migration in both directions. +- Large object migration is node-local. Native large objects live in `pg_catalog.pg_largeobject`, which is never replicated, so each node holds an independent set and `migrate_from_native()` migrates only the local node's objects; with spock installed, the migration DML runs in repair mode and is not replicated. Run the migration on every node that holds native large objects, connecting to each node directly. It refuses to run inside an apply worker, so sending it through DDL replication fails on every subscriber. Migrated objects keep their original native OIDs, which are not node-encoded: if different nodes hold different objects under the same OID, the nodes' lolor contents will diverge and later replicated changes to those objects can conflict. Newly created large objects are collision-free, since new OIDs are node-encoded via `lolor.node` and checked against existing rows. To make that hazard an error rather than silent divergence, collect the other nodes' OIDs with `lolor.native_lo_oids()` and pass them in: `SELECT lolor.migrate_from_native(peer_oids => ARRAY[...])` refuses when any of them collide. After migrating every node, compare `lolor.digest()` across nodes to confirm they converged. +- `lolor.migrate_from_native()` and `lolor.migrate_to_native()` refuse while any other session is connected to the database, and refuse to run from anything but a client session, so replicated DDL cannot execute them inside an apply worker. diff --git a/docs/lolor_release_notes.md b/docs/lolor_release_notes.md index 0e9d82b..f7535e9 100644 --- a/docs/lolor_release_notes.md +++ b/docs/lolor_release_notes.md @@ -2,16 +2,21 @@ ## lolor 1.3.0 -* Add bidirectional large object migration between native PostgreSQL and lolor storage: - * `lolor.migrate_from_native()` migrates existing native large objects into lolor storage. This is a manual step (run after `CREATE EXTENSION lolor`) and requires superuser privileges. - * `lolor.migrate_to_native()` migrates lolor large objects back to native storage. - * Reverse migration runs automatically on `DROP EXTENSION lolor`, so large objects are never lost when the extension is removed. - * With the spock extension installed, both migration functions run under `spock.repair_mode()` so migration is replication-safe. If a logical replication slot that spock cannot suppress is present — a non-spock slot, or any logical slot when spock is absent — the functions refuse to migrate rather than risk losing objects. - * Migration is node-local: native large objects are never replicated, so each node holds an independent set and `migrate_from_native()` migrates only the local node's objects. Run it on every node that holds native large objects, for example via `spock.replicate_ddl('SELECT lolor.migrate_from_native()')`. Migrated objects keep their original native OIDs, which are not node-encoded and can collide across nodes if different nodes hold different objects under the same OID; newly created large objects are collision-free, since new OIDs are node-encoded via `lolor.node` and checked against existing rows. +* Add bidirectional large object migration between native PostgreSQL and lolor storage, implemented as a direct relation-to-relation copy in C (`lolor.migrate_storage()`) rather than a loop through the large object API: + * `lolor.migrate_from_native(peer_oids oid[] DEFAULT NULL)` moves native large objects into lolor storage. It is a manual step after `CREATE EXTENSION lolor`, requires superuser, and returns the number of objects moved or -1 if the migration was refused. + * `lolor.migrate_to_native()` moves them back, whether or not lolor is enabled. It also still runs automatically on `DROP EXTENSION lolor`. + * Both directions preserve OIDs, owners, ACLs, comments and the exact page layout, so sparse objects stay sparse. Ownership and ACLs are recorded in `pg_shdepend` on the way back, so `DROP ROLE`, `REASSIGN OWNED` and `DROP OWNED` see the objects again. Comments are parked in `lolor.pg_largeobject_description` while an object is in lolor storage. Native objects are removed through `performMultipleDeletions()`, the path `DROP` uses, so their dependencies, comments and labels are cleaned up. + * Storage layouts are verified against the running server at migration time. Security labels, which lolor storage cannot represent, cause `migrate_from_native()` to refuse. OID conflicts are reported with the conflicting OIDs. + * Migration holds `ShareRowExclusiveLock` on both stores until commit. Ordinary large object access takes `RowExclusiveLock`, which does not conflict with itself, so a concurrent `lo_write()` could otherwise be lost silently. Opposing migrations take their locks in a fixed order and cannot deadlock. + * Both migration functions refuse while any other session is connected to the database, and refuse to run from anything but a client session. + * With spock installed, both functions run under `spock.repair_mode()`. If a logical slot spock cannot suppress exists, a non-spock slot or any slot when spock is absent, they refuse rather than let subscribers decode the migration as row changes. + * Migration is node-local: native large objects are never replicated, so run the migration on each node by connecting to it directly. Do not send it through DDL replication, which would run it inside every subscriber's apply worker. Native OIDs are not node-encoded, so pass the other nodes' OIDs (from `lolor.native_lo_oids()`) as `peer_oids` to turn a cross-node collision into a refusal, and compare `lolor.digest()` across nodes afterwards. + * `migrate_to_native()` refuses with a clear error while any object refers to a dropped role, instead of failing part-way with "role N was concurrently dropped"; remove those objects with `lo_unlink()` and retry. +* New helpers `lolor.digest()` and `lolor.native_lo_oids()` for cross-node checks after a node-local migration. * **Security fix: `lo_import()` and `lo_export()` were executable by any database user.** These read and write files on the server as the account PostgreSQL runs under, so core revokes `EXECUTE` on them from `PUBLIC`. lolor replaces them by renaming the originals to `*_orig`; an ACL belongs to a function rather than a name, so the restriction stayed on the parked original while each replacement got the default `EXECUTE TO PUBLIC`. Any user could read an arbitrary server file with `lo_import()` or overwrite one with `lo_export()`. The replacements are now locked down at install time, and upgrading revokes the privilege on existing installations in either state. Versions 1.0 through 1.2.2 are affected. * The extension is no longer marked `trusted`. Installing lolor renames functions in `pg_catalog` for the whole database, which a non-superuser should not be able to do. * Fixed the `lolor.node` upper bound. The GUC accepted 0..16 while a generated OID reserves four bits for the node id, so node 16 did not fit: the node field of every OID it generated read back as 0. The bound is now derived from the encoding (`LOLOR_MAX_NODE_ID`), giving 0..15. **If you have `lolor.node = 16` configured**, the server still starts but logs `16 is outside the valid range for parameter "lolor.node" (0 .. 15)` and falls back to 0, which is the node id it was effectively using already. Set it to a value in 0..15, and check for OID collisions against whichever node is genuinely 0. -* Expanded test coverage: TAP tests for dump/restore, streaming and logical replication, and standby promotion; regression tests for `lo_lseek`, `lo_tell`, and `lo_truncate`. +* Expanded test coverage: TAP tests for dump/restore, streaming and logical replication, standby promotion, migration fidelity and locking, and cross-node OID collisions; regression tests for `lo_lseek`, `lo_tell`, `lo_truncate`, sparse and multi-page objects, comment round trips and the migration helpers. * Security hardening: addressed Codacy/Flawfinder warnings. ## lolor 1.2.2 diff --git a/expected/lolor.out b/expected/lolor.out index 674ec34..03e7f87 100644 --- a/expected/lolor.out +++ b/expected/lolor.out @@ -217,7 +217,7 @@ SELECT lo_close(:fd); END; DROP EXTENSION lolor; -NOTICE: migrated 4 large object(s) from lolor to native storage +NOTICE: migrated 4 large object(s), 4 data page(s), to native storage -- Check extension upgrade CREATE EXTENSION lolor VERSION '1.0'; SELECT lo_creat(-1) AS loid \gset @@ -244,14 +244,14 @@ END; ALTER EXTENSION lolor UPDATE TO '1.3.0'; -- Verify migration functions are available after upgrade SELECT lolor.migrate_to_native(); -- One LO object has been created before LOLOR -NOTICE: migrated 1 large object(s) from lolor to native storage +NOTICE: migrated 1 large object(s), 1 data page(s), to native storage migrate_to_native ------------------- 1 (1 row) SELECT lolor.migrate_from_native(); -- two objects -NOTICE: migrated 5 large object(s) (5 data page(s)) from native to lolor storage +NOTICE: migrated 5 large object(s), 5 data page(s), to lolor storage migrate_from_native --------------------- 5 @@ -259,14 +259,14 @@ NOTICE: migrated 5 large object(s) (5 data page(s)) from native to lolor storag -- Repeat conversion cycle - should see the same two objects SELECT lolor.migrate_to_native(); -NOTICE: migrated 5 large object(s) from lolor to native storage +NOTICE: migrated 5 large object(s), 5 data page(s), to native storage migrate_to_native ------------------- 5 (1 row) SELECT lolor.migrate_from_native(); -NOTICE: migrated 5 large object(s) (5 data page(s)) from native to lolor storage +NOTICE: migrated 5 large object(s), 5 data page(s), to lolor storage migrate_from_native --------------------- 5 @@ -336,16 +336,19 @@ SELECT lolor.enable(); -- Check that no tails existing after the extension drop in both enabled and -- disabled states. DROP EXTENSION lolor; -NOTICE: migrated 6 large object(s) from lolor to native storage +NOTICE: migrated 6 large object(s), 6 data page(s), to native storage SELECT oid, proname FROM pg_proc WHERE proname IN ('lo_open_orig', 'lolor_lo_open'); oid | proname -----+--------- (0 rows) --- Check: we can't just delete LOLOR without LO migration in disabled mode. --- XXX: should we introduce a 'forced' flag to allow this? +-- DROP EXTENSION while lolor is disabled. The plpgsql migration failed here +-- with "lolor must be enabled before migration to native" because it went +-- through the renamed _orig functions; the relation copy works against the +-- catalogs directly, so the drop migrates and succeeds in either state. CREATE EXTENSION lolor; +SELECT lo_from_bytea(0, 'stored before disabling') AS disabled_drop_oid \gset SELECT lolor.disable(); disable --------- @@ -353,26 +356,24 @@ SELECT lolor.disable(); (1 row) DROP EXTENSION lolor; -ERROR: lolor must be enabled before migration to native -SELECT extname FROM pg_extension; -- lolor is here +NOTICE: migrated 1 large object(s), 1 data page(s), to native storage +SELECT extname FROM pg_extension; -- check lolor removal extname --------- plpgsql - lolor -(2 rows) +(1 row) -SELECT lolor.enable(); - enable --------- - t +-- The object was migrated to native storage, not dropped with lolor's tables +SELECT convert_from(lo_get(:disabled_drop_oid), 'UTF8') AS survived_disabled_drop; + survived_disabled_drop +------------------------- + stored before disabling (1 row) -DROP EXTENSION lolor; -NOTICE: no lolor large objects to migrate -SELECT extname FROM pg_extension; -- check lolor removal - extname ---------- - plpgsql +SELECT lo_unlink(:disabled_drop_oid); + lo_unlink +----------- + 1 (1 row) -- @@ -391,7 +392,7 @@ SELECT count(*) AS native_lo_count FROM pg_catalog.pg_largeobject_metadata; -- Install lolor and migrate native LOs into lolor storage CREATE EXTENSION lolor; SELECT lolor.migrate_from_native(); -NOTICE: migrated 9 large object(s) (9 data page(s)) from native to lolor storage +NOTICE: migrated 9 large object(s), 9 data page(s), to lolor storage migrate_from_native --------------------- 9 @@ -446,7 +447,7 @@ 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 10 large object(s) from lolor to native storage +NOTICE: migrated 10 large object(s), 10 data page(s), to native storage SELECT count(*) AS native_after_drop FROM pg_catalog.pg_largeobject_metadata; native_after_drop ------------------- @@ -495,21 +496,21 @@ SELECT lo_unlink(:'lolor_direct_oid'::oid); CREATE EXTENSION lolor; SELECT lolor.migrate_from_native(); -NOTICE: migrated 7 large object(s) (7 data page(s)) from native to lolor storage +NOTICE: migrated 7 large object(s), 7 data page(s), to lolor storage migrate_from_native --------------------- 7 (1 row) DROP EXTENSION lolor; -NOTICE: migrated 7 large object(s) from lolor to native storage +NOTICE: migrated 7 large object(s), 7 data page(s), to native storage -- -- Manual migrate_to_native (not via DROP EXTENSION) -- CREATE EXTENSION lolor; SELECT lo_from_bytea(0, 'Manual reverse test') AS manual_oid \gset SELECT lolor.migrate_to_native(); -NOTICE: migrated 1 large object(s) from lolor to native storage +NOTICE: migrated 1 large object(s), 1 data page(s), to native storage migrate_to_native ------------------- 1 @@ -569,7 +570,7 @@ INSERT INTO lolor.pg_largeobject_metadata (oid, lomowner, lomacl) VALUES (:'conflict_oid', (SELECT oid FROM pg_roles WHERE rolname = current_user), NULL); -- This should fail with OID conflict SELECT lolor.migrate_from_native(); -ERROR: OID conflict: some native large objects already exist in lolor storage +ERROR: 1 large object already exists in the destination storage -- Cleanup: remove the conflicting row and drop cleanly DELETE FROM lolor.pg_largeobject_metadata WHERE oid = :'conflict_oid'; DROP EXTENSION lolor; @@ -606,7 +607,7 @@ SELECT lolor.enable(); -- migrate_to_native should detect the collision SELECT lolor.migrate_to_native(); -ERROR: OID conflict: some lolor large objects already exist in native storage +ERROR: 1 large object already exists in the destination storage -- Cleanup: remove the native duplicate, then drop cleanly SELECT lolor.disable(); disable @@ -627,7 +628,7 @@ SELECT lolor.enable(); (1 row) DROP EXTENSION lolor; -NOTICE: migrated 1 large object(s) from lolor to native storage +NOTICE: migrated 1 large object(s), 1 data page(s), to native storage -- DROP EXTENSION should be rejected when migrate_to_native has OID conflict CREATE EXTENSION lolor; SELECT lo_from_bytea(0, 'Drop conflict test') AS drop_conflict_oid \gset @@ -653,7 +654,7 @@ SELECT lolor.enable(); -- DROP EXTENSION should ERROR to prevent data loss DROP EXTENSION lolor; -ERROR: OID conflict: some lolor large objects already exist in native storage +ERROR: 1 large object already exists in the destination storage -- Extension should still be installed SELECT extname FROM pg_extension WHERE extname = 'lolor'; extname @@ -689,7 +690,7 @@ SELECT lolor.enable(); -- Now DROP should succeed DROP EXTENSION lolor; -NOTICE: migrated 1 large object(s) from lolor to native storage +NOTICE: migrated 1 large object(s), 1 data page(s), to native storage -- -- lo_import() and lo_export() read and write files on the server, so core -- revokes EXECUTE on them from PUBLIC. lolor replaces them by renaming the @@ -1056,3 +1057,531 @@ ERROR: large object 0 does not exist ROLLBACK; DROP EXTENSION lolor; NOTICE: no lolor large objects to migrate +-- +-- Fidelity of the storage relocation +-- +CREATE EXTENSION lolor; +CREATE ROLE lolor_owner; +CREATE ROLE lolor_grantee; +-- A sparse object: two pages holding a 10 MB logical object. Migration must +-- not fill the hole; the old implementation rewrote it through the LO API and +-- materialised every intervening page. +SELECT lolor.disable(); + disable +--------- + t +(1 row) + +SELECT lo_create(0) AS sparse_oid \gset +BEGIN; +SELECT lo_open(:sparse_oid, x'60000'::int) AS fd \gset +SELECT lowrite(:fd, 'start'); + lowrite +--------- + 5 +(1 row) + +SELECT lo_lseek64(:fd, 10000000, 0); + lo_lseek64 +------------ + 10000000 +(1 row) + +SELECT lowrite(:fd, 'end'); + lowrite +--------- + 3 +(1 row) + +SELECT lo_close(:fd); + lo_close +---------- + 0 +(1 row) + +END; +-- An object carrying owner, ACL and a comment +SELECT lo_from_bytea(0, 'annotated object') AS annotated_oid \gset +ALTER LARGE OBJECT :annotated_oid OWNER TO lolor_owner; +GRANT SELECT ON LARGE OBJECT :annotated_oid TO lolor_grantee; +COMMENT ON LARGE OBJECT :annotated_oid IS 'kept across migration'; +-- An object with no data pages at all +SELECT lo_create(0) AS empty_oid \gset +SELECT lolor.enable(); + enable +-------- + t +(1 row) + +SELECT lolor.migrate_from_native(); +NOTICE: migrated 12 large object(s), 12 data page(s), to lolor storage + migrate_from_native +--------------------- + 12 +(1 row) + +-- Sparse object keeps its exact page count (2), not 4883 +SELECT count(*) AS sparse_pages FROM lolor.pg_largeobject WHERE loid = :sparse_oid; + sparse_pages +-------------- + 2 +(1 row) + +SELECT lo_get(:sparse_oid) IS NOT NULL AS sparse_readable; + sparse_readable +----------------- + t +(1 row) + +SELECT length(lo_get(:sparse_oid)) AS sparse_length; + sparse_length +--------------- + 10000003 +(1 row) + +SELECT length(lo_get(:empty_oid)) AS empty_length; + empty_length +-------------- + 0 +(1 row) + +-- Owner and ACL survive; the comment is parked for the round trip +SELECT pg_get_userbyid(lomowner) AS owner, lomacl IS NOT NULL AS has_acl +FROM lolor.pg_largeobject_metadata WHERE oid = :annotated_oid; + owner | has_acl +-------------+--------- + lolor_owner | t +(1 row) + +SELECT description FROM lolor.pg_largeobject_description WHERE loid = :annotated_oid; + description +----------------------- + kept across migration +(1 row) + +-- Native side is fully cleaned up, including shared deps and comments +SELECT count(*) AS native_objs FROM pg_catalog.pg_largeobject_metadata; + native_objs +------------- + 0 +(1 row) + +SELECT count(*) AS native_shdep FROM pg_shdepend + WHERE classid = 'pg_largeobject'::regclass + AND dbid = (SELECT oid FROM pg_database WHERE datname = current_database()); + native_shdep +-------------- + 0 +(1 row) + +SELECT count(*) AS native_comments FROM pg_description + WHERE classoid = 'pg_largeobject'::regclass; + native_comments +----------------- + 0 +(1 row) + +-- Back to native storage +SELECT lolor.migrate_to_native(); +NOTICE: migrated 12 large object(s), 12 data page(s), to native storage + migrate_to_native +------------------- + 12 +(1 row) + +-- Ownership is recorded in pg_shdepend, not merely in lomowner: a raw catalog +-- UPDATE would leave DROP ROLE unable to see the object. +SELECT pg_get_userbyid(lomowner) AS owner +FROM pg_catalog.pg_largeobject_metadata WHERE oid = :annotated_oid; + owner +------------- + lolor_owner +(1 row) + +SELECT deptype, refobjid::regrole::text AS role FROM pg_shdepend + WHERE classid = 'pg_largeobject'::regclass AND objid = :annotated_oid + AND dbid = (SELECT oid FROM pg_database WHERE datname = current_database()) + ORDER BY deptype; + deptype | role +---------+--------------- + a | lolor_grantee + o | lolor_owner +(2 rows) + +SELECT description FROM pg_description + WHERE classoid = 'pg_largeobject'::regclass AND objoid = :annotated_oid; + description +----------------------- + kept across migration +(1 row) + +-- DROP ROLE must refuse for both the owner and the ACL grantee +DROP ROLE lolor_owner; +ERROR: role "lolor_owner" cannot be dropped because some objects depend on it +DROP ROLE lolor_grantee; +ERROR: role "lolor_grantee" cannot be dropped because some objects depend on it +-- Sparse object is still sparse after the round trip +SELECT count(*) AS sparse_pages_native FROM pg_catalog.pg_largeobject + WHERE loid = :sparse_oid; + sparse_pages_native +--------------------- + 2 +(1 row) + +-- Comment parking table is emptied once the comments are reinstated +SELECT count(*) AS parked_left FROM lolor.pg_largeobject_description; + parked_left +------------- + 0 +(1 row) + +-- Cleanup +SELECT lolor.disable(); + disable +--------- + t +(1 row) + +SELECT lo_unlink(:sparse_oid); + lo_unlink +----------- + 1 +(1 row) + +SELECT lo_unlink(:annotated_oid); + lo_unlink +----------- + 1 +(1 row) + +SELECT lo_unlink(:empty_oid); + lo_unlink +----------- + 1 +(1 row) + +SELECT lolor.enable(); + enable +-------- + t +(1 row) + +SELECT lolor.migrate_to_native(); +NOTICE: no lolor large objects to migrate + migrate_to_native +------------------- + 0 +(1 row) + +DROP EXTENSION lolor; +NOTICE: no lolor large objects to migrate +DROP ROLE lolor_owner; +DROP ROLE lolor_grantee; +-- +-- A parked comment must not outlive the object it describes. Object OIDs are +-- only checked against pg_largeobject_metadata when a new one is generated, so +-- a comment left behind by lo_unlink() would be handed to whatever object next +-- took that OID. +-- +CREATE EXTENSION lolor; +SELECT lolor.disable(); + disable +--------- + t +(1 row) + +SELECT lo_from_bytea(0, 'has a comment') AS commented_oid \gset +COMMENT ON LARGE OBJECT :commented_oid IS 'parked then orphaned'; +SELECT lolor.enable(); + enable +-------- + t +(1 row) + +SELECT lolor.migrate_from_native(); +NOTICE: migrated 10 large object(s), 10 data page(s), to lolor storage + migrate_from_native +--------------------- + 10 +(1 row) + +SELECT count(*) AS parked FROM lolor.pg_largeobject_description + WHERE loid = :commented_oid; + parked +-------- + 1 +(1 row) + +-- Unlinking must take the parked comment with it. +SELECT lo_unlink(:commented_oid); + lo_unlink +----------- + 1 +(1 row) + +SELECT count(*) AS parked_after_unlink FROM lolor.pg_largeobject_description + WHERE loid = :commented_oid; + parked_after_unlink +--------------------- + 0 +(1 row) + +-- Re-create an object under the very same OID and send it back to native +-- storage. It must arrive with no comment. +SELECT lo_create(:commented_oid) = :commented_oid AS oid_reused; + oid_reused +------------ + t +(1 row) + +SELECT lolor.migrate_to_native(); +NOTICE: migrated 10 large object(s), 9 data page(s), to native storage + migrate_to_native +------------------- + 10 +(1 row) + +SELECT count(*) AS inherited_comment FROM pg_description + WHERE classoid = 'pg_largeobject'::regclass AND objoid = :commented_oid; + inherited_comment +------------------- + 0 +(1 row) + +SELECT lolor.disable(); + disable +--------- + t +(1 row) + +SELECT lo_unlink(:commented_oid); + lo_unlink +----------- + 1 +(1 row) + +SELECT lolor.enable(); + enable +-------- + t +(1 row) + +SELECT lolor.migrate_to_native(); +NOTICE: no lolor large objects to migrate + migrate_to_native +------------------- + 0 +(1 row) + +DROP EXTENSION lolor; +NOTICE: no lolor large objects to migrate +-- +-- Verification helpers. +-- +-- Start from a clean slate so the counts below do not depend on what earlier +-- sections happened to leave behind. The notices carry those counts, so they +-- are suppressed for the duration. +CREATE EXTENSION lolor; +SET client_min_messages = warning; +SELECT lolor.migrate_to_native() IS NOT NULL AS drained_to_native; + drained_to_native +------------------- + t +(1 row) + +SELECT lolor.disable(); + disable +--------- + t +(1 row) + +DO $$ +DECLARE r record; +BEGIN + FOR r IN SELECT oid FROM pg_catalog.pg_largeobject_metadata LOOP + PERFORM lo_unlink(r.oid); + END LOOP; +END +$$; +SELECT lolor.enable(); + enable +-------- + t +(1 row) + +RESET client_min_messages; +SELECT count(*) AS native_oids_when_empty FROM lolor.native_lo_oids(); + native_oids_when_empty +------------------------ + 0 +(1 row) + +-- Both directions report zero rather than failing when there is nothing. +SELECT lolor.migrate_to_native() AS nothing_to_move; +NOTICE: no lolor large objects to migrate + nothing_to_move +----------------- + 0 +(1 row) + +SELECT lolor.migrate_from_native() AS nothing_to_take; +NOTICE: no native large objects to migrate + nothing_to_take +----------------- + 0 +(1 row) + +-- digest() reports one row per object. The OID and owner vary between runs; +-- the page count, byte count and content digest do not. +SELECT lo_from_bytea(0, 'digest one') AS d1 \gset +SELECT lo_from_bytea(0, 'digest two, longer') AS d2 \gset +SELECT npages, nbytes, digest FROM lolor.digest() ORDER BY nbytes, digest; + npages | nbytes | digest +--------+--------+---------------------------------- + 1 | 10 | be0bbea1ab8a74be81d08e575166e9e4 + 1 | 18 | ce7a8cf8a81e8ec180a8a8a224f473c6 +(2 rows) + +SELECT lo_unlink(:d1); + lo_unlink +----------- + 1 +(1 row) + +SELECT lo_unlink(:d2); + lo_unlink +----------- + 1 +(1 row) + +-- migrate_storage() is the mechanism behind both migration functions. Grant +-- schema access so that the function's own privileges are what is exercised +-- rather than USAGE on the schema. +CREATE ROLE lolor_nosuper; +GRANT USAGE ON SCHEMA lolor TO lolor_nosuper; +SET ROLE lolor_nosuper; +SELECT lolor.migrate_storage(true); +ERROR: permission denied for function migrate_storage +SELECT lolor.migrate_from_native(); +ERROR: permission denied for function migrate_from_native +SELECT lolor.migrate_to_native(); +ERROR: permission denied for function migrate_to_native +RESET ROLE; +REVOKE USAGE ON SCHEMA lolor FROM lolor_nosuper; +DROP ROLE lolor_nosuper; +-- Native OIDs are not node-encoded, so two nodes can hold different objects +-- under the same OID. Passing the peer OIDs makes that a refusal instead of +-- silent divergence once the migration is hidden from replication. +SELECT lolor.disable(); + disable +--------- + t +(1 row) + +SELECT lo_create(0) AS peer_oid \gset +SELECT lolor.enable(); + enable +-------- + t +(1 row) + +SELECT lolor.migrate_from_native(peer_oids => ARRAY[:peer_oid]::oid[]); +ERROR: cannot migrate: 1 OID(s) are also held natively by another node +-- Security labels cannot be represented in lolor storage and cannot be +-- reinstated without their provider, so migration refuses rather than +-- discarding them. +INSERT INTO pg_catalog.pg_seclabel (objoid, classoid, objsubid, provider, label) +VALUES (:peer_oid, 'pg_catalog.pg_largeobject'::regclass, 0, 'lolor_test', 'secret'); +SELECT lolor.migrate_from_native(); +ERROR: cannot migrate: 1 large object security label(s) present +DELETE FROM pg_catalog.pg_seclabel + WHERE classoid = 'pg_catalog.pg_largeobject'::regclass AND provider = 'lolor_test'; +-- With the label gone the migration proceeds. +SELECT lolor.migrate_from_native() AS migrated; +NOTICE: migrated 1 large object(s), 0 data page(s), to lolor storage + migrated +---------- + 1 +(1 row) + +SELECT lo_unlink(:peer_oid); + lo_unlink +----------- + 1 +(1 row) + +SELECT lolor.migrate_to_native(); +NOTICE: no lolor large objects to migrate + migrate_to_native +------------------- + 0 +(1 row) + +DROP EXTENSION lolor; +NOTICE: no lolor large objects to migrate +-- +-- A role can be dropped while objects that refer to it sit in lolor storage, +-- because those objects are not in pg_shdepend. Migrating them back would +-- otherwise die inside shdepLockAndCheckObject() with "role N was concurrently +-- dropped", which also blocks DROP EXTENSION. The migration must refuse up +-- front with a clear message instead. +-- +CREATE EXTENSION lolor; +CREATE ROLE lolor_dead_owner; +CREATE ROLE lolor_dead_grantee; +-- Both objects are created in native storage and migrated in: ACLs can only +-- be set there, and this change does not touch what lolor records in +-- pg_shdepend for objects created through it. +SELECT lolor.disable(); + disable +--------- + t +(1 row) + +-- Owned by a role that is about to be dropped +SET ROLE lolor_dead_owner; +SELECT lo_from_bytea(0, 'orphaned owner') AS dead_owner_oid \gset +RESET ROLE; +-- Granted to a role that is about to be dropped +SELECT lo_from_bytea(0, 'orphaned grantee') AS dead_grantee_oid \gset +GRANT SELECT ON LARGE OBJECT :dead_grantee_oid TO lolor_dead_grantee; +SELECT lolor.enable(); + enable +-------- + t +(1 row) + +SELECT lolor.migrate_from_native(); +NOTICE: migrated 2 large object(s), 2 data page(s), to lolor storage + migrate_from_native +--------------------- + 2 +(1 row) + +DROP ROLE lolor_dead_owner; +DROP ROLE lolor_dead_grantee; +-- Migration refuses up front rather than failing mid-copy +SELECT lolor.migrate_to_native(); +ERROR: 2 large objects refer to roles that no longer exist +-- With the affected objects gone there is nothing left to refuse +SELECT lo_unlink(:dead_owner_oid); + lo_unlink +----------- + 1 +(1 row) + +SELECT lo_unlink(:dead_grantee_oid); + lo_unlink +----------- + 1 +(1 row) + +SELECT lolor.migrate_to_native(); +NOTICE: no lolor large objects to migrate + migrate_to_native +------------------- + 0 +(1 row) + +DROP EXTENSION lolor; +NOTICE: no lolor large objects to migrate diff --git a/lolor--1.2.2--1.3.0.sql b/lolor--1.2.2--1.3.0.sql index 53fc468..cb93b43 100644 --- a/lolor--1.2.2--1.3.0.sql +++ b/lolor--1.2.2--1.3.0.sql @@ -48,169 +48,232 @@ END; $$; /* - * lolor.migrate_from_native() - * - * Migrate all native PostgreSQL large objects from pg_catalog.pg_largeobject - * into lolor's storage, preserving original OIDs, owners, ACLs, and data. - * After migration, the native copies are removed. - * - * The entire operation is transactional: if anything fails, ROLLBACK undoes - * all changes and no data is lost. - * - * Returns the number of large objects migrated, or -1 if migration was - * refused because logical replication slots exist whose decoding of the - * migration DML cannot be suppressed: either spock is not available to - * silence its own slots, or a non-spock slot (e.g. pgoutput, wal2json) is - * present that spock.repair_mode() cannot exclude. - * - * Refusal is reported as a soft -1 return (alongside a WARNING), not an - * error, and this asymmetry with migrate_to_native() is deliberate. This - * function is a manual, non-destructive operation: on refusal the native - * large objects are left untouched, so the caller can drop the offending - * slots and simply retry. - * - * Callers acting on the result MUST check for a non-positive return: a -1 - * means nothing was migrated, and ignoring it treats a refused migration - * as success. + * Shared precondition for enable(), disable() and the migration functions. + * Defined with CREATE OR REPLACE so that the lifecycle change set can carry an + * identical copy; whichever lands second is a no-op. */ -CREATE FUNCTION lolor.migrate_from_native() -RETURNS bigint AS $$ +CREATE OR REPLACE FUNCTION lolor._require_no_other_sessions(what text) +RETURNS void AS $$ DECLARE - lo_count bigint; - inserted_count bigint; - page_count bigint; - native_page_count bigint; - repair_enabled boolean := false; - lr_slots_exists boolean := false; - foreign_lr_slots_exists boolean := false; + n int; + self text; BEGIN - -- Only superusers can read pg_largeobject.data and unlink others' objects - IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = current_user AND rolsuper) THEN - RAISE EXCEPTION 'lolor.migrate_from_native() requires superuser privileges'; + -- A background worker, such as an apply worker executing replicated DDL, + -- is not a client session and must not do this at all: the change is + -- node-local and a refusal would leave that worker retrying forever. + SELECT backend_type INTO self + FROM pg_catalog.pg_stat_activity + WHERE pid = pg_backend_pid(); + + IF self IS DISTINCT FROM 'client backend' THEN + RAISE EXCEPTION 'cannot % from a %', what, coalesce(self, 'non-client process') + USING HINT = 'Run it from a client session connected directly to this node.'; END IF; - -- Verify lolor is enabled (functions are replaced) - IF NOT lolor.is_enabled() THEN - RAISE EXCEPTION 'lolor must be enabled before migration'; + SELECT count(*) INTO n + FROM pg_catalog.pg_stat_activity + WHERE datname = current_database() + AND backend_type = 'client backend' + AND pid <> pg_backend_pid(); + + IF n > 0 THEN + RAISE EXCEPTION 'cannot % while other sessions are connected to the database', what + USING DETAIL = CASE WHEN n = 1 THEN '1 other session is connected.' + ELSE n || ' other sessions are connected.' END, + HINT = 'Disconnect them first. A session that already resolved the ' + 'large object functions keeps calling the previous implementation.'; END IF; +END; +$$ LANGUAGE plpgsql VOLATILE; - -- Check for OID conflicts: native LOs that already exist in lolor storage - IF EXISTS ( - SELECT 1 - FROM pg_catalog.pg_largeobject_metadata native - JOIN lolor.pg_largeobject_metadata lm ON lm.oid = native.oid - ) THEN - RAISE EXCEPTION 'OID conflict: some native large objects already exist in lolor storage'; - END IF; +REVOKE ALL ON FUNCTION lolor._require_no_other_sessions(text) FROM PUBLIC; - -- Count what we are about to migrate - SELECT count(*) INTO lo_count FROM pg_catalog.pg_largeobject_metadata; - IF lo_count = 0 THEN - RAISE NOTICE 'no native large objects to migrate'; - RETURN 0; - END IF; +/* + * Comment parking. + * + * COMMENT ON LARGE OBJECT stores its text in pg_description keyed by + * (classoid = 'pg_largeobject', objoid = loid). While an object lives in + * lolor storage there is no catalog object for that row to describe, so the + * comment is parked here and restored on the way back. Without this it is + * silently lost the first time an object is migrated. + */ +CREATE TABLE lolor.pg_largeobject_description( + loid oid NOT NULL, + description text NOT NULL, + CONSTRAINT pg_largeobject_description_pkey PRIMARY KEY (loid)); +SELECT pg_catalog.pg_extension_config_dump('lolor.pg_largeobject_description', ''); - -- Logical slots indicate subscribers (replica nodes) that will not receive - -- the node-local migration DML. - SELECT EXISTS ( - SELECT 1 FROM pg_catalog.pg_replication_slots - WHERE slot_type = 'logical' AND database = current_database() - ) INTO lr_slots_exists; +/* + * Storage relocation mechanism. + * + * lolor.migrate_storage() moves every large object from one store to the + * other by copying tuples directly between the two relations, which have + * identical layouts by construction. It carries ownership, ACLs and comments + * across, verifies the layouts at run time, and removes native objects + * through the same deletion path DROP uses. + * + * This is the mechanism only. Policy -- privileges and the interaction with + * logical replication -- lives in the wrappers below, which are the supported + * entry points. + */ +CREATE FUNCTION lolor.migrate_storage(to_native boolean) + RETURNS bigint + AS 'MODULE_PATHNAME', 'lolor_migrate_storage' + LANGUAGE C STRICT VOLATILE; - -- XXX: there are no evidence that the 'spock' name has been used anywhere. - -- Fix this mess later. +REVOKE ALL ON FUNCTION lolor.migrate_storage(boolean) FROM PUBLIC; + +/* + * Shared replication guard. + * + * Both migration directions face the same question: the row movement is a + * node-local storage relocation, but logical decoding cannot tell that apart + * from ordinary DML. If a subscriber decodes it, the two nodes diverge. + * + * Returns true when repair mode was engaged and the caller must turn it off, + * false when no suppression was needed, and NULL when the migration must be + * refused. NULL is only ever returned when strict_mode is false; a strict + * caller gets an ERROR instead. + */ +CREATE FUNCTION lolor._migration_guard(strict_mode boolean, refuse_hint text) +RETURNS boolean AS $$ +DECLARE + lr_slots boolean; + foreign_slots boolean; + spock_ready boolean; +BEGIN SELECT EXISTS ( SELECT 1 FROM pg_catalog.pg_replication_slots - WHERE slot_type = 'logical' AND database = current_database() - AND plugin NOT IN ('spock_output', 'spock') - ) INTO foreign_lr_slots_exists; + WHERE slot_type = 'logical' AND database = current_database() + ) INTO lr_slots; + + -- Nothing decodes this database: nothing to suppress. + IF NOT lr_slots THEN + RETURN false; + END IF; - -- Suppress spock replication of the bulk migration DML. The migration only - -- shuffles rows between native and lolor storage on this node. - -- -- Use spock only when it is fully operational: the extension is installed -- (pg_extension is superuser-gated, so the schema name cannot be squatted -- by an unprivileged user), the function exists (older spock versions lack - -- it) and the GUC exists (the library is actually preloaded). Anything - -- less falls through to the refusal branch below. - -- - -- Without spock the migration DML cannot be excluded from logical decoding, - -- so if any logical replication slot exists the migrated rows would leak to - -- subscribers. - IF EXISTS (SELECT 1 FROM pg_catalog.pg_extension WHERE extname = 'spock') - AND to_regprocedure('spock.repair_mode(boolean)') IS NOT NULL - AND current_setting('spock.replication_repair_mode', true) IS NOT NULL - THEN - -- If a non-spock logical slot is present its consumer would still decode - -- the bulk migration DML and receive the node-local row shuffling. - -- Refuse rather than leak the migration to that subscriber. - IF foreign_lr_slots_exists THEN - RAISE WARNING 'not migrating: non-spock logical replication slot(s) exist' - USING DETAIL = 'This call is a no-op: no large objects were migrated. ' - 'spock repair mode cannot exclude the migration DML from ' - 'non-spock output plugins (e.g. pgoutput, wal2json), so the ' - 'migrated rows would leak to those subscribers', - HINT = 'Drop all non-spock logical replication slots in this database before executing this procedure'; - RETURN -1; + -- it) and the GUC exists (the library is actually preloaded). + spock_ready := + EXISTS (SELECT 1 FROM pg_catalog.pg_extension WHERE extname = 'spock') + AND to_regprocedure('spock.repair_mode(boolean)') IS NOT NULL + AND current_setting('spock.replication_repair_mode', true) IS NOT NULL; + + IF NOT spock_ready THEN + IF strict_mode THEN + RAISE EXCEPTION 'cannot migrate large objects: logical replication slot(s) exist' + USING DETAIL = 'Without spock the migration DML cannot be excluded from ' + 'logical decoding, so subscribers would receive this ' + 'node-local storage relocation as ordinary row changes', + HINT = refuse_hint; END IF; + RAISE WARNING 'not migrating: logical replication slot(s) exist' + USING DETAIL = 'This call is a no-op: no large objects were migrated', + HINT = refuse_hint; + RETURN NULL; + END IF; - IF current_setting('spock.replication_repair_mode', true) = 'off' THEN - PERFORM spock.repair_mode(true); - repair_enabled := true; + -- spock.repair_mode() suppresses spock's own 'spock_output' plugin only; + -- any other plugin decoding this database would still see the migration. + SELECT EXISTS ( + SELECT 1 FROM pg_catalog.pg_replication_slots + WHERE slot_type = 'logical' AND database = current_database() + AND plugin <> 'spock_output' + ) INTO foreign_slots; + + IF foreign_slots THEN + IF strict_mode THEN + RAISE EXCEPTION 'cannot migrate large objects: non-spock logical replication slot(s) exist' + USING DETAIL = 'spock repair mode silences only the spock_output plugin; a slot ' + 'using another plugin (pgoutput, wal2json, decoderbufs, ...) would ' + 'still decode the migration and diverge from this node', + HINT = refuse_hint; END IF; - ELSIF lr_slots_exists THEN - RAISE WARNING 'not migrating: logical replication slot(s) exist' + RAISE WARNING 'not migrating: non-spock logical replication slot(s) exist' USING DETAIL = 'This call is a no-op: no large objects were migrated', - HINT = 'Drop all logical replication slots in this database before executing this procedure'; - RETURN -1; + HINT = refuse_hint; + RETURN NULL; + END IF; + + IF current_setting('spock.replication_repair_mode', true) = 'off' THEN + PERFORM spock.repair_mode(true); + RETURN true; END IF; - SELECT count(*) INTO native_page_count - FROM pg_catalog.pg_largeobject; + -- Repair mode was already on; leave it to whoever turned it on. + RETURN false; +END; +$$ LANGUAGE plpgsql VOLATILE; - -- Copy metadata (preserving OIDs, owners, and ACLs) - INSERT INTO lolor.pg_largeobject_metadata (oid, lomowner, lomacl) - SELECT oid, lomowner, lomacl - FROM pg_catalog.pg_largeobject_metadata; +REVOKE ALL ON FUNCTION lolor._migration_guard(boolean, text) FROM PUBLIC; - GET DIAGNOSTICS inserted_count = ROW_COUNT; - IF inserted_count <> lo_count THEN - RAISE EXCEPTION 'metadata row count mismatch: expected %, inserted %', - lo_count, inserted_count; +CREATE FUNCTION lolor.migrate_from_native(peer_oids oid[] DEFAULT NULL) +RETURNS bigint AS $$ +DECLARE + lo_count bigint; + repair_enabled boolean; + overlap oid[]; + labelled bigint; +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = current_user AND rolsuper) THEN + RAISE EXCEPTION 'lolor.migrate_from_native() requires superuser privileges'; END IF; - -- Copy data pages - INSERT INTO lolor.pg_largeobject (loid, pageno, data) - SELECT loid, pageno, data FROM pg_catalog.pg_largeobject; + PERFORM lolor._require_no_other_sessions('migrate large objects'); + + SELECT count(*) INTO lo_count FROM pg_catalog.pg_largeobject_metadata; + + IF lo_count = 0 THEN + RAISE NOTICE 'no native large objects to migrate'; + RETURN 0; + END IF; - GET DIAGNOSTICS page_count = ROW_COUNT; - IF page_count <> native_page_count THEN - RAISE EXCEPTION 'data page count mismatch: expected %, inserted %', - native_page_count, page_count; + -- Security labels have nowhere to live in lolor storage and, unlike + -- comments, cannot be parked and replayed: reinstating one has to go + -- through the label provider. Refuse rather than discard them. + SELECT count(*) INTO labelled + FROM pg_catalog.pg_seclabel + WHERE classoid = 'pg_catalog.pg_largeobject'::regclass; + + IF labelled > 0 THEN + RAISE EXCEPTION 'cannot migrate: % large object security label(s) present', labelled + USING DETAIL = 'lolor storage cannot represent security labels, and they ' + 'cannot be reinstated without their label provider', + HINT = 'Remove the labels with SECURITY LABEL ... IS NULL before migrating'; END IF; - -- Remove native large objects using the original (renamed) function. - -- Materialize the OID list first to avoid scanning the catalog while - -- lo_unlink_orig modifies it. - PERFORM pg_catalog.lo_unlink_orig(oid) - FROM (SELECT oid FROM pg_catalog.pg_largeobject_metadata) AS native_oids; + /* Cross-node OID collision pre-flight; see the comment on this function. */ + IF peer_oids IS NOT NULL THEN + SELECT array_agg(m.oid ORDER BY m.oid) INTO overlap + FROM pg_catalog.pg_largeobject_metadata m + WHERE m.oid = ANY (peer_oids); + + IF overlap IS NOT NULL THEN + RAISE EXCEPTION 'cannot migrate: % OID(s) are also held natively by another node', + array_length(overlap, 1) + USING DETAIL = format('Colliding OID(s): %s', overlap), + HINT = 'Native OIDs are not node-encoded. Re-create the colliding objects ' + 'under fresh OIDs on one of the nodes before migrating'; + END IF; + END IF; - -- Re-enable replication for the remainder of the caller's transaction, so - -- repair mode covers exactly the migration DML and nothing after it. - -- Error paths need no cleanup: they abort the whole transaction. - IF repair_enabled THEN - PERFORM spock.repair_mode(false); + repair_enabled := lolor._migration_guard( + false, + 'Drop the offending logical replication slots in this database and retry'); + + IF repair_enabled IS NULL THEN + RETURN -1; END IF; - RAISE NOTICE 'migrated % large object(s) (% data page(s)) from native to lolor storage', - lo_count, page_count; + PERFORM lolor.migrate_storage(false); - -- The migration DML was excluded from replication, so connected subscribers - -- (replica nodes) did not receive it. - IF lr_slots_exists THEN - RAISE NOTICE 'this migration is local to this node: ' - 'execute lolor.migrate_from_native() on each replica as well before modifying LOs'; + -- Cover exactly the migration and nothing after it. Error paths need no + -- cleanup: they abort the whole transaction. + IF repair_enabled THEN + PERFORM spock.repair_mode(false); END IF; RETURN lo_count; @@ -220,48 +283,28 @@ $$ LANGUAGE plpgsql VOLATILE; /* * lolor.migrate_to_native() * - * Migrate all large objects from lolor storage back into native PostgreSQL - * storage, preserving original OIDs, owners, ACLs, and data. After - * migration, the lolor copies are removed. - * - * Called automatically by the DROP EXTENSION event trigger, but can also - * be invoked manually to revert to native large object storage. + * Move every large object from lolor storage back into native storage. * - * The _orig functions (native LO API) must be available, which means lolor - * must be in the enabled state. + * Called automatically by the drop event trigger, and safe to invoke by hand. + * Unlike previous versions this does not require lolor to be enabled: the + * movement is performed directly against the catalogs and never calls the + * renamed _orig functions. * - * Returns the number of large objects migrated. RAISEs an ERROR if something - * goes wrong. - * The hard failure is deliberate: accidental ignore of a failure may result in - * loosing LOs. So, user must solve the issue manually before moving forward. + * Failure is a hard ERROR, not a soft return: this runs on the DROP path, + * where silently losing large objects is far worse than a failed DROP. */ CREATE FUNCTION lolor.migrate_to_native() RETURNS bigint AS $$ DECLARE - lo_count bigint; - loblksize bigint; - r_meta record; - r_data record; - fd integer; - repair_enabled boolean := false; - lr_slots_exists boolean := false; - foreign_lr_slots_exists boolean := false; + lo_count bigint; + repair_enabled boolean; BEGIN - -- Only superusers can UPDATE pg_catalog.pg_largeobject_metadata IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = current_user AND rolsuper) THEN RAISE EXCEPTION 'lolor.migrate_to_native() requires superuser privileges'; END IF; - -- Verify lolor is enabled so _orig functions point to native API - IF NOT lolor.is_enabled() THEN - RAISE EXCEPTION 'lolor must be enabled before migration to native'; - END IF; - - -- Derive LOBLKSIZE at runtime. PostgreSQL defines it as BLCKSZ / 4. - -- Hard-coding 2048 would break on non-default block size builds. - loblksize := current_setting('block_size')::bigint / 4; + PERFORM lolor._require_no_other_sessions('migrate large objects'); - -- Count what we are about to migrate SELECT count(*) INTO lo_count FROM lolor.pg_largeobject_metadata; IF lo_count = 0 THEN @@ -269,128 +312,51 @@ BEGIN RETURN 0; END IF; - -- Check for OID conflicts - IF EXISTS ( - SELECT 1 - FROM lolor.pg_largeobject_metadata lm - JOIN pg_catalog.pg_largeobject_metadata native ON native.oid = lm.oid - ) THEN - RAISE EXCEPTION 'OID conflict: some lolor large objects already exist in native storage'; - END IF; - - -- Logical slots indicate subscribers (replica nodes) that will not receive - -- the node-local migration DML. - SELECT EXISTS ( - SELECT 1 FROM pg_catalog.pg_replication_slots - WHERE slot_type = 'logical' AND database = current_database() - ) INTO lr_slots_exists; - - -- A logical slot whose output plugin is not spock's own cannot be silenced - -- by spock.repair_mode(). Track those separately: their consumers would - -- still decode the migration DML even when spock is fully operational. - SELECT EXISTS ( - SELECT 1 FROM pg_catalog.pg_replication_slots - WHERE slot_type = 'logical' AND database = current_database() - AND plugin NOT IN ('spock_output', 'spock') - ) INTO foreign_lr_slots_exists; - - -- Suppress spock replication of the bulk migration DML. The migration only - -- shuffles rows between lolor and native storage on this node. - -- - -- Use spock only when it is fully operational: the extension is installed - -- (pg_extension is superuser-gated, so the schema name cannot be squatted - -- by an unprivileged user), the function exists (older spock versions lack - -- it) and the GUC exists (the library is actually preloaded). Anything - -- less falls through to the refusal branch below. - -- - -- Without spock the migration DML cannot be excluded from logical decoding, - -- so if any logical replication slot exists the deletes from the lolor - -- tables would replicate while the re-created native large objects would - -- not, losing large objects on the subscriber side. - IF EXISTS (SELECT 1 FROM pg_catalog.pg_extension WHERE extname = 'spock') - AND to_regprocedure('spock.repair_mode(boolean)') IS NOT NULL - AND current_setting('spock.replication_repair_mode', true) IS NOT NULL - THEN - -- spock.repair_mode() only suppresses spock's own output plugin - -- ('spock_output'/'spock'). If a non-spock logical slot is present - -- (pgoutput, wal2json, decoderbufs, ...) its consumer would still decode - -- the migration DML: the lolor deletes would replicate while the - -- re-created native large objects would not, losing LOs on the subscriber. - IF foreign_lr_slots_exists THEN - RAISE EXCEPTION 'cannot migrate LOs to pg_catalog: non-spock logical replication slot(s) exist' - USING DETAIL = 'spock repair mode only silences spock''s own output plugin; ' - 'a non-spock slot (e.g. pgoutput, wal2json) would replicate the ' - 'lolor deletes while the re-created native large objects would not, ' - 'losing large objects on the subscriber side', - HINT = 'Drop all non-spock logical replication slots in this database before executing this procedure'; - END IF; - - IF current_setting('spock.replication_repair_mode', true) = 'off' THEN - PERFORM spock.repair_mode(true); - repair_enabled := true; - END IF; - ELSIF lr_slots_exists THEN - RAISE EXCEPTION 'cannot migrate LOs to pg_catalog: logical replication slot(s) exist' - USING DETAIL = 'Without spock the migration DML cannot be excluded from logical decoding: ' - 'the deletes from the lolor tables would replicate to subscribers while ' - 'the re-created native large objects would not, losing large objects on ' - 'the subscriber side', - HINT = 'Drop all logical replication slots in this database before executing this procedure'; - END IF; - - -- Migrate each object using the native LO API (_orig functions). - -- We cannot INSERT directly into pg_catalog.pg_largeobject from SQL, - -- so we use lo_create_orig + lo_open_orig + lowrite_orig. - -- - -- Zero-data-page LOs (metadata only) are handled correctly: lo_create_orig - -- creates an empty LO and the inner FOR loop simply does not execute. - -- - -- Note on sparse LOs: any gap between non-consecutive page numbers will - -- be filled with zeroes by the native LO write API. This preserves read - -- semantics (holes already returned zeroes) but may increase storage. - FOR r_meta IN SELECT oid, lomowner, lomacl FROM lolor.pg_largeobject_metadata - LOOP - -- Create native LO with the exact same OID - PERFORM pg_catalog.lo_create_orig(r_meta.oid); - - -- Write data pages through the native LO write API. - -- Use lo_lseek64_orig (bigint offset) to handle LOs larger than 2 GB. - fd := pg_catalog.lo_open_orig(r_meta.oid, x'60000'::int); - FOR r_data IN - SELECT pageno, data FROM lolor.pg_largeobject - WHERE loid = r_meta.oid ORDER BY pageno - LOOP - PERFORM pg_catalog.lo_lseek64_orig(fd, r_data.pageno::bigint * loblksize, 0); - PERFORM pg_catalog.lowrite_orig(fd, r_data.data); - END LOOP; - PERFORM pg_catalog.lo_close_orig(fd); - - -- Restore ownership and ACL (lo_create sets current user as owner) - UPDATE pg_catalog.pg_largeobject_metadata - SET lomowner = r_meta.lomowner, lomacl = r_meta.lomacl - WHERE pg_catalog.pg_largeobject_metadata.oid = r_meta.oid; - END LOOP; + repair_enabled := lolor._migration_guard( + true, + 'Drop the offending logical replication slots in this database and retry'); - -- Clean lolor storage - DELETE FROM lolor.pg_largeobject; - DELETE FROM lolor.pg_largeobject_metadata; + PERFORM lolor.migrate_storage(true); - -- Re-enable replication for the remainder of the caller's transaction, so - -- repair mode covers exactly the migration DML and nothing after it. - -- Error paths need no cleanup: they abort the whole transaction. IF repair_enabled THEN PERFORM spock.repair_mode(false); END IF; - RAISE NOTICE 'migrated % large object(s) from lolor to native storage', lo_count; - - -- The migration DML was excluded from replication, so connected subscribers - -- (replica nodes) did not receive it. - IF lr_slots_exists THEN - RAISE NOTICE 'this migration is local to this node: ' - 'execute lolor.migrate_to_native() on each replica as well before modifying LOs'; - END IF; - RETURN lo_count; END; $$ LANGUAGE plpgsql VOLATILE; + +REVOKE ALL ON FUNCTION lolor.migrate_from_native(oid[]) FROM PUBLIC; +REVOKE ALL ON FUNCTION lolor.migrate_to_native() FROM PUBLIC; + +/* + * Native large object OIDs held by this node, for the cross-node pre-flight + * of lolor.migrate_from_native(). + */ +CREATE FUNCTION lolor.native_lo_oids() +RETURNS SETOF oid AS $$ + SELECT oid FROM pg_catalog.pg_largeobject_metadata ORDER BY oid +$$ LANGUAGE sql STABLE; + +/* + * Per-object digest of lolor storage, so that convergence across nodes can be + * checked instead of assumed. Compare the output on every node after + * migrating: because the movement is hidden from replication, divergence is + * otherwise silent until a later conflict. + * + * This reads every page and is deliberately not cheap; run it as a check, not + * on a schedule. + */ +CREATE FUNCTION lolor.digest() +RETURNS TABLE (loid oid, lomowner name, npages bigint, nbytes bigint, digest text) +AS $$ + SELECT m.oid, + pg_catalog.pg_get_userbyid(m.lomowner), + count(d.pageno), + coalesce(sum(length(d.data)), 0), + md5(coalesce(string_agg(md5(d.data), ',' ORDER BY d.pageno), '')) + FROM lolor.pg_largeobject_metadata m + LEFT JOIN lolor.pg_largeobject d ON d.loid = m.oid + GROUP BY m.oid, m.lomowner + ORDER BY m.oid +$$ LANGUAGE sql STABLE; diff --git a/sql/lolor.sql b/sql/lolor.sql index cf25c11..defd301 100644 --- a/sql/lolor.sql +++ b/sql/lolor.sql @@ -142,15 +142,18 @@ DROP EXTENSION lolor; SELECT oid, proname FROM pg_proc WHERE proname IN ('lo_open_orig', 'lolor_lo_open'); --- Check: we can't just delete LOLOR without LO migration in disabled mode. --- XXX: should we introduce a 'forced' flag to allow this? +-- DROP EXTENSION while lolor is disabled. The plpgsql migration failed here +-- with "lolor must be enabled before migration to native" because it went +-- through the renamed _orig functions; the relation copy works against the +-- catalogs directly, so the drop migrates and succeeds in either state. CREATE EXTENSION lolor; +SELECT lo_from_bytea(0, 'stored before disabling') AS disabled_drop_oid \gset SELECT lolor.disable(); DROP EXTENSION lolor; -SELECT extname FROM pg_extension; -- lolor is here -SELECT lolor.enable(); -DROP EXTENSION lolor; SELECT extname FROM pg_extension; -- check lolor removal +-- The object was migrated to native storage, not dropped with lolor's tables +SELECT convert_from(lo_get(:disabled_drop_oid), 'UTF8') AS survived_disabled_drop; +SELECT lo_unlink(:disabled_drop_oid); -- -- Migration tests: migrate_from_native / migrate_to_native / DROP EXTENSION @@ -433,3 +436,230 @@ BEGIN; SELECT lo_open(0, 262144); ROLLBACK; DROP EXTENSION lolor; + +-- +-- Fidelity of the storage relocation +-- +CREATE EXTENSION lolor; +CREATE ROLE lolor_owner; +CREATE ROLE lolor_grantee; + +-- A sparse object: two pages holding a 10 MB logical object. Migration must +-- not fill the hole; the old implementation rewrote it through the LO API and +-- materialised every intervening page. +SELECT lolor.disable(); +SELECT lo_create(0) AS sparse_oid \gset +BEGIN; +SELECT lo_open(:sparse_oid, x'60000'::int) AS fd \gset +SELECT lowrite(:fd, 'start'); +SELECT lo_lseek64(:fd, 10000000, 0); +SELECT lowrite(:fd, 'end'); +SELECT lo_close(:fd); +END; + +-- An object carrying owner, ACL and a comment +SELECT lo_from_bytea(0, 'annotated object') AS annotated_oid \gset +ALTER LARGE OBJECT :annotated_oid OWNER TO lolor_owner; +GRANT SELECT ON LARGE OBJECT :annotated_oid TO lolor_grantee; +COMMENT ON LARGE OBJECT :annotated_oid IS 'kept across migration'; + +-- An object with no data pages at all +SELECT lo_create(0) AS empty_oid \gset + +SELECT lolor.enable(); +SELECT lolor.migrate_from_native(); + +-- Sparse object keeps its exact page count (2), not 4883 +SELECT count(*) AS sparse_pages FROM lolor.pg_largeobject WHERE loid = :sparse_oid; +SELECT lo_get(:sparse_oid) IS NOT NULL AS sparse_readable; +SELECT length(lo_get(:sparse_oid)) AS sparse_length; +SELECT length(lo_get(:empty_oid)) AS empty_length; + +-- Owner and ACL survive; the comment is parked for the round trip +SELECT pg_get_userbyid(lomowner) AS owner, lomacl IS NOT NULL AS has_acl +FROM lolor.pg_largeobject_metadata WHERE oid = :annotated_oid; +SELECT description FROM lolor.pg_largeobject_description WHERE loid = :annotated_oid; + +-- Native side is fully cleaned up, including shared deps and comments +SELECT count(*) AS native_objs FROM pg_catalog.pg_largeobject_metadata; +SELECT count(*) AS native_shdep FROM pg_shdepend + WHERE classid = 'pg_largeobject'::regclass + AND dbid = (SELECT oid FROM pg_database WHERE datname = current_database()); +SELECT count(*) AS native_comments FROM pg_description + WHERE classoid = 'pg_largeobject'::regclass; + +-- Back to native storage +SELECT lolor.migrate_to_native(); + +-- Ownership is recorded in pg_shdepend, not merely in lomowner: a raw catalog +-- UPDATE would leave DROP ROLE unable to see the object. +SELECT pg_get_userbyid(lomowner) AS owner +FROM pg_catalog.pg_largeobject_metadata WHERE oid = :annotated_oid; +SELECT deptype, refobjid::regrole::text AS role FROM pg_shdepend + WHERE classid = 'pg_largeobject'::regclass AND objid = :annotated_oid + AND dbid = (SELECT oid FROM pg_database WHERE datname = current_database()) + ORDER BY deptype; +SELECT description FROM pg_description + WHERE classoid = 'pg_largeobject'::regclass AND objoid = :annotated_oid; + +-- DROP ROLE must refuse for both the owner and the ACL grantee +DROP ROLE lolor_owner; +DROP ROLE lolor_grantee; + +-- Sparse object is still sparse after the round trip +SELECT count(*) AS sparse_pages_native FROM pg_catalog.pg_largeobject + WHERE loid = :sparse_oid; + +-- Comment parking table is emptied once the comments are reinstated +SELECT count(*) AS parked_left FROM lolor.pg_largeobject_description; + +-- Cleanup +SELECT lolor.disable(); +SELECT lo_unlink(:sparse_oid); +SELECT lo_unlink(:annotated_oid); +SELECT lo_unlink(:empty_oid); +SELECT lolor.enable(); +SELECT lolor.migrate_to_native(); +DROP EXTENSION lolor; +DROP ROLE lolor_owner; +DROP ROLE lolor_grantee; + +-- +-- A parked comment must not outlive the object it describes. Object OIDs are +-- only checked against pg_largeobject_metadata when a new one is generated, so +-- a comment left behind by lo_unlink() would be handed to whatever object next +-- took that OID. +-- +CREATE EXTENSION lolor; +SELECT lolor.disable(); +SELECT lo_from_bytea(0, 'has a comment') AS commented_oid \gset +COMMENT ON LARGE OBJECT :commented_oid IS 'parked then orphaned'; +SELECT lolor.enable(); +SELECT lolor.migrate_from_native(); +SELECT count(*) AS parked FROM lolor.pg_largeobject_description + WHERE loid = :commented_oid; + +-- Unlinking must take the parked comment with it. +SELECT lo_unlink(:commented_oid); +SELECT count(*) AS parked_after_unlink FROM lolor.pg_largeobject_description + WHERE loid = :commented_oid; + +-- Re-create an object under the very same OID and send it back to native +-- storage. It must arrive with no comment. +SELECT lo_create(:commented_oid) = :commented_oid AS oid_reused; +SELECT lolor.migrate_to_native(); +SELECT count(*) AS inherited_comment FROM pg_description + WHERE classoid = 'pg_largeobject'::regclass AND objoid = :commented_oid; +SELECT lolor.disable(); +SELECT lo_unlink(:commented_oid); +SELECT lolor.enable(); +SELECT lolor.migrate_to_native(); +DROP EXTENSION lolor; + +-- +-- Verification helpers. +-- +-- Start from a clean slate so the counts below do not depend on what earlier +-- sections happened to leave behind. The notices carry those counts, so they +-- are suppressed for the duration. +CREATE EXTENSION lolor; +SET client_min_messages = warning; +SELECT lolor.migrate_to_native() IS NOT NULL AS drained_to_native; +SELECT lolor.disable(); +DO $$ +DECLARE r record; +BEGIN + FOR r IN SELECT oid FROM pg_catalog.pg_largeobject_metadata LOOP + PERFORM lo_unlink(r.oid); + END LOOP; +END +$$; +SELECT lolor.enable(); +RESET client_min_messages; + +SELECT count(*) AS native_oids_when_empty FROM lolor.native_lo_oids(); + +-- Both directions report zero rather than failing when there is nothing. +SELECT lolor.migrate_to_native() AS nothing_to_move; +SELECT lolor.migrate_from_native() AS nothing_to_take; + +-- digest() reports one row per object. The OID and owner vary between runs; +-- the page count, byte count and content digest do not. +SELECT lo_from_bytea(0, 'digest one') AS d1 \gset +SELECT lo_from_bytea(0, 'digest two, longer') AS d2 \gset +SELECT npages, nbytes, digest FROM lolor.digest() ORDER BY nbytes, digest; +SELECT lo_unlink(:d1); +SELECT lo_unlink(:d2); + +-- migrate_storage() is the mechanism behind both migration functions. Grant +-- schema access so that the function's own privileges are what is exercised +-- rather than USAGE on the schema. +CREATE ROLE lolor_nosuper; +GRANT USAGE ON SCHEMA lolor TO lolor_nosuper; +SET ROLE lolor_nosuper; +SELECT lolor.migrate_storage(true); +SELECT lolor.migrate_from_native(); +SELECT lolor.migrate_to_native(); +RESET ROLE; +REVOKE USAGE ON SCHEMA lolor FROM lolor_nosuper; +DROP ROLE lolor_nosuper; + +-- Native OIDs are not node-encoded, so two nodes can hold different objects +-- under the same OID. Passing the peer OIDs makes that a refusal instead of +-- silent divergence once the migration is hidden from replication. +SELECT lolor.disable(); +SELECT lo_create(0) AS peer_oid \gset +SELECT lolor.enable(); +SELECT lolor.migrate_from_native(peer_oids => ARRAY[:peer_oid]::oid[]); + +-- Security labels cannot be represented in lolor storage and cannot be +-- reinstated without their provider, so migration refuses rather than +-- discarding them. +INSERT INTO pg_catalog.pg_seclabel (objoid, classoid, objsubid, provider, label) +VALUES (:peer_oid, 'pg_catalog.pg_largeobject'::regclass, 0, 'lolor_test', 'secret'); +SELECT lolor.migrate_from_native(); +DELETE FROM pg_catalog.pg_seclabel + WHERE classoid = 'pg_catalog.pg_largeobject'::regclass AND provider = 'lolor_test'; + +-- With the label gone the migration proceeds. +SELECT lolor.migrate_from_native() AS migrated; +SELECT lo_unlink(:peer_oid); +SELECT lolor.migrate_to_native(); +DROP EXTENSION lolor; + +-- +-- A role can be dropped while objects that refer to it sit in lolor storage, +-- because those objects are not in pg_shdepend. Migrating them back would +-- otherwise die inside shdepLockAndCheckObject() with "role N was concurrently +-- dropped", which also blocks DROP EXTENSION. The migration must refuse up +-- front with a clear message instead. +-- +CREATE EXTENSION lolor; +CREATE ROLE lolor_dead_owner; +CREATE ROLE lolor_dead_grantee; + +-- Both objects are created in native storage and migrated in: ACLs can only +-- be set there, and this change does not touch what lolor records in +-- pg_shdepend for objects created through it. +SELECT lolor.disable(); +-- Owned by a role that is about to be dropped +SET ROLE lolor_dead_owner; +SELECT lo_from_bytea(0, 'orphaned owner') AS dead_owner_oid \gset +RESET ROLE; +-- Granted to a role that is about to be dropped +SELECT lo_from_bytea(0, 'orphaned grantee') AS dead_grantee_oid \gset +GRANT SELECT ON LARGE OBJECT :dead_grantee_oid TO lolor_dead_grantee; +SELECT lolor.enable(); +SELECT lolor.migrate_from_native(); + +DROP ROLE lolor_dead_owner; +DROP ROLE lolor_dead_grantee; + +-- Migration refuses up front rather than failing mid-copy +SELECT lolor.migrate_to_native(); + +-- With the affected objects gone there is nothing left to refuse +SELECT lo_unlink(:dead_owner_oid); +SELECT lo_unlink(:dead_grantee_oid); +SELECT lolor.migrate_to_native(); +DROP EXTENSION lolor; diff --git a/src/lolor.c b/src/lolor.c index cdaf70d..1ba4f72 100644 --- a/src/lolor.c +++ b/src/lolor.c @@ -42,24 +42,46 @@ static Oid LOLOR_LargeObjectRelationId = InvalidOid; static Oid LOLOR_LargeObjectLOidPNIndexId = InvalidOid; static Oid LOLOR_LargeObjectMetadataRelationId = InvalidOid; static Oid LOLOR_LargeObjectMetadataOidIndexId = InvalidOid; +static Oid LOLOR_LargeObjectDescriptionRelationId = InvalidOid; +static Oid LOLOR_LargeObjectDescriptionIndexId = InvalidOid; PG_FUNCTION_INFO_V1(lolor_on_drop_extension); static Oid -get_lobj_table_oid(const char *table) +get_lobj_table_oid_extended(const char *table, bool missing_ok) { Oid reloid; Oid nspoid; nspoid = get_namespace_oid(EXTENSION_NAME, false); reloid = get_relname_relid(table, nspoid); - if (reloid == InvalidOid) + if (reloid == InvalidOid && !missing_ok) elog(ERROR, "cache lookup failed for relation %s.%s", EXTENSION_NAME, table); return reloid; } +static Oid +get_lobj_table_oid(const char *table) +{ + return get_lobj_table_oid_extended(table, false); +} + +/* + * Same, but returns InvalidOid when the relation is absent. + * + * The shared library is replaced before ALTER EXTENSION UPDATE runs, so a + * backend can execute newer code against an older schema in which + * lolor.pg_largeobject_description does not exist yet. Paths reached during + * ordinary large object activity have to tolerate that. + */ +Oid +get_LOLOR_LargeObjectDescriptionRelationIdIfExists(void) +{ + return get_lobj_table_oid_extended(LOLOR_LARGEOBJECT_DESCRIPTION, true); +} + Oid get_LOLOR_LargeObjectRelationId() { @@ -96,6 +118,31 @@ get_LOLOR_LargeObjectMetadataOidIndexId() return LOLOR_LargeObjectMetadataOidIndexId; } +/* + * lolor.pg_largeobject_description parks COMMENT ON LARGE OBJECT text while an + * object lives in lolor storage, where there is no catalog entry for a comment + * to hang off. See lolor_migrate.c. + */ +Oid +get_LOLOR_LargeObjectDescriptionRelationId() +{ + if (!OidIsValid(LOLOR_LargeObjectDescriptionRelationId)) + LOLOR_LargeObjectDescriptionRelationId = + get_lobj_table_oid(LOLOR_LARGEOBJECT_DESCRIPTION); + + return LOLOR_LargeObjectDescriptionRelationId; +} + +Oid +get_LOLOR_LargeObjectDescriptionIndexId() +{ + if (!OidIsValid(LOLOR_LargeObjectDescriptionIndexId)) + LOLOR_LargeObjectDescriptionIndexId = + get_lobj_table_oid(LOLOR_LARGEOBJECT_DESCRIPTION_PKEY); + + return LOLOR_LargeObjectDescriptionIndexId; +} + static void lolor_xact_callback(XactEvent event, void *arg) { @@ -142,6 +189,8 @@ relcache_invalidate_callback(Datum arg, Oid reloid) LOLOR_LargeObjectLOidPNIndexId = InvalidOid; LOLOR_LargeObjectMetadataRelationId = InvalidOid; LOLOR_LargeObjectMetadataOidIndexId = InvalidOid; + LOLOR_LargeObjectDescriptionRelationId = InvalidOid; + LOLOR_LargeObjectDescriptionIndexId = InvalidOid; } /* diff --git a/src/lolor.h b/src/lolor.h index 447b873..4fe31a6 100644 --- a/src/lolor.h +++ b/src/lolor.h @@ -20,6 +20,8 @@ #define LOLOR_LARGEOBJECT_PKEY "pg_largeobject_pkey" #define LOLOR_LARGEOBJECT_METADATA "pg_largeobject_metadata" #define LOLOR_LARGEOBJECT_METADATA_PKEY "pg_largeobject_metadata_pkey" +#define LOLOR_LARGEOBJECT_DESCRIPTION "pg_largeobject_description" +#define LOLOR_LARGEOBJECT_DESCRIPTION_PKEY "pg_largeobject_description_pkey" /* * Layout of a lolor-assigned large object OID: the low LOLOR_NODEID_BITS hold @@ -38,6 +40,9 @@ extern Oid get_LOLOR_LargeObjectRelationId(void); extern Oid get_LOLOR_LargeObjectLOidPNIndexId(void); extern Oid get_LOLOR_LargeObjectMetadataRelationId(void); extern Oid get_LOLOR_LargeObjectMetadataOidIndexId(void); +extern Oid get_LOLOR_LargeObjectDescriptionRelationId(void); +extern Oid get_LOLOR_LargeObjectDescriptionIndexId(void); +extern Oid get_LOLOR_LargeObjectDescriptionRelationIdIfExists(void); /* lolor_largeobject.c */ extern Oid LOLOR_LargeObjectCreate(Oid loid); @@ -98,4 +103,7 @@ extern Datum lolor_lo_get(PG_FUNCTION_ARGS); extern Datum lolor_lo_get_fragment(PG_FUNCTION_ARGS); extern Datum lolor_lo_put(PG_FUNCTION_ARGS); +/* lolor_migrate.c */ +extern Datum lolor_migrate_storage(PG_FUNCTION_ARGS); + #endif /* LOLOR_LARGEOBJECT_H */ diff --git a/src/lolor_largeobject.c b/src/lolor_largeobject.c index 88dd98c..8aed9b1 100644 --- a/src/lolor_largeobject.c +++ b/src/lolor_largeobject.c @@ -100,6 +100,7 @@ LOLOR_LargeObjectDrop(Oid loid) ScanKeyData skey[1]; SysScanDesc scan; HeapTuple tuple; + Oid descoid; pg_lo_meta = table_open(get_LOLOR_LargeObjectMetadataRelationId(), RowExclusiveLock); @@ -150,6 +151,38 @@ LOLOR_LargeObjectDrop(Oid loid) table_close(pg_largeobject, RowExclusiveLock); table_close(pg_lo_meta, RowExclusiveLock); + + /* + * Drop any comment parked for this object while it lived in lolor storage. + * Leaving it behind would outlive the object: OIDs are only checked + * against pg_largeobject_metadata when a new one is generated, so a later + * object reusing this OID would inherit the stale comment on its way back + * to native storage. + * + * The relation is absent when the loaded library is newer than the + * installed extension version, which is the normal state between + * installing the package and running ALTER EXTENSION UPDATE. + */ + descoid = get_LOLOR_LargeObjectDescriptionRelationIdIfExists(); + if (OidIsValid(descoid)) + { + Relation pg_lo_desc = table_open(descoid, RowExclusiveLock); + + ScanKeyInit(&skey[0], + 1, /* loid */ + BTEqualStrategyNumber, F_OIDEQ, + ObjectIdGetDatum(loid)); + + scan = systable_beginscan(pg_lo_desc, + get_LOLOR_LargeObjectDescriptionIndexId(), + true, NULL, 1, skey); + + while (HeapTupleIsValid(tuple = systable_getnext(scan))) + CatalogTupleDelete(pg_lo_desc, &tuple->t_self); + + systable_endscan(scan); + table_close(pg_lo_desc, RowExclusiveLock); + } } /* diff --git a/src/lolor_migrate.c b/src/lolor_migrate.c new file mode 100644 index 0000000..49e85c2 --- /dev/null +++ b/src/lolor_migrate.c @@ -0,0 +1,798 @@ +/*------------------------------------------------------------------------- + * + * lolor_migrate.c + * Relocate large objects between PostgreSQL's native catalog storage + * (pg_catalog.pg_largeobject{,_metadata}) and lolor's replicated user + * tables (lolor.pg_largeobject{,_metadata}). + * + * lolor's tables have the same column layout as the catalogs they stand in + * for (see lolor--1.0.sql), which makes migration a plain tuple copy rather + * than a decode/re-encode through the large object API. The layout is + * verified at run time rather than assumed. + * + * The rest is the bookkeeping only the real catalogs take part in: pg_shdepend + * rows for the owner and ACL grantees, and pg_description comments, which have + * nowhere to live while an object sits in lolor storage and are parked in + * lolor.pg_largeobject_description for the duration. Native objects are + * removed through performMultipleDeletions(), the path DROP uses, so core + * cleans up dependencies, comments and security labels. + * + * Copyright (c) 2022-2026, pgEdge, Inc. + * Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group + * + * IDENTIFICATION + * contrib/lolor/src/lolor_migrate.c + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "access/detoast.h" +#include "access/genam.h" +#include "access/htup_details.h" +#include "access/table.h" +#include "access/xact.h" +#include "catalog/dependency.h" +#include "catalog/indexing.h" +#include "catalog/objectaccess.h" +#include "catalog/objectaddress.h" +#include "catalog/pg_largeobject.h" +#include "catalog/pg_largeobject_metadata.h" +#include "commands/comment.h" +#include "funcapi.h" +#include "miscadmin.h" +#include "utils/acl.h" +#include "utils/builtins.h" +#include "utils/fmgroids.h" +#include "utils/memutils.h" +#include "utils/rel.h" +#include "utils/snapmgr.h" +#include "utils/syscache.h" + +#include "lolor.h" + +/* + * How many objects to hand to performMultipleDeletions() at a time. Bounds + * the memory used by the ObjectAddresses array on databases holding a very + * large number of large objects. + */ +#define MIGRATE_DELETE_CHUNK 1000 + +/* + * How many conflicting OIDs to name in the pre-flight error before + * summarising the remainder. + */ +#define MIGRATE_MAX_REPORTED_CONFLICTS 10 + +PG_FUNCTION_INFO_V1(lolor_migrate_storage); + +/* + * The four relations involved in one migration, plus the indexes needed to + * probe the destination. "src" is where the objects live now, "dst" is where + * they are going. + */ +typedef struct MigrateRels +{ + bool to_native; /* lolor -> pg_catalog when true */ + + Relation src_meta; + Relation dst_meta; + Oid src_meta_idx; + Oid dst_meta_idx; + + Relation src_data; + Relation dst_data; + Oid src_data_idx; + Oid dst_data_idx; + + Relation desc_rel; /* lolor.pg_largeobject_description */ + Oid desc_idx; +} MigrateRels; + +/* + * Confirm that two relations have interchangeable on-disk tuple layouts. + * + * A mismatch means the extension was built against a different catalog + * definition than the server runs, and copying tuples would corrupt data. + */ +static void +validate_layout(Relation a, Relation b, int expected_natts) +{ + TupleDesc da = RelationGetDescr(a); + TupleDesc db = RelationGetDescr(b); + int i; + + if (da->natts != db->natts || da->natts != expected_natts) + ereport(ERROR, + (errcode(ERRCODE_DATATYPE_MISMATCH), + errmsg("incompatible large object storage layout"), + errdetail("Relation \"%s\" has %d columns and \"%s\" has %d; %d expected.", + RelationGetRelationName(a), da->natts, + RelationGetRelationName(b), db->natts, + expected_natts))); + + for (i = 0; i < da->natts; i++) + { + Form_pg_attribute aa = TupleDescAttr(da, i); + Form_pg_attribute ab = TupleDescAttr(db, i); + + if (aa->attisdropped || ab->attisdropped) + ereport(ERROR, + (errcode(ERRCODE_DATATYPE_MISMATCH), + errmsg("incompatible large object storage layout"), + errdetail("Column %d of \"%s\" or \"%s\" is dropped.", + i + 1, RelationGetRelationName(a), + RelationGetRelationName(b)))); + + if (aa->atttypid != ab->atttypid || + aa->attlen != ab->attlen || + aa->attbyval != ab->attbyval || + aa->attalign != ab->attalign || + aa->attndims != ab->attndims) + ereport(ERROR, + (errcode(ERRCODE_DATATYPE_MISMATCH), + errmsg("incompatible large object storage layout"), + errdetail("Column %d differs between \"%s\" and \"%s\".", + i + 1, RelationGetRelationName(a), + RelationGetRelationName(b)), + errhint("The lolor extension was built against a different " + "PostgreSQL catalog definition than this server uses."))); + } +} + +/* + * Flatten any toasted or short-header varlena in a deformed tuple. + * + * heap_form_tuple() copies the pointer it is handed, so an external datum + * belonging to the source's TOAST table would reach the destination as a + * dangling reference once the source row is removed. lolor.pg_largeobject has + * a TOAST table of its own and a full page of incompressible data can be + * pushed out of line, so this is reachable in practice. + * + * detoast_attr() returns a plain varlena untouched, so this is cheap. + */ +static void +flatten_varlenas(TupleDesc desc, Datum *values, const bool *nulls) +{ + int i; + + for (i = 0; i < desc->natts; i++) + { + Form_pg_attribute att = TupleDescAttr(desc, i); + struct varlena *va; + + if (nulls[i] || att->attbyval || att->attlen != -1) + continue; + + va = (struct varlena *) DatumGetPointer(values[i]); + values[i] = PointerGetDatum(detoast_attr(va)); + } +} + +/* + * Does "loid" already have a metadata row in rel? + */ +static bool +metadata_exists(Relation rel, Oid indexId, Oid loid) +{ + ScanKeyData skey[1]; + SysScanDesc scan; + bool found; + + ScanKeyInit(&skey[0], + Anum_pg_largeobject_metadata_oid, + BTEqualStrategyNumber, F_OIDEQ, + ObjectIdGetDatum(loid)); + + scan = systable_beginscan(rel, indexId, true, NULL, 1, skey); + found = HeapTupleIsValid(systable_getnext(scan)); + systable_endscan(scan); + + return found; +} + +/* + * Pre-flight OID conflict check. + * + * Runs before anything is written so that a conflict leaves both stores + * untouched. Reports the offending OIDs rather than merely their existence, + * because resolving a conflict means acting on specific objects. + */ +static void +check_oid_conflicts(MigrateRels *rels) +{ + SysScanDesc scan; + HeapTuple tup; + StringInfoData buf; + int64 nconflicts = 0; + + initStringInfo(&buf); + + scan = systable_beginscan(rels->src_meta, InvalidOid, false, NULL, 0, NULL); + while (HeapTupleIsValid(tup = systable_getnext(scan))) + { + Oid loid; + bool isnull; + + CHECK_FOR_INTERRUPTS(); + + loid = DatumGetObjectId(heap_getattr(tup, + Anum_pg_largeobject_metadata_oid, + RelationGetDescr(rels->src_meta), + &isnull)); + Assert(!isnull); + + if (!metadata_exists(rels->dst_meta, rels->dst_meta_idx, loid)) + continue; + + if (nconflicts < MIGRATE_MAX_REPORTED_CONFLICTS) + appendStringInfo(&buf, "%s%u", nconflicts > 0 ? ", " : "", loid); + nconflicts++; + } + systable_endscan(scan); + + if (nconflicts == 0) + { + pfree(buf.data); + return; + } + + if (nconflicts > MIGRATE_MAX_REPORTED_CONFLICTS) + appendStringInfo(&buf, " and " INT64_FORMAT " more", + nconflicts - MIGRATE_MAX_REPORTED_CONFLICTS); + + ereport(ERROR, + (errcode(ERRCODE_DUPLICATE_OBJECT), + errmsg_plural("%lld large object already exists in the destination storage", + "%lld large objects already exist in the destination storage", + (unsigned long) nconflicts, + (long long) nconflicts), + errdetail("Conflicting OID(s): %s.", buf.data), + errhint("Remove or rename the conflicting large objects and retry; " + "nothing has been migrated."))); +} + +/* + * Pre-flight role check for migration to native storage. + * + * Recording ownership and ACLs in pg_shdepend goes through + * shdepLockAndCheckObject(), which reports a missing role as "role %u was + * concurrently dropped". Nothing was dropped concurrently: objects in lolor + * storage cannot take part in pg_shdepend, so DROP ROLE does not see them and + * an owner, grantee or grantor can vanish while they sit there. Find those + * first and say what happened; otherwise one such object blocks the whole + * migration, and with it DROP EXTENSION. + */ +static void +check_orphaned_roles(MigrateRels *rels) +{ + TupleDesc desc = RelationGetDescr(rels->src_meta); + SysScanDesc scan; + HeapTuple tup; + StringInfoData buf; + int64 norphans = 0; + MemoryContext tmpcxt; + MemoryContext oldcxt; + + initStringInfo(&buf); + + /* Detoasting the ACL and aclmembers() allocate; reset per row. */ + tmpcxt = AllocSetContextCreate(CurrentMemoryContext, + "lolor migrate role check", + ALLOCSET_DEFAULT_SIZES); + + scan = systable_beginscan(rels->src_meta, InvalidOid, false, NULL, 0, NULL); + while (HeapTupleIsValid(tup = systable_getnext(scan))) + { + Datum values[Natts_pg_largeobject_metadata]; + bool nulls[Natts_pg_largeobject_metadata]; + Oid loid; + Oid owner; + bool orphan; + + CHECK_FOR_INTERRUPTS(); + + oldcxt = MemoryContextSwitchTo(tmpcxt); + + heap_deform_tuple(tup, desc, values, nulls); + loid = DatumGetObjectId(values[Anum_pg_largeobject_metadata_oid - 1]); + owner = DatumGetObjectId(values[Anum_pg_largeobject_metadata_lomowner - 1]); + + orphan = !SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(owner)); + + if (!orphan && !nulls[Anum_pg_largeobject_metadata_lomacl - 1]) + { + Acl *acl = DatumGetAclP(values[Anum_pg_largeobject_metadata_lomacl - 1]); + Oid *roles; + int nroles = aclmembers(acl, &roles); + int i; + + /* aclmembers() covers grantors and grantees and skips PUBLIC. */ + for (i = 0; i < nroles; i++) + { + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roles[i]))) + { + orphan = true; + break; + } + } + } + + MemoryContextSwitchTo(oldcxt); + MemoryContextReset(tmpcxt); + + if (!orphan) + continue; + + if (norphans < MIGRATE_MAX_REPORTED_CONFLICTS) + appendStringInfo(&buf, "%s%u", norphans > 0 ? ", " : "", loid); + norphans++; + } + systable_endscan(scan); + MemoryContextDelete(tmpcxt); + + if (norphans == 0) + { + pfree(buf.data); + return; + } + + if (norphans > MIGRATE_MAX_REPORTED_CONFLICTS) + appendStringInfo(&buf, " and %lld more", + (long long) (norphans - MIGRATE_MAX_REPORTED_CONFLICTS)); + + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg_plural("%lld large object refers to a role that no longer exists", + "%lld large objects refer to roles that no longer exist", + (unsigned long) norphans, + (long long) norphans), + errdetail("Affected OID(s): %s.", buf.data), + errhint("Nothing has been migrated. Remove the affected large objects " + "with lo_unlink() as a superuser, then retry."))); +} + +/* + * Fetch the parked comment for loid from lolor.pg_largeobject_description, + * or NULL when the object has none. Caller owns the returned string. + */ +static char * +fetch_parked_comment(MigrateRels *rels, Oid loid) +{ + Relation rel = rels->desc_rel; + TupleDesc desc = RelationGetDescr(rel); + ScanKeyData skey[1]; + SysScanDesc scan; + HeapTuple tup; + char *result = NULL; + + ScanKeyInit(&skey[0], 1, BTEqualStrategyNumber, F_OIDEQ, + ObjectIdGetDatum(loid)); + + scan = systable_beginscan(rel, rels->desc_idx, true, NULL, 1, skey); + + tup = systable_getnext(scan); + if (HeapTupleIsValid(tup)) + { + bool isnull; + Datum d = heap_getattr(tup, 2, desc, &isnull); + + if (!isnull) + result = TextDatumGetCString(d); + } + + systable_endscan(scan); + + return result; +} + +/* + * Park a comment for loid in lolor.pg_largeobject_description. + */ +static void +park_comment(MigrateRels *rels, Oid loid, const char *comment) +{ + Relation rel = rels->desc_rel; + HeapTuple tup; + Datum values[2]; + bool nulls[2]; + + values[0] = ObjectIdGetDatum(loid); + values[1] = CStringGetTextDatum(comment); + nulls[0] = false; + nulls[1] = false; + + tup = heap_form_tuple(RelationGetDescr(rel), values, nulls); + CatalogTupleInsert(rel, tup); + heap_freetuple(tup); +} + +/* + * Remove every row from lolor.pg_largeobject_description. + */ +static void +clear_parked_comments(MigrateRels *rels) +{ + Relation rel = rels->desc_rel; + SysScanDesc scan; + HeapTuple tup; + + scan = systable_beginscan(rel, InvalidOid, false, NULL, 0, NULL); + while (HeapTupleIsValid(tup = systable_getnext(scan))) + { + CHECK_FOR_INTERRUPTS(); + CatalogTupleDelete(rel, &tup->t_self); + } + systable_endscan(scan); +} + +/* + * Copy every metadata row from src_meta to dst_meta, carrying ownership, + * ACLs and comments across the storage boundary. + * + * Returns the number of objects copied. + */ +static int64 +copy_metadata(MigrateRels *rels) +{ + TupleDesc srcdesc = RelationGetDescr(rels->src_meta); + TupleDesc dstdesc = RelationGetDescr(rels->dst_meta); + CatalogIndexState indstate; + SysScanDesc scan; + HeapTuple tup; + int64 count = 0; + MemoryContext tmpcxt; + MemoryContext oldcxt; + + /* + * Each iteration allocates a detoasted datum, the formed tuple, index + * scratch and any comment text. CurrentMemoryContext is not reset within + * a function call, so on a database with millions of large objects those + * would accumulate until the backend ran out of memory, aborting an + * all-or-nothing migration. Reset a per-row context instead. + */ + tmpcxt = AllocSetContextCreate(CurrentMemoryContext, + "lolor migrate metadata", + ALLOCSET_DEFAULT_SIZES); + + indstate = CatalogOpenIndexes(rels->dst_meta); + + scan = systable_beginscan(rels->src_meta, InvalidOid, false, NULL, 0, NULL); + while (HeapTupleIsValid(tup = systable_getnext(scan))) + { + Datum values[Natts_pg_largeobject_metadata]; + bool nulls[Natts_pg_largeobject_metadata]; + HeapTuple newtup; + Oid loid; + Oid owner; + + CHECK_FOR_INTERRUPTS(); + + oldcxt = MemoryContextSwitchTo(tmpcxt); + + heap_deform_tuple(tup, srcdesc, values, nulls); + flatten_varlenas(srcdesc, values, nulls); + + loid = DatumGetObjectId(values[Anum_pg_largeobject_metadata_oid - 1]); + owner = DatumGetObjectId(values[Anum_pg_largeobject_metadata_lomowner - 1]); + + newtup = heap_form_tuple(dstdesc, values, nulls); + CatalogTupleInsertWithInfo(rels->dst_meta, newtup, indstate); + heap_freetuple(newtup); + + if (rels->to_native) + { + /* + * Record the shared dependencies the catalog is expected to have. + * Note the owner is recorded directly rather than creating the + * object as the current user and transferring it afterwards, so + * pg_shdepend never passes through a wrong intermediate state. + */ + recordDependencyOnOwner(LargeObjectRelationId, loid, owner); + + if (!nulls[Anum_pg_largeobject_metadata_lomacl - 1]) + recordDependencyOnNewAcl(LargeObjectRelationId, loid, 0, owner, + DatumGetAclP(values[Anum_pg_largeobject_metadata_lomacl - 1])); + + /* Restore any comment parked on the way into lolor storage. */ + { + char *comment = fetch_parked_comment(rels, loid); + + if (comment != NULL) + { + CreateComments(loid, LargeObjectRelationId, 0, comment); + pfree(comment); + } + } + + InvokeObjectPostCreateHook(LargeObjectRelationId, loid, 0); + } + else + { + /* + * lolor's tables are ordinary tables, so a comment has nowhere to + * attach while the object lives there. Park it so the round trip + * back to native storage is lossless. The pg_description row + * itself is removed by performMultipleDeletions() later. + */ + char *comment = GetComment(loid, LargeObjectRelationId, 0); + + if (comment != NULL) + park_comment(rels, loid, comment); + } + + MemoryContextSwitchTo(oldcxt); + MemoryContextReset(tmpcxt); + + count++; + } + systable_endscan(scan); + + CatalogCloseIndexes(indstate); + MemoryContextDelete(tmpcxt); + CommandCounterIncrement(); + + return count; +} + +/* + * Copy every data page from src_data to dst_data. + * + * Page numbers are carried across verbatim, so a sparse large object stays + * sparse: there is no re-chunking, no offset arithmetic and therefore no + * dependence on LOBLKSIZE or on the 2 GB boundary. + * + * Returns the number of pages copied. + */ +static int64 +copy_data_pages(MigrateRels *rels) +{ + TupleDesc srcdesc = RelationGetDescr(rels->src_data); + TupleDesc dstdesc = RelationGetDescr(rels->dst_data); + CatalogIndexState indstate; + SysScanDesc scan; + HeapTuple tup; + int64 count = 0; + MemoryContext tmpcxt; + MemoryContext oldcxt; + + /* One row per page, so this is the loop that runs millions of times. */ + tmpcxt = AllocSetContextCreate(CurrentMemoryContext, + "lolor migrate pages", + ALLOCSET_DEFAULT_SIZES); + + indstate = CatalogOpenIndexes(rels->dst_data); + + scan = systable_beginscan(rels->src_data, InvalidOid, false, NULL, 0, NULL); + while (HeapTupleIsValid(tup = systable_getnext(scan))) + { + Datum values[Natts_pg_largeobject]; + bool nulls[Natts_pg_largeobject]; + HeapTuple newtup; + + CHECK_FOR_INTERRUPTS(); + + oldcxt = MemoryContextSwitchTo(tmpcxt); + + heap_deform_tuple(tup, srcdesc, values, nulls); + flatten_varlenas(srcdesc, values, nulls); + + newtup = heap_form_tuple(dstdesc, values, nulls); + CatalogTupleInsertWithInfo(rels->dst_data, newtup, indstate); + + MemoryContextSwitchTo(oldcxt); + MemoryContextReset(tmpcxt); + + count++; + } + systable_endscan(scan); + + CatalogCloseIndexes(indstate); + MemoryContextDelete(tmpcxt); + CommandCounterIncrement(); + + return count; +} + +/* + * Remove the native large objects that have just been copied into lolor. + * + * performMultipleDeletions() is the same path DROP takes, so pg_shdepend, + * pg_description and pg_seclabel rows go away with the objects instead of + * being left behind for someone to discover later. + */ +static void +drop_native_objects(MigrateRels *rels) +{ + SysScanDesc scan; + HeapTuple tup; + ObjectAddresses *addrs; + int pending = 0; + + addrs = new_object_addresses(); + + scan = systable_beginscan(rels->src_meta, InvalidOid, false, NULL, 0, NULL); + while (HeapTupleIsValid(tup = systable_getnext(scan))) + { + ObjectAddress obj; + bool isnull; + + CHECK_FOR_INTERRUPTS(); + + obj.classId = LargeObjectRelationId; + obj.objectId = DatumGetObjectId(heap_getattr(tup, + Anum_pg_largeobject_metadata_oid, + RelationGetDescr(rels->src_meta), + &isnull)); + obj.objectSubId = 0; + Assert(!isnull); + + add_exact_object_address(&obj, addrs); + + if (++pending >= MIGRATE_DELETE_CHUNK) + { + performMultipleDeletions(addrs, DROP_CASCADE, + PERFORM_DELETION_INTERNAL | + PERFORM_DELETION_QUIETLY); + free_object_addresses(addrs); + addrs = new_object_addresses(); + pending = 0; + } + } + systable_endscan(scan); + + if (pending > 0) + performMultipleDeletions(addrs, DROP_CASCADE, + PERFORM_DELETION_INTERNAL | + PERFORM_DELETION_QUIETLY); + + free_object_addresses(addrs); + CommandCounterIncrement(); +} + +/* + * Delete every row of an ordinary lolor storage table. + */ +static void +truncate_lolor_table(Relation rel) +{ + SysScanDesc scan; + HeapTuple tup; + + scan = systable_beginscan(rel, InvalidOid, false, NULL, 0, NULL); + while (HeapTupleIsValid(tup = systable_getnext(scan))) + { + CHECK_FOR_INTERRUPTS(); + CatalogTupleDelete(rel, &tup->t_self); + } + systable_endscan(scan); +} + +/* + * lolor.migrate_storage(to_native boolean) returns bigint + * + * Moves every large object from one storage to the other and returns the + * number of objects moved. The whole move is one transaction: on any error + * nothing has changed in either store. + * + * This is the mechanism only. Policy -- who may run it, and how it interacts + * with logical replication -- lives in the SQL wrappers, which are the + * supported entry points. + */ +Datum +lolor_migrate_storage(PG_FUNCTION_ARGS) +{ + bool to_native = PG_GETARG_BOOL(0); + MigrateRels rels; + Relation native_meta; + Relation native_data; + Relation lolor_meta; + Relation lolor_data; + Oid lolor_meta_id; + Oid lolor_data_id; + int64 nobjects; + int64 npages; + + if (!superuser()) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("must be superuser to migrate large object storage"))); + + memset(&rels, 0, sizeof(rels)); + rels.to_native = to_native; + + lolor_meta_id = get_LOLOR_LargeObjectMetadataRelationId(); + lolor_data_id = get_LOLOR_LargeObjectRelationId(); + + /* + * Lock both stores against concurrent large object activity for the whole + * migration. + * + * Ordinary large object access takes RowExclusiveLock, which does not + * conflict with itself, so a concurrent lo_write() could commit between + * the page copy and the emptying of the source store and be lost without + * trace. ShareRowExclusiveLock conflicts with both, so writers wait and + * two migrations serialise. The locks are taken in the same order in + * either direction, so opposing migrations cannot deadlock. + */ + native_meta = table_open(LargeObjectMetadataRelationId, ShareRowExclusiveLock); + native_data = table_open(LargeObjectRelationId, ShareRowExclusiveLock); + lolor_meta = table_open(lolor_meta_id, ShareRowExclusiveLock); + lolor_data = table_open(lolor_data_id, ShareRowExclusiveLock); + + rels.desc_rel = table_open(get_LOLOR_LargeObjectDescriptionRelationId(), + ShareRowExclusiveLock); + rels.desc_idx = get_LOLOR_LargeObjectDescriptionIndexId(); + + if (to_native) + { + rels.src_meta = lolor_meta; + rels.src_data = lolor_data; + rels.dst_meta = native_meta; + rels.dst_data = native_data; + + rels.src_meta_idx = get_LOLOR_LargeObjectMetadataOidIndexId(); + rels.src_data_idx = get_LOLOR_LargeObjectLOidPNIndexId(); + rels.dst_meta_idx = LargeObjectMetadataOidIndexId; + rels.dst_data_idx = LargeObjectLOidPNIndexId; + } + else + { + rels.src_meta = native_meta; + rels.src_data = native_data; + rels.dst_meta = lolor_meta; + rels.dst_data = lolor_data; + + rels.src_meta_idx = LargeObjectMetadataOidIndexId; + rels.src_data_idx = LargeObjectLOidPNIndexId; + rels.dst_meta_idx = get_LOLOR_LargeObjectMetadataOidIndexId(); + rels.dst_data_idx = get_LOLOR_LargeObjectLOidPNIndexId(); + } + + /* Never copy tuples between relations whose layouts might differ. */ + validate_layout(rels.src_meta, rels.dst_meta, + Natts_pg_largeobject_metadata); + validate_layout(rels.src_data, rels.dst_data, + Natts_pg_largeobject); + + /* Refuse before writing anything if the destination already has the OIDs. */ + check_oid_conflicts(&rels); + + /* Likewise if a role the catalogs must record has been dropped. */ + if (to_native) + check_orphaned_roles(&rels); + + nobjects = copy_metadata(&rels); + npages = copy_data_pages(&rels); + + if (to_native) + { + truncate_lolor_table(rels.src_data); + truncate_lolor_table(rels.src_meta); + clear_parked_comments(&rels); + CommandCounterIncrement(); + } + else + { + drop_native_objects(&rels); + } + + /* + * Close the relcache references but hold the locks until the caller's + * transaction ends. Releasing them here would reopen the window this + * function exists to close: a write landing between the release and the + * commit would vanish when the emptied source store became visible. + */ + table_close(rels.desc_rel, NoLock); + table_close(lolor_data, NoLock); + table_close(lolor_meta, NoLock); + table_close(native_data, NoLock); + table_close(native_meta, NoLock); + + ereport(NOTICE, + (errmsg("migrated " INT64_FORMAT " large object(s), " + INT64_FORMAT " data page(s), to %s storage", + nobjects, npages, to_native ? "native" : "lolor"))); + + PG_RETURN_INT64(nobjects); +} diff --git a/t/009_migration.pl b/t/009_migration.pl new file mode 100644 index 0000000..d64d2f6 --- /dev/null +++ b/t/009_migration.pl @@ -0,0 +1,205 @@ +# Check large object migration between native and lolor storage +# +# Copyright (c) 2022-2026, pgEdge, Inc. +# + +use strict; +use warnings FATAL => 'all'; + +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('migration'); +my ($result, $stdout, $stderr); + +$node->init(allows_streaming => 'logical'); +$node->append_conf('postgresql.conf', qq{lolor.node = 1}); +$node->start; +$node->safe_psql('postgres', "CREATE EXTENSION lolor"); + +# ############################################################################## +# +# Build native large objects covering the shapes that migration has to +# preserve, then move them into lolor storage and back. +# +# ############################################################################## + +$node->safe_psql('postgres', "SELECT lolor.disable()"); +$node->safe_psql('postgres', "CREATE ROLE lo_owner"); +$node->safe_psql('postgres', "CREATE ROLE lo_grantee"); + +my $plain = $node->safe_psql('postgres', + "SELECT lo_from_bytea(0, 'annotated native object')"); +$node->safe_psql('postgres', qq( + ALTER LARGE OBJECT $plain OWNER TO lo_owner; + GRANT SELECT ON LARGE OBJECT $plain TO lo_grantee; + COMMENT ON LARGE OBJECT $plain IS 'survives the round trip'; +)); + +# A sparse object: a few bytes at offset 0 and a few 10MB in. +my $sparse = $node->safe_psql('postgres', "SELECT lo_create(0)"); +$node->safe_psql('postgres', qq( + BEGIN; + SELECT lo_open($sparse, x'60000'::int) AS fd \\gset + SELECT lowrite(:fd, 'start'); + SELECT lo_lseek64(:fd, 10000000, 0); + SELECT lowrite(:fd, 'end'); + SELECT lo_close(:fd); + COMMIT; +)); + +# An object with no data pages at all. +my $empty = $node->safe_psql('postgres', "SELECT lo_create(0)"); + +my $native_pages = $node->safe_psql('postgres', + "SELECT count(*) FROM pg_catalog.pg_largeobject"); + +$node->safe_psql('postgres', "SELECT lolor.enable()"); +$node->safe_psql('postgres', "SELECT lolor.migrate_from_native()"); + +is($node->safe_psql('postgres', + "SELECT count(*) FROM pg_catalog.pg_largeobject_metadata"), + '0', "native storage emptied by migrate_from_native()"); + +is($node->safe_psql('postgres', + "SELECT count(*) FROM lolor.pg_largeobject"), + $native_pages, + "page count preserved exactly, so sparse objects stay sparse"); + +is($node->safe_psql('postgres', + "SELECT count(*) FROM pg_shdepend WHERE classid = 'pg_largeobject'::regclass " + . "AND dbid = (SELECT oid FROM pg_database WHERE datname = current_database())"), + '0', "native shared dependencies removed with the objects"); + +is($node->safe_psql('postgres', + "SELECT description FROM lolor.pg_largeobject_description WHERE loid = $plain"), + 'survives the round trip', "comment parked while in lolor storage"); + +is($node->safe_psql('postgres', + "SELECT pg_get_userbyid(lomowner) FROM lolor.pg_largeobject_metadata WHERE oid = $plain"), + 'lo_owner', "owner preserved into lolor storage"); + +is($node->safe_psql('postgres', "SELECT convert_from(lo_get($plain), 'UTF8')"), + 'annotated native object', "content readable from lolor storage"); + +is($node->safe_psql('postgres', "SELECT length(lo_get($sparse))"), + '10000003', "sparse object keeps its logical length"); + +is($node->safe_psql('postgres', "SELECT length(lo_get($empty))"), + '0', "object with no data pages survives"); + +# ############################################################################## +# +# Everything must come back intact, including the catalog bookkeeping that +# only native storage participates in. +# +# ############################################################################## + +$node->safe_psql('postgres', "SELECT lolor.migrate_to_native()"); + +is($node->safe_psql('postgres', + "SELECT count(*) FROM lolor.pg_largeobject_metadata"), + '0', "lolor storage emptied by migrate_to_native()"); + +is($node->safe_psql('postgres', + "SELECT pg_get_userbyid(lomowner) FROM pg_catalog.pg_largeobject_metadata WHERE oid = $plain"), + 'lo_owner', "owner restored"); + +is($node->safe_psql('postgres', + "SELECT string_agg(deptype::text || ':' || refobjid::regrole::text, ',' ORDER BY deptype) " + . "FROM pg_shdepend WHERE classid = 'pg_largeobject'::regclass AND objid = $plain " + . "AND dbid = (SELECT oid FROM pg_database WHERE datname = current_database())"), + 'a:lo_grantee,o:lo_owner', + "owner and ACL grantee recorded in pg_shdepend"); + +is($node->safe_psql('postgres', + "SELECT description FROM pg_description " + . "WHERE classoid = 'pg_largeobject'::regclass AND objoid = $plain"), + 'survives the round trip', "comment reinstated on the catalog object"); + +is($node->safe_psql('postgres', + "SELECT count(*) FROM lolor.pg_largeobject_description"), + '0', "comment parking table drained"); + +is($node->safe_psql('postgres', "SELECT count(*) FROM pg_catalog.pg_largeobject"), + $native_pages, "page count still exact after the return trip"); + +# DROP ROLE must now see the objects, which a raw catalog update would not +# have allowed. +($result, $stdout, $stderr) = $node->psql('postgres', "DROP ROLE lo_owner"); +like($stderr, qr/cannot be dropped because some objects depend on it/, + "DROP ROLE refuses while the role owns migrated large objects"); + +($result, $stdout, $stderr) = $node->psql('postgres', "DROP ROLE lo_grantee"); +like($stderr, qr/cannot be dropped because some objects depend on it/, + "DROP ROLE refuses for an ACL grantee of a migrated large object"); + +# ############################################################################## +# +# Migrated objects must survive a restart, and an unclean one. +# +# ############################################################################## + +$node->safe_psql('postgres', "SELECT lolor.migrate_from_native()"); +$node->restart; + +is($node->safe_psql('postgres', "SELECT convert_from(lo_get($plain), 'UTF8')"), + 'annotated native object', "content survives a clean restart"); + +$node->safe_psql('postgres', + "SELECT lo_from_bytea(0, 'written before a crash') AS o"); +my $crash_oid = $node->safe_psql('postgres', + "SELECT oid FROM lolor.pg_largeobject_metadata ORDER BY oid DESC LIMIT 1"); + +$node->stop('immediate'); +$node->start; + +is($node->safe_psql('postgres', "SELECT convert_from(lo_get($plain), 'UTF8')"), + 'annotated native object', "content survives an immediate stop and recovery"); + +is($node->safe_psql('postgres', "SELECT convert_from(lo_get($crash_oid), 'UTF8')"), + 'written before a crash', + "a committed object written just before the crash is recovered"); + +# ############################################################################## +# +# Without spock there is no way to keep the migration out of logical decoding, +# so the presence of a logical slot must stop it rather than let subscribers +# diverge. +# +# ############################################################################## + +$node->safe_psql('postgres', "SELECT lolor.migrate_to_native()"); +$node->safe_psql('postgres', + "SELECT pg_create_logical_replication_slot('lolor_test_slot', 'pgoutput')"); + +($result, $stdout, $stderr) = + $node->psql('postgres', "SELECT lolor.migrate_from_native()"); +is($stdout, '-1', + "migrate_from_native() refuses with a logical slot present and reports -1"); +like($stderr, qr/logical replication slot\(s\) exist/, + "refusal explains why"); + +is($node->safe_psql('postgres', + "SELECT count(*) > 0 FROM pg_catalog.pg_largeobject_metadata"), + 't', "refusal left the native objects untouched"); + +# The zero-object case returns early, so put something in lolor storage for +# the guard to actually be reached. +$node->safe_psql('postgres', "SELECT lo_from_bytea(0, 'held in lolor storage')"); + +($result, $stdout, $stderr) = + $node->psql('postgres', "SELECT lolor.migrate_to_native()"); +isnt($result, 0, "migrate_to_native() raises an error rather than reporting -1"); +like($stderr, qr/cannot migrate large objects/, + "migrate_to_native() refuses on the drop path rather than losing objects"); + +$node->safe_psql('postgres', + "SELECT pg_drop_replication_slot('lolor_test_slot')"); + +is($node->safe_psql('postgres', "SELECT lolor.migrate_from_native() > 0"), + 't', "migration proceeds once the slot is gone"); + +$node->stop; +done_testing(); diff --git a/t/010_migration_locking.pl b/t/010_migration_locking.pl new file mode 100644 index 0000000..ead75e6 --- /dev/null +++ b/t/010_migration_locking.pl @@ -0,0 +1,139 @@ +# Check migration locking and cross-node OID collision detection +# +# Copyright (c) 2022-2026, pgEdge, Inc. +# + +use strict; +use warnings FATAL => 'all'; + +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('migration_locking'); +my ($result, $stdout, $stderr); + +$node->init; +$node->append_conf('postgresql.conf', qq{lolor.node = 1}); +$node->start; +$node->safe_psql('postgres', "CREATE EXTENSION lolor"); + +# ############################################################################## +# +# A migration must exclude concurrent large object activity for its whole +# transaction. Ordinary large object access takes RowExclusiveLock, which does +# not conflict with itself, so a migration holding only that lock would let a +# concurrent write commit between the copy and the emptying of the source +# store, losing it silently. +# +# ############################################################################## + +$node->safe_psql('postgres', "SELECT lo_from_bytea(0, 'to be migrated')"); + +# A migration refuses while another session is connected: the row movement +# cannot be made atomic for backends that already resolved the large object +# functions. +my $other = $node->background_psql('postgres'); +$other->query_safe("SELECT 1"); +($result, $stdout, $stderr) = + $node->psql('postgres', "SELECT lolor.migrate_to_native()"); +isnt($result, 0, + "migrate_to_native() refuses while another session is connected"); +like( + $stderr, + qr/while other sessions are connected to the database/, + "the refusal says why"); +$other->quit; + +# Once alone it proceeds, and for the window between that check and commit it +# holds ShareRowExclusiveLock on both stores, which excludes ordinary large +# object access (RowExclusiveLock) that could otherwise slip in. +is( $node->safe_psql( + 'postgres', qq( + SET client_min_messages = warning; + BEGIN; + SELECT lolor.migrate_to_native(); + SELECT count(*) FROM pg_locks l JOIN pg_class c ON c.oid = l.relation + WHERE c.relname IN ('pg_largeobject', 'pg_largeobject_metadata') + AND l.mode = 'ShareRowExclusiveLock' AND l.pid = pg_backend_pid(); + COMMIT;)), + "1\n4", + "the migration runs and holds ShareRowExclusiveLock on both stores until commit" +); + +is( $node->safe_psql( + 'postgres', "SELECT count(*) FROM pg_catalog.pg_largeobject_metadata" + ), + '1', + "the migrated object is in native storage"); + +$node->stop; + +# ############################################################################## +# +# Native large object OIDs are not node encoded, so two nodes can hold +# different objects under the same OID. Migrating both would converge them +# onto one row, and because the migration is hidden from replication nothing +# would report it. Passing the peer OIDs turns that into a refusal. +# +# ############################################################################## + +my $n1 = PostgreSQL::Test::Cluster->new('node1'); +my $n2 = PostgreSQL::Test::Cluster->new('node2'); +foreach my $n ($n1, $n2) +{ + $n->init; + $n->start; +} +$n1->append_conf('postgresql.conf', qq{lolor.node = 1}); +$n2->append_conf('postgresql.conf', qq{lolor.node = 2}); +$n1->restart; +$n2->restart; + +foreach my $n ($n1, $n2) +{ + $n->safe_psql('postgres', "CREATE EXTENSION lolor"); + $n->safe_psql('postgres', "SELECT lolor.disable()"); +} + +# Give both nodes a native object under the same OID but with different +# contents, which is exactly the situation that silently diverges. +$n1->safe_psql('postgres', + "SELECT lo_from_bytea(500001, 'node one content')"); +$n2->safe_psql('postgres', + "SELECT lo_from_bytea(500001, 'node two content')"); + +foreach my $n ($n1, $n2) +{ + $n->safe_psql('postgres', "SELECT lolor.enable()"); +} + +my $peer_oids = $n2->safe_psql('postgres', + "SELECT coalesce(string_agg(o::text, ','), '') FROM lolor.native_lo_oids() AS o" +); +is($peer_oids, '500001', "native_lo_oids() reports the peer's OIDs"); + +($result, $stdout, $stderr) = $n1->psql('postgres', + "SELECT lolor.migrate_from_native(peer_oids => ARRAY[$peer_oids]::oid[])" +); +isnt($result, 0, "migrate_from_native() refuses a colliding peer OID"); +like( + $stderr, + qr/also held natively by another node/, + "the refusal names the reason"); + +is( $n1->safe_psql( + 'postgres', "SELECT count(*) FROM pg_catalog.pg_largeobject_metadata" + ), + '1', + "the refusal left node one's native objects in place"); + +# Without the peer list the collision is invisible, which is why the argument +# exists; the migration itself still succeeds locally. +is($n1->safe_psql('postgres', "SELECT lolor.migrate_from_native()"), + '1', "migration proceeds when no peer OIDs are supplied"); + +$n1->stop; +$n2->stop; + +done_testing();