Skip to content

fix(lfs): failed upload deletes a concurrent upload's meta object - #38693

Open
hsdfat wants to merge 1 commit into
go-gitea:mainfrom
hsdfat:fix-lfs-failed-upload-deletes-meta
Open

fix(lfs): failed upload deletes a concurrent upload's meta object#38693
hsdfat wants to merge 1 commit into
go-gitea:mainfrom
hsdfat:fix-lfs-failed-upload-deletes-meta

Conversation

@hsdfat

@hsdfat hsdfat commented Jul 29, 2026

Copy link
Copy Markdown

UploadHandler creates the LFS meta object only as the last step of uploadOrVerify, after the content is already in the store, so a request that errors has never created a row of its own. The removal on the error path was a real compensating action when it was added in #14726, where the meta object was created before contentStore.Put, but #16865 moved creation after the Put and left the removal behind.

Since then it can only ever delete a row created by a different request: a stalled git-lfs PUT that fails after its own retry has already succeeded wipes the winner's meta object, leaving the content in the store unreachable and eligible for orphan cleanup.

Drop the removal and add a regression test asserting that a failing upload keeps a pre-existing meta object.

Fixes #38424.

Assisted-by: Claude Code:claude-opus-5

UploadHandler creates the LFS meta object only as the last step of
uploadOrVerify, after the content is already in the store, so a request
that errors has never created a row of its own. The removal on the error
path was a real compensating action when it was added in go-gitea#14726, where
the meta object was created before contentStore.Put, but go-gitea#16865 moved
creation after the Put and left the removal behind.

Since then it can only ever delete a row created by a different request:
a stalled git-lfs PUT that fails after its own retry has already
succeeded wipes the winner's meta object, leaving the content in the
store unreachable and eligible for orphan cleanup.

Drop the removal and add a regression test asserting that a failing
upload keeps a pre-existing meta object.

Fixes go-gitea#38424.

Assisted-by: Claude Code:claude-opus-5
@GiteaBot GiteaBot added the lgtm/need 2 This PR needs two approvals by maintainers to be considered for merging. label Jul 29, 2026
Comment thread services/lfs/server.go
log.Error("Error whilst removing MetaObject for LFS OID[%s]: %v", p.Oid, err)
}
// Do not remove the meta object here: this request only creates it after the
// content is stored, so removing by oid could only drop a concurrent upload's row.

@wxiaoguang wxiaoguang Jul 29, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

What if the store content is corrupted (incomplete)? How to recover it by the next upload?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good question, and checking it showed my own premise was wrong, so let me give the accurate picture.

ContentStore.Put does not delete on every error. If Save returns an error it returns at modules/lfs/content_store.go:57, above the Delete block, which is only reached when Save returned nil but the wrapper recorded an error or the written size differs. What actually keeps a partial object from becoming visible is backend atomicity: local Save copies to a temp file and only renames it into place at the end, removing the temp on any error (modules/storage/local.go:80-100), and MinIO/S3 PutObject never publishes a partial object. The one case where the reader error is swallowed — bytes written equal the expected size — is exactly the case Put's explicit Delete covers.

On the removal itself: it cannot be a self-cleanup, because NewLFSMetaObject is the last statement of uploadOrVerify. A request that errors has no row of its own, so RemoveLFSMetaObjectByOid(repo, oid) either deletes nothing or deletes a row another request created after its content was safely stored.

And for missing content with a meta row present, recovery does not depend on deleting the row: BatchHandler keys the upload action on !exists, so the object is offered for upload again regardless of the meta row, and Put rewrites it.

Where recovery genuinely does not work today, independently of this PR:

  • Both handlers use contentStore.Exists (Stat only) rather than Verify (Stat + size), so a wrong-sized stored object counts as present. Batch then offers no upload action at all, and if there is no meta row, UploadHandler takes the proof-of-possession branch, hashes the request body, creates the row, and never rewrites the stored bytes. I confirmed the same short-circuit from a test: once content and a meta row both exist, a PUT for that oid returns 200 without reading the body, so a client cannot repair a bad object by re-uploading it.
  • Deleting the meta row would not have repaired any of that either — it only changes which branch runs, never the stored bytes.

There is one narrow state where the old unconditional delete did incidentally unblock a repository, and I think it points at a separate bug worth fixing:

LFSAutoAssociate inserts the size submitted in the form rather than the size of the object it validated. It looks up the accessible rows with Cols("oid") (models/git/lfs.go:227), so the real size is never fetched, and then assigns newMetas[i].Size = oidMap[newMetas[i].Oid].Size (models/git/lfs.go:236) — the caller's value, parsed straight from the form at routers/web/repo/setting/lfs.go:521. A row whose size does not match the object is therefore reachable from the repo settings page, and BatchHandler rejects it with 422 at services/lfs/server.go:247 before ever offering an upload, so the repository stays stuck. The old delete could clear such a row if a client still held an upload URL minted before the row appeared — a coincidence rather than a mechanism, since a normal client gets the 422 and never receives an upload URL, but it is fair to say the broad delete happened to paper over it.

Selecting the size alongside the oid and using the stored value looks like the right fix there. I'm happy to send that as a separate PR, and to switch Exists to Verify in both handlers (allowing the upload when the content is genuinely missing even if an existing row has a different size) either here or as a follow-up — I'd rather keep this PR to the concurrent-delete bug unless you prefer it all together.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Unable to understand, don't reply by AI. Just tell me what's the right thing to do in one sentence.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Sorry mb, request only insert meta row after the content is stored, so on failure it has no row of its own and 'RemoveLFSMetaObjectByOid(repo, oid)' can only delete a row that a concurrent successful upload created

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

Labels

lgtm/need 2 This PR needs two approvals by maintainers to be considered for merging. topic/lfs type/bug

Projects

None yet

Development

Successfully merging this pull request may close these issues.

LFS: failed concurrent upload deletes the MetaObject created by a successful retry, silently orphaning content (404 despite content in store)

3 participants