lolor - Managing Large Objects for Logical Replication
lolor is an extension that makes Postgres-style large objects compatible with logical Replication.
Postgres supports large objects as related chunks as described in the pg_largeobject table. Large objects provide stream-style access to user data stored in a special large-object structure in the catalog. Large objects stored in catalog tables require special handling during replication; the lolor extension allows for the storage of large objects in non-catalog tables, aiding in replication of large objects.
lolor creates and manages large object related tables in the lolor schema:
lolor.pg_largeobject
lolor.pg_largeobject_metadata
Postgres large objects allow you to store large files within the database. Each large object is recognised by an OID that is assigned at the time of its creation. lolor stores objects in smaller segments within a separate system table and generates associated OIDs for large objects that are distinct from those of native large objects.
Use of the lolor extension requires Postgres 16 or newer.
Migrating large objects
Migration is a manual step in both directions: you decide when to move existing large objects into lolor storage, and you move them back before dropping the extension. Dropping the extension never moves data: it is refused while any object remains in lolor storage.
Native to lolor (manual)
If your database already contains native Postgres large objects, call
migrate_from_native() after enabling the extension. The migration preserves
original OIDs, owners, ACLs, and data, so existing application references remain
valid.
CREATE EXTENSION lolor;
SELECT lolor.migrate_from_native();
The function returns the number of large objects migrated. It is safe to call when there are no native large objects. This step is intentionally not automatic: it requires superuser privileges and should be performed during a maintenance window.
Lolor to native (before DROP EXTENSION)
Move the large objects back to native Postgres storage, then drop the extension:
SELECT lolor.migrate_to_native();
DROP EXTENSION lolor;
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:
SELECT * FROM lolor.digest(); -- per-object checksum, compare across nodes
An object access hook refuses to remove the extension while it is enabled or
while any object remains in lolor storage, on every path that reaches it
(DROP EXTENSION, DROP SCHEMA lolor CASCADE, DROP OWNED BY). The drop
itself only puts the native pg_catalog.lo_* names back. To discard whatever
is in lolor storage instead, a superuser can set lolor.allow_unsafe_drop = on
for the session that runs the drop. It skips every check, and the drop then
does not restore the native function names either, so run lolor.disable()
first if lolor is enabled. While lolor is enabled, a normal drop renames the
native functions back, so it has the same requirements as lolor.disable():
no other session connected to the database, and a client session. A disabled
lolor renames nothing, and the drop has no such requirement.
lolor.enable() and lolor.disable() refuse while
any other session is connected to the database, and refuse to run from
anything but a client session. Reconnect client sessions after enable() or
disable(): libpq caches the large object function OIDs per connection.
Two helpers find and repair objects whose roles have been dropped:
SELECT * FROM lolor.check_orphans(); -- objects referring to a dropped role
SELECT lolor.fix_orphans('some_role'); -- reassign them and drop dead ACL entries
When the spock extension is installed, both migration functions run under
spock.repair_mode(), so the row-shuffling migration DML is not replicated
to other nodes. This is essential for migrate_to_native(): its deletes from
the lolor tables would otherwise replicate while the native re-creation stayed
local, destroying large objects on the other nodes.
Even with spock, migration is refused if a non-spock logical replication slot
(e.g. pgoutput, wal2json) exists, since repair mode only suppresses spock's
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.
Without spock, the migration DML cannot be excluded from logical decoding, so
both functions refuse to migrate while logical replication slots exist in the
database: migrate_from_native() raises a WARNING and returns -1 without
doing anything (0 is reserved for "nothing to migrate"), while
migrate_to_native() raises an ERROR. Drop all logical replication slots before
migrating; merely disabling a subscription is not sufficient, since its slot
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 against objects held in lolor storage:
ALTER LARGE OBJECT,GRANT ON LARGE OBJECT,COMMENT ON LARGE OBJECT, andREVOKE ON LARGE OBJECT. Owners, ACLs and comments set while an object was in native storage are preserved across migration in both directions. lolor.enable(),lolor.disable(), the migration functions, and dropping the extension while lolor is enabled, refuse while any other session is connected to the database, the same rule asALTER DATABASE ... RENAME: a renamed function keeps its OID, so a session that already resolved thelo_*functions keeps calling the previous implementation, and the row movement cannot be made atomic for other backends either. The calling session must reconnect afterenable()ordisable(). They also refuse to run from anything but a client session, so replicated DDL cannot drive them from an apply worker.- Objects in lolor storage are rows in ordinary tables and so cannot participate in
pg_shdepend.DROP ROLE,DROP OWNED BYandREASSIGN OWNED BYdo not see them: a role that owns them or appears in their ACL can be dropped without a warning, andREASSIGN OWNED/DROP OWNEDleave them untouched. Before dropping a role, runREASSIGN OWNED BYorDROP OWNED BYin each database that has lolor, thenDROP ROLE. For the role that owns the extension useREASSIGN OWNED BY:DROP OWNED BYwould remove the extension too, and is refused while any object remains in lolor storage. Afterwards runlolor.check_orphans()in each such database and repair anything it reports withlolor.fix_orphans(new_owner);lolor.migrate_to_native()refuses while orphans exist. - Role OIDs come from a cluster-wide counter that wraps around, so a new role can receive a dropped role's OID and silently become the owner or grantee of that role's orphaned objects. This cannot be detected after the fact, which is another reason to run
lolor.check_orphans()promptly after dropping roles. - 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 andmigrate_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 vialolor.nodeand checked against existing rows. To make that hazard an error rather than silent divergence, collect the other nodes' OIDs withlolor.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, comparelolor.digest()across nodes to confirm they converged.