Skip to content

Rewrite large object migration as a direct relation copy. - #63

Open
ibrarahmad wants to merge 1 commit into
mainfrom
lo-migration-copy
Open

ibrarahmad wants to merge 1 commit into
mainfrom
lo-migration-copy

Conversation

@ibrarahmad

@ibrarahmad ibrarahmad commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

lolor moves large objects between PostgreSQL's native storage and its own tables with lolor.migrate_from_native() and lolor.migrate_to_native(). The move back to native storage is written in plpgsql and rebuilds each object through the large object API, one lo_lseek64() and one lowrite() call per 2 KB page, because the original commit believed rows cannot be inserted into pg_catalog.pg_largeobject directly. A superuser can insert into it, and both functions already require superuser.

A 1 GB large object is about 500,000 pages, so moving it back costs about a million function calls. A sparse object, with data at offset 0 and at offset 10 MB and nothing in between, occupies two pages, but the move writes all 5,000 pages in between as zeros, so 4 KB of data becomes 10 MB on disk.

After migrate_to_native(), DROP ROLE alice succeeds although alice still owns the migrated objects, and REASSIGN OWNED BY alice TO bob leaves them untouched. Ownership was set with a plain UPDATE on the catalog, which records nothing in pg_shdepend, the table PostgreSQL uses to protect owned objects.

COMMENT ON LARGE OBJECT 16385 IS 'invoice scan', then migrate_from_native(), then migrate_to_native(): the comment is gone.

While a migration runs, another session's lo_write() can commit between the copy of the rows and the removal of the originals, and that write disappears with no error. Both sides take RowExclusiveLock, which does not conflict with itself.

Native large object OIDs are not node encoded. Running migrate_from_native() on several nodes of a cluster can map different objects onto the same OID, and because the migration runs under spock.repair_mode(), the other nodes never see it happen.

How this PR fixes it

lolor.migrate_storage(to_native boolean), in C, copies tuples directly between pg_catalog.pg_largeobject{,_metadata} and lolor.pg_largeobject{,_metadata}. The two pairs have the same layout by construction, and the function compares the tuple descriptors against the running server before copying anything. Pages are copied as they are, so a sparse object stays sparse and no per-page API calls are made. Native objects are removed with performMultipleDeletions(), the path DROP uses, so their dependencies, comments and security labels go with them. Each row is processed in its own memory context. migrate_to_native() no longer requires lolor to be enabled, and DROP EXTENSION lolor still migrates objects back through the existing event trigger, now through this copy.

On the way to native storage, ownership and ACLs are recorded with recordDependencyOnOwner() and recordDependencyOnNewAcl(), so DROP ROLE, REASSIGN OWNED and DROP OWNED see the objects again.

Comments are kept in a new table, lolor.pg_largeobject_description, while an object is in lolor storage and put back with CreateComments() when it returns. lo_unlink() removes the parked row so a reused OID cannot pick it up.

Both stores are locked with ShareRowExclusiveLock until the transaction commits, in a fixed order so that two migrations in opposite directions cannot deadlock.

Before copying to native storage, every owner and every role in an ACL is checked against pg_authid; objects that refer to a dropped role are reported with their OIDs and the migration refuses, with a hint to lo_unlink() them, instead of failing part way through with role N was concurrently dropped. Both functions refuse while another session is connected to the database and when run from anything other than a client session. migrate_from_native(peer_oids oid[]) refuses if any native OID on this node is in the list, and lolor.native_lo_oids() and lolor.digest() produce the per-node data for building that list and comparing nodes afterwards. migrate_from_native() also refuses if any large object carries a security label, which lolor storage cannot represent.

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.
@coderabbitai

coderabbitai Bot commented Oct 9, 2026

Copy link
Copy Markdown

Warning

Review limit reached

  • Run on-demand review

This review includes 12 billable files and costs up to $3.00.

Or wait 59 minutes for your next included review.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 380ce27e-b5d6-4fbb-8a7f-dd9c535bc377

📥 Commits

Reviewing files that changed from the base of the PR and between 6d7cc89 and 11a814f.


⛔ Files ignored due to path filters (1)
  • expected/lolor.out is excluded by !**/*.out

📒 Files selected for processing (12)
  • Makefile
  • README.md
  • docs/index.md
  • docs/lolor_release_notes.md
  • lolor--1.2.2--1.3.0.sql
  • sql/lolor.sql
  • src/lolor.c
  • src/lolor.h
  • src/lolor_largeobject.c
  • src/lolor_migrate.c
  • t/009_migration.pl
  • t/010_migration_locking.pl


  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

@codacy-production

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 16 high · 5 medium

Results:
21 new issues

Category Results
Compatibility 16 high (4 false positives)
Complexity 5 medium

View in Codacy

🟢 Metrics 68 complexity · 2 duplication

Metric Results
Complexity 68
Duplication 2

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

@ibrarahmad ibrarahmad self-assigned this Oct 9, 2026
@ibrarahmad ibrarahmad added the enhancement New feature or request label Oct 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant