Skip to content

fix: improve lifecycle log accuracy - #1428

Open
itslenny wants to merge 35 commits into
masterfrom
lenny/lifecycle-log-fixes-after-versioning
Open

itslenny wants to merge 35 commits into
masterfrom
lenny/lifecycle-log-fixes-after-versioning

Conversation

@itslenny

@itslenny itslenny commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

What kind of change does this PR introduce?

Bug fix

What is the current behavior?

The lifecycle event logs used for usage tracking have accuracy issues:

  • ObjectCreated:Put / ObjectCreated:Copy does not include old object size when overwriting an existing object
  • Not logged if webhooks are disabled for the tenant causing usage tracking to be inaccurate
  • Logs emitted in queue job which can cause duplicate entries if the job is retried

What is the new behavior?

  • ObjectCreated:Put / ObjectCreated:Copy add an oldObject field with the replaced object's size. This lets usage be correctly attributed.
  • Emit ObjectRemoved:Delete when a move operation causes an object deletion (e.g. restore version)
  • All lifecycle events:
    • log once at dispatch time, before the event is queued. This prevents double counted usage.
    • emit lifecycle logs if webhooks are disabled

Lifecycle log change

This is an example of a lifecycle log diff after this change including oldObject if there is an object being overwritten

{
  "tenant": {
    "ref": "PROJECT_REF"
  },
  "name": "obj-name.txt",
  "version": "UUID",
  "bucketId": "bucket-name",
  "metadata": {
    "httpStatusCode": 200,
    "cacheControl": "no-cache",
    "eTag": "ETAG",
    "mimetype": "text/plain",
    "contentLength": 56,
    "lastModified": "TIMESTAMP",
    "size": 56
  },
  "reqId": "REQID",
  "uploadType": "standard",
+  "oldObject": {
+    "name": "obj-name.txt",
+    "bucketId": "bucket-name",
+    "version": "UUID",
+    "metadata": {
+      "eTag": "ETAG",
+      "size": 30,
+      "mimetype": "text/plain",
+      "cacheControl": "no-cache",
+      "lastModified": "TIMESTAMP",
+      "contentLength": 30,
+      "httpStatusCode": 200
+    },
    "reqId": "REQID"
  }
}

@itslenny
itslenny added this pull request to stack #1355 September 21, 2026 22:00
@itslenny
itslenny requested a review from a team as a code owner September 21, 2026 22:00

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Comment thread src/storage/object.ts
@itslenny
itslenny force-pushed the lenny/lifecycle-log-fixes-after-versioning branch from 468291e to c1ab0d1 Compare September 22, 2026 14:50
TylerHillery and others added 25 commits October 8, 2026 15:25
Every superuser statement issued inside a request-scoped transaction was
wrapped in its own SAVEPOINT, scope switch, scope restore and RELEASE,
and every nested withTransaction re-applied the scope of the enclosing
role. Track the scope applied to each transaction so a nested unit only
switches when the transaction is scoped to another role, queries issued
inside a superuser unit run without per-statement isolation, and scope
restoration targets the scope that was actually active before the switch
(a doubly elevated unit previously left the elevated scope behind).

Rolling back to a savepoint reverts transaction-local settings, so the
tracker is reset to the savepoint state on rollback.
Add waitObjectLocks to the database adapter. It deduplicates the keys,
sorts them by advisory-lock hash so overlapping batches can never deadlock
each other, and acquires all of them in a single round trip using the same
lock_timeout CTE shape as waitObjectLock (or the top-level statements on
Multigres). The statement aggregates the lock calls so every key is locked
before lock_timeout is restored, and the caller verifies the acquired
count.
…ints

Add findBucketsById (one statement, ascending id order, which is also the
lock order for FOR SHARE/FOR UPDATE reads) and findObjectTargets (current
rows for a list of names plus exact rows for a list of (name, version)
pairs in one statement ordered by (name, version), so a locking read takes
its row locks in one deterministic order across both target forms).

upsertObject, deleteObject and deleteObjects accept a versioningStatus
hint. A caller that already read the bucket status under its shared lock
in the current transaction passes it so the write skips its own status
lock and read.
Promotion picked the newest remaining row in a plain subquery. If that
candidate was being hard-deleted by a concurrent transaction, the UPDATE
waited on the row, found it gone once the other transaction committed and
updated nothing, leaving the key with archived rows but no current one.
Lock the candidate inside the subquery (single delete) or a LATERAL
subquery (bulk delete) so a deleted candidate is skipped and the next
remaining version is promoted instead.
…get read

Split deleteObjects into partition, lock, authorize, apply and cleanup
steps. Each batch now takes one batched advisory lock statement for all
keys, reads the bucket status once under its shared lock, and reads the
current rows and exact version rows in one FOR UPDATE statement ordered by
(name, version). The status is passed as a hint to the writes so they
skip their own status lock. The superuser work runs in one scoped unit.

Deleting a missing key on a versioned bucket writes a delete marker, an
INSERT under RLS whose policy violation throws instead of filtering rows.
That error used to abort the whole batch, including authorized keys.
Missing names are now probed together first and, only when that probe is
rejected, one by one, so a rejection drops just that key.

A bulk delete of 500 existing keys on an ENABLED bucket goes from 2,524
statements to 17, independent of the batch size.
… one

deleteObject took the key-level advisory lock for every delete, so all
version deletes of one key serialized with each other and with writers.
Deleting a non-current version changes nothing the current row depends
on, so it now locks only that version. Deleting the current row (no
versionId, or the versionId of the current version) still takes the key
lock, because writing a delete marker or promoting the next version
changes what is current. The lock scope is decided from an unlocked read
and verified under the row lock; a state change in between rejects the
delete with ResourceLocked rather than proceeding under the wrong lock.

The superuser work now runs in one scoped unit.
The pre-copy authorization pass no longer takes advisory locks or bucket
share locks: the post-copy pass re-reads everything under the final locks
and rejects the move if the status snapshot or the source row changed, so
the preflight only needs consistent reads. The post-copy pass takes both
key locks in one batched statement and reads both bucket statuses in one
FOR SHARE statement ordered by id, and passes the statuses to the
destination upsert and the source delete so they skip their own status
locks. The superuser work runs in one scoped unit.

A same-bucket move on an ENABLED bucket goes from 96 statements to 43.
…upload units

authorizeUpload took the current-row delete-marker state as a positional
boolean. Make it an optional field of CanUploadOptions: canUpload looks it
up only when the caller did not supply it, and completeUpload and
copyObject, which already hold the current row, pass it along.

Both run their superuser work in one scoped unit, so the statements
inside no longer pay a savepoint and scope switch each. An ENABLED upsert
goes from 43 statements to 28.
Replace the module-level WeakMap that tracked which scope was applied to
a transaction with a small holder created together with the transaction
and shared, through the options, by every StoragePgDB bound to it
(including asSuperUser copies). The state now lives exactly as long as
the database objects bound to that transaction and is not reachable from
anywhere else in the module. A transaction supplied from outside has no
holder and keeps the conservative unknown-scope behaviour.
…free

Four defects found by the object versioning fuzz suite.

The database write now reports the row it replaced in place (the DISABLED
current row, or the SUSPENDED null-version row, current or archived) through
extra RETURNING columns whose scalar subqueries read the statement snapshot.
Every writer frees those bytes through one helper (replacedContent): copy,
move, single delete and bulk delete used to look only at the current row and
leaked the bytes of an archived null-version row that a SUSPENDED write
resurrected. The uploader no longer needs its separate locking lookup.

Uploader.canUpload authorized writes by running the real archive UPDATE and
INSERT in a rolled-back savepoint without the key's advisory lock. When a
concurrent writer committed a new current row the probe collided with it on
idx_objects_current_version and the upload or copy failed with
ResourceAlreadyExists; the probe's two row locks, taken without the key lock,
also closed lock cycles that Postgres resolved with a deadlock. The probe is
now a single status-independent INSERT ... ON CONFLICT (current row) DO UPDATE
(upsertObject with probe: true) that takes no bucket status lock, never
touches the versioning flags and waits for a concurrent writer instead of
failing. The uploader, copy and single delete take the bucket's shared status
lock right after the advisory lock, the order move and bulk delete already
used, and pass the status to their writes.

authorizeMove probed the destination write before the source removal and
treated "nothing deleted" as a policy rejection, so a same-path move of the
null-version row under SUSPENDED (which rewrites that row first) and a move
racing a delete of its source failed with AccessDenied. The source removal is
probed first and a vanished row is reported as NoSuchKey.

Copy and move read the source row without a lock and copy its bytes before
locking; a concurrent hard delete surfaced the raw object store error. A
missing source is now resolved again and the copy retried once with the
version the row points at, otherwise NoSuchKey is reported.
resetMigration only deletes rows from storage.migrations, so a reset is a
replay: the runner re-executes the files from that point. Two migrations
were not safe to replay once a later migration had reverted part of them.
storage-schema recreated the bucketid_objname unique index that
drop-bucketid-objname-index removes, which fails on versioned duplicates
or blocks versioned writes; object-versioning-core re-added the dark check
that unlock-object-versioning drops, which fails on an ENABLED bucket or
silently re-locks versioning. MIGRATION_RESET_FLOORS blocked such resets
from a side table, keyed on rows still present in storage.migrations, so
two successive per-tenant resets slipped past it, and the idempotency
runner needed a flag to bypass it and skip those two files.

Fix the migrations instead. storage-schema creates the legacy index only
while the versioning transition function from unlock-object-versioning is
absent, so tenants below the drop still get it and a replay on a
version-aware schema leaves it dropped. object-versioning-core no longer
adds the dark check: the application refuses to enable versioning before
unlock-object-versioning, and that migration installs the transition
trigger that guards status changes afterwards. Existing tenants pick up
the new file hashes through the hash refresh.

Remove reset-floor.ts, the skipResetFloorValidation option and the replay
check in resetMigration, and let the idempotency script replay every
migration again. Verified with a full replay on a tenant seeded with an
ENABLED bucket and two versions of the same name, a fresh tenant frozen
before the drop, and the before/after pg_dump diff of the CI check.
… start

archived_at orders a key's versions: listings sort noncurrent versions by it
and promotion after a hard delete picks the most recently archived row. Both
archive UPDATEs used now(), which is the transaction start time. A writer
that began earlier but reached the key lock later would archive with an
older timestamp than a write that already committed, flipping the version
order and promoting the wrong row. clock_timestamp() is taken when the
statement runs, after the lock, so it follows lock order.
A delete without a versionId on an ENABLED or SUSPENDED bucket writes a
delete marker row, but that row carried no owner or owner_id, so it was the
only kind of row a principal could create anonymously. That breaks
owner-scoped policies such as USING (owner = auth.uid()): the marker probe
for a missing key fails its WITH CHECK, a later upload over the caller's own
marker fails the ON CONFLICT DO UPDATE policy check on the marker, and the
marker cannot be listed or deleted by owner.

Thread the caller's owner from the HTTP routes and the S3 handler through
ObjectStorage.deleteObject/deleteObjects and moveObject into the single and
batched marker writes, including the SUSPENDED in-place rewrite of the
not-versioned row.
…content

The finish hook of a resumable upload can run more than once for the same
version: a retried final PATCH reaches onUploadFinish again once the
offset equals the size. completeUpload now checks the key inside the
transaction and, when the current row already carries the incoming
version, reports the upload as done without rewriting it. Before this a
non-upsert replay threw KeyAlreadyExists and an ENABLED replay hit the
key/version unique index, and both landed in the catch block, which
scheduled the removal of that version's bytes: the live object lost its
content.

The catch block itself no longer trusts that a failure means the version
was never committed. It looks the version up first and only removes the
bytes of a version no row references; if the lookup fails it keeps them,
since an orphaned version is recoverable and a current version without
content is not.
Exercises the HTTP routes with a user token against a FOR ALL policy on
owner = auth.uid(): deleting a missing key, bulk deleting missing keys, and
uploading again over the caller's own delete marker. All three fail without
the marker owner, with AccessDenied on the marker INSERT probe and on the
upsert probe's ON CONFLICT DO UPDATE against the ownerless marker.
The UpsertObjectOptions flag that routes upsertObject to the rolled-back
RLS permission probe is now named for the row it targets; the option's
doc comment carries the full contract (probe shape, testPermission-only,
never a versioning toggle for real writes).
The pre-copy authorize pass claimed to take no locks, but its RLS probes
take row locks (source, then destination); two opposite concurrent moves
acquire them in inverted order and can deadlock, as can a move racing a
batch delete that covers both keys. The pass now enters the same
advisory-lock gate every other writer uses before touching rows, scoped
to the rolled-back testPermission transaction, so nothing stays locked
across the backend copy.
Drop the inert pre-copy spread from the copy upsert so the input lists
exactly the fields the write reads, and document which RLS policy
governs a versionless delete: the DELETE policy for an existing key
(versioning must not change who may delete), the INSERT policy for a
marker on a missing one.
The 0002 replay guard keyed on the trigger function from 0073, but the
bucketid_objname index is dropped by 0072, so a replay against a schema
sitting between the two rebuilt the index: a transactional CREATE UNIQUE
INDEX that ShareLocks storage.objects for the whole build, fails if any
names are duplicated, and is dropped again when 0072 replays. Key the
guard on idx_objects_current_version from 0066 instead - the earliest
schema marker of the several-versions era, never dropped or renamed by a
later migration. Verified by migrating a scratch database to 0072,
rewinding the migrations bookkeeping, and replaying 0002: the old guard
recreated the index, the new one skips it; a fresh install still creates
it.
completeUpload queued the delete of the replaced row's bytes (and the
created webhook) inside the write transaction. The queue lives in
another database, so a rollback after the send left a delete in flight
for a row that was still the live object, destroying its content. The
transaction now only returns what was written; the replaced-bytes
delete, the webhook and the success metric all run after commit. If a
post-commit send fails the replaced bytes are merely orphaned, which is
recoverable.
moveObject returned the transaction promise from inside the try block
without awaiting it, so a rejected transaction (status change, existing
destination, denied RLS) never reached the catch that removes the
destination bytes copied before the transaction started, orphaning them
in the backend. The test now pins that the cleanup event fires when the
transaction is rejected.
copySourceContent retries a copy whose source bytes vanished by
re-resolving the row, but copyObject discarded the re-resolved row and
kept building the destination - both the backend copy's replacement
metadata and the destination row - from the stale pre-copy source. The
copy callback now receives the resolved row and re-derives the
destination metadata from it, so the retry describes the bytes it
actually copied. moveObject already used the returned row; only its
callback signature changes.
fenos added 9 commits October 8, 2026 15:26
findObjects and findObjectVersions ordered their locking reads by name
in the default collation while findObjectTargets locks in name COLLATE
"C" order. Two multi-row lockers sorting the same names under
different collations can acquire row locks in inverted order and
deadlock; the C collation also matches the objects indexes.
findObjectTargets combined its two target forms with an OR, and
Postgres cannot BitmapOr an arm containing a subquery join: a batch
mixing plain names and (name, version) pairs fell back to walking every
row of the bucket (301k buffers vs 74 on a 300k-row bench bucket). Each
form now resolves in its own UNION ALL subquery on its intended index
and the outer statement locks the union by id, keeping the single
deterministic (name, version) lock order. Single-form batches keep
their direct predicate and plan unchanged.
Deleting a missing key on a versioned bucket still writes a delete
marker (S3 parity), but authorizing it as the caller's INSERT broke the
rule that DELETE policies govern deletes: delete-rights users could not
delete missing keys, insert-only users could tombstone names they could
never delete, and per-name verdicts needed a probe per name because a
denied INSERT aborts the whole statement while the batch held every
key's advisory lock.

A DELETE policy only evaluates against rows, so the probe now
materializes the markers as superuser inside the rolled-back
testPermission transaction and asks, in one RLS-filtered statement,
which of them the caller may delete. Per-name verdicts in a constant
number of statements, the sequential probe loop is gone, and every
delete - existing, versioned or missing - is governed by the same
DELETE policy. A denied missing-key single delete now reports NoSuchKey
instead of AccessDenied, matching what a caller without rights could
already observe.
The S3 surface never read VersionId: a versioned GetObject or
HeadObject silently served the current version, a versioned
DeleteObject silently deleted or markered the current version, and a
versionId on x-amz-copy-source was glued onto the source key. Now that
versioned writes exist, acting on the wrong version is live
misbehavior, so the routes parse versionId and the handlers reject it
as NotSupported until version-addressed operations land: GetObject,
HeadObject (backend and db variants), DeleteObject, per-entry
DeleteObjects, CopyObject and UploadPartCopy sources.
Move dispatched the queued deletes of every byte range it freed (the
replaced destination row, the marker-replaced null-version row, the
source content) and both webhooks inside its transaction, and copy did
the same for its replaced destination row: a rollback after a send left
deletes in flight for rows the rollback restored. Both catch blocks
also assumed any failure meant nothing committed, so a post-commit send
failure would have deleted the committed destination's bytes. The
transactions now only report what they freed; sends and webhooks run
after commit, and the cleanup catch fires only while nothing has
committed.
…load bytes

copyObject and moveObject tracked a local `committed` boolean, set once
their write transaction resolved, to decide whether a later failure could
safely delete the bytes just copied to the destination. That flag isn't
safe: a transaction can COMMIT on the server and still surface an error
to the client (an acknowledgement lost after COMMIT, for instance), which
would leave `committed` false while a row now references those bytes,
deleting a live object's content. Both now reuse the same database probe
completeUpload already relies on for this (extracted as the standalone
isCommittedVersion), checking whether a row actually references the
version before deleting it.

completeUpload's post-commit queue send for a replaced version's bytes had
no failure handling, unlike the webhook send next to it. A transient
failure there (the write already committed) aborted the whole completion
and reported an already-successful upload as failed to the caller. It now
logs and swallows the failure the same way the webhook send does, since
the orphaned bytes are recoverable.
getTenantCapabilities overwrote the application flag with the tenant
feature flag in multitenant mode, so object_versioning reported false
with STORAGE_VERSIONING_ENABLED=true. The enforcement path in
versioning/guards.ts already treats the application flag as a global
override, so the reported capability contradicted what writes allowed.
Rebasing onto master dropped the stub adaptations that previously lived
in the merge commit, and master grew suites that predate them:

- uploader, bucket mime-type and TUS upload-id fixtures stub
  hasMigration, which canUpload and completeUpload now consult
- the UploadPartCopy CopySource cases go through storage.from(bucket),
  since the source lookup is routed through ObjectStorage
- the S3 multipart MIME probe asserts the upsertObject options argument,
  which the permission probe now passes
@itslenny
itslenny force-pushed the lenny/lifecycle-log-fixes-after-versioning branch from c1ab0d1 to e469b06 Compare October 8, 2026 14:30
@coveralls

Copy link
Copy Markdown

Coverage Report for CI Build 37792990870

Warning

No base build found for commit 31c8408 on tyler/feat/object-versioning-wave-3.
Coverage changes can't be calculated without a base build.
If a base build is processing, this comment will update automatically when it completes.

Coverage: 85.221%

Details

  • Patch coverage: 3 uncovered changes across 2 files (13 of 16 lines covered, 81.25%).

Uncovered Changes

File Changed Covered %
src/storage/object.ts 4 2 50.0%
src/storage/uploader.ts 5 4 80.0%
Total (5 files) 16 13 81.25%

Coverage Regressions

Requires a base build to compare against. How to fix this →


Coverage Stats

Coverage Status
Relevant Lines: 15011
Covered Lines: 13245
Line Coverage: 88.24%
Relevant Branches: 9429
Covered Branches: 7583
Branch Coverage: 80.42%
Branches in Coverage %: Yes
Coverage Strength: 849.07 hits per line

💛 - Coveralls

Base automatically changed from tyler/feat/object-versioning-wave-3 to master October 9, 2026 10:49

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants