Skip to content

Feature: conditional upload API#178

Open
alsenz wants to merge 13 commits intothanos-io:mainfrom
alsenz:feat_conditions_API_129
Open

Feature: conditional upload API#178
alsenz wants to merge 13 commits intothanos-io:mainfrom
alsenz:feat_conditions_API_129

Conversation

@alsenz
Copy link
Copy Markdown

@alsenz alsenz commented Apr 21, 2025

Resolves #129

Adds support for conditional object uploads.

Extends ObjectAttributes with optional ObjectVersion (if supported), and Upload(...) with three new ObjectUploadOption, depending on provider support.

  1. IfMatch: the object is written only if the existing object matches the provided version
  2. IfNotMatch: the object is written only if the existing object does not match the provided version
  3. IfNotExists: the object is written only if no object already exists for the provided key

Supports two forms of ObjectVersion:

  1. Generational versions (i.e. increasing integers)
  2. Etags

Not all providers support conditional write. The following providers are supported:

  1. Filesystem: uses extended filesystem attributes. Only supported if the host system supports extended attributes.
  2. InMem
  3. GCS
  4. Azure
  5. S3: IfNotMatch is not yet supported (as it is not supported by AWS).

Clients can check conditional API by calling SupportedObjectUploadOptions on the Bucket interface.

  • I added CHANGELOG entry for this change.
  • Change is not relevant to the end user.

Changes

API changes

  1. Added 3 new ObjectUploadOptions: IfMatch, IfNotMatch and IfNotExists
    1.1 Made ObjectUploadOption typed, repeating the existing IterOption pattern.
    1.2 Added three new parameter modifiers- WithIfMatch, WithIfNotExists and WithIfNotMatch, and WithContentType to support the previous un-typed ObjectUploadOption
  2. Added optional ObjectVersion field to ObjectAttributes
  3. Added acceptance tests for IfMatch, IfNotMatch, IfNotExists and for Attributes API
  4. Added Bucket provider implementations for:
    1.1 filesystem: this now uses Extended Attributes, if supported by the host system
    1.2 inmem
    1.3 GCS
    1.4 Azure
    1.5 S3, excepting IfNoneMatch which is not fully supported (by AWS) yet
    1.6 Wrappers

Backwards compatibility

API changes are additive and should be backwards compatible.

  • The Upload API changes use a trailing variadic parameter so should be backwards compatible

  • The ObjectAttributes add new field so should be backwards compatible

  • The SupportedObjectUploadOptions interface method potentially breaks existing Bucket implementations. Library users who implement their own providers will need to implement this method. Full or stub implementations are provided for all provider implementations in the libary.

Dependencies

  • Adds dependency on github.com/pkg/xattr, which is BSD-2-clause licensed.

Verification

Added acceptance tests for

  • IfMatch
  • IfNotMatch
  • IfExists
  • Versions on Attributes

Did not run integration tests on other object store providers, as I do not have access to test environments, but these should be unchanged.

@alsenz alsenz changed the title Feature: conditions API Feature: conditional upload API Apr 21, 2025
alsenz added 4 commits April 21, 2025 14:56
…, inmem providers

Signed-off-by: Tom Plowman <7210552+alsenz@users.noreply.github.com>
Signed-off-by: Tom Plowman <7210552+alsenz@users.noreply.github.com>
Signed-off-by: Tom Plowman <7210552+alsenz@users.noreply.github.com>
Signed-off-by: Tom Plowman <7210552+alsenz@users.noreply.github.com>
@alsenz alsenz force-pushed the feat_conditions_API_129 branch from c21b930 to 1bb876b Compare April 21, 2025 13:57
@alsenz
Copy link
Copy Markdown
Author

alsenz commented May 1, 2025

@bwplotka - would you be the appropriate maintainer to submit this for a first review?

Greatly appreciated.

}
}

func tryOpenFile(name string, ifNotExists bool) (exists bool, err error) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

does it leak file descriptor?

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.

Agreed - fixed.

Comment thread objstore.go
}

// ValidateUploadOptions ensures that only supported options are passed as options.
func ValidateUploadOptions(supportedOptions []ObjectUploadOptionType, opts ...ObjectUploadOption) error {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

maybe we should also validate IfNotExists and IfMatch/IfNotMatch aren't used together

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.

Sounds sensible - done.

Comment thread inmem.go
b.mtx.Lock()
defer b.mtx.Unlock()

params := ApplyObjectUploadOptions(opts...)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

shall we validate the options here?

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.

Whoops - done!

@anarcher
Copy link
Copy Markdown

What's the progress on this PR? Any chance it gets merged?

Signed-off-by: Alsenz <7210552+alsenz@users.noreply.github.com>
Signed-off-by: Tom Plowman <7210552+alsenz@users.noreply.github.com>
@alsenz alsenz force-pushed the feat_conditions_API_129 branch from a5d77ae to b395c1c Compare December 22, 2025 22:15
Signed-off-by: Tom Plowman <7210552+alsenz@users.noreply.github.com>
@alsenz
Copy link
Copy Markdown
Author

alsenz commented Dec 22, 2025

Thanks @yuchen-db for your review. I agree with all your points and have pushed some changes accordingly. This is an old PR but hopefully we can get it across the line.

@alsenz
Copy link
Copy Markdown
Author

alsenz commented Dec 22, 2025

@anarcher this is an older PR that didn't get much attention, hopefully the maintainers will be able to find some time to review and accept. I have brought the feature branch back in line with main and responded to comments, so it should be ready to go.

Signed-off-by: Tom Plowman <7210552+alsenz@users.noreply.github.com>
Signed-off-by: Tom Plowman <7210552+alsenz@users.noreply.github.com>
@alsenz alsenz force-pushed the feat_conditions_API_129 branch from cbdcc69 to 9ee1894 Compare December 22, 2025 22:44
Signed-off-by: Tom Plowman <7210552+alsenz@users.noreply.github.com>
@RichardZhangRZ
Copy link
Copy Markdown

Would like to see this PR merged soon :)

@GiedriusS
Copy link
Copy Markdown
Member

I'll try to take a look no later than 2 months~ from now

Comment thread providers/azure/azure.go Outdated
return ctx.Err()
}

file := filepath.Join(b.rootDir, name)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Why can't we create an extended attribute on the same file?

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.

This was written a while ago, so apologies if I'm a little hazy, but I'm fairly use we're using the swap-and-move idiom to prevent race conditions when the file is created but the xattrs aren't written, which would could cause versioning assumptions to go wrong- this relies on atomic move of course but that's already ensured by SupportedObjectUploadOptions - we don't do this code path if we don't have xattrs and atomic moves.

To double check, I ran this through claude (which I try to avoid relying on but is a good double-check), it said:
▎ The xattr is set on the swap file before the atomic os.Rename. Since os.Rename on Unix is atomic and xattrs are stored as inode metadata, they travel with the file on rename. Setting the xattr on file after the
rename would introduce a race: another goroutine could call checkConditions in that window and see the file without its version data.

Comment thread providers/gcs/gcs.go Outdated
return false
}

// IsConditionNotMetErr returns true if the response status code was Precondition Failed or Not Modified.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The same here: I suggest not adding into the comments what is already in the code.

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.

Agreed - will fix in next commit

Comment thread providers/s3/s3.go
if uploadOpts.IfNotExists {
putOpts.SetMatchETagExcept("*")
} else if uploadOpts.Condition != nil {
// If-None-Match with header values other than "*" is not supported by AWS yet.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Should we error out on this condition? How the user would know about this? The user could pass something and it would do something different than what is expected so I would expect an error here.

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.

Absolutely - yes, good catch! Will fix in next commit.

@GiedriusS
Copy link
Copy Markdown
Member

@alsenz ping

@alsenz
Copy link
Copy Markdown
Author

alsenz commented Apr 28, 2026

@GiedriusS just seen this thanks for the review (been a while sorry).

I will resolve these tonight after work

alsenz and others added 2 commits April 28, 2026 16:39
Co-authored-by: Giedrius Statkevičius <giedriuswork@gmail.com>
Signed-off-by: Alsenz <7210552+alsenz@users.noreply.github.com>
Signed-off-by: Tom Plowman <7210552+alsenz@users.noreply.github.com>
@alsenz alsenz requested a review from GiedriusS April 29, 2026 20:55
@alsenz
Copy link
Copy Markdown
Author

alsenz commented Apr 29, 2026

@GiedriusS -- PR comments addressed - haven't resolved the comments yet until you take a look. my best.

Copy link
Copy Markdown
Member

@GiedriusS GiedriusS left a comment

Choose a reason for hiding this comment

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

Thank you, a few more suggestions

Comment thread README.md Outdated
// Upload should be idempotent.
Upload(ctx context.Context, name string, r io.Reader, opts ...ObjectUploadOption) error
Upload(ctx context.Context, name string, r io.Reader, options ...ObjectUploadOption) error

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Some random whitespace issues here.

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.

Will resolve.

Comment thread objstore.go Outdated

// ObjectUploadOption configures UploadObjectParams.
type ObjectUploadOption struct {
Type ObjectUploadOptionType
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I don't like that these two are disjointed now and users can misuse this ie create some ObjectUploadOption that doesn't do what Type says it should do. Should we make Type and Apply hidden i.e. lowercase so that users wouldn't be able to do that?

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.

No fair enough, although I think there may be other places where symbols are overexposed maybe. Either way, easy change to make and very happy to do.

Comment thread providers/filesystem/filesystem.go Outdated
}

func tryOpenFile(name string, ifNotExists bool) (exists bool, err error) {
// First try to open the file with exclusive create, then truncate if permitted
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Where truncation happens here? 🤔

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.

No idea. I think it's a comment typo rather than code typo, re-readin gthe whole logic.

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.

I'll just remove the comment fo rnow

Comment thread providers/filesystem/filesystem.go Outdated
if _, err := io.Copy(writer, r); err != nil {
return errors.Wrapf(err, "copy to %s", swap)
}
// Write the checksum into an xattr
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I suggest removing self-explanatory comments like this.

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.

Will remove

return err
}

if xattr.XATTR_SUPPORTED {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

If this is false then where are we writing to swf?

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.

This is a bug- thanks - think the fix is a few lines, will fix.

Comment thread objstore.go Outdated

// ValidateUploadOptions ensures that only supported options are passed as options, and that options used simultaneously are valid.
func ValidateUploadOptions(supportedOptions []ObjectUploadOptionType, opts ...ObjectUploadOption) error {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Typically we don't put a empty line just after the definition.

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.

Will do

Comment thread providers/filesystem/filesystem.go Outdated
// Upload writes the file specified in src to into the memory.
func (b *Bucket) Upload(ctx context.Context, name string, r io.Reader, _ ...objstore.ObjectUploadOption) (err error) {
func (b *Bucket) Upload(ctx context.Context, name string, r io.Reader, opts ...objstore.ObjectUploadOption) (err error) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Same here.

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.

:thumbs-up:

@alsenz
Copy link
Copy Markdown
Author

alsenz commented May 4, 2026

Thanks for the second pass @GiedriusS - will apply changes tonight and push.

Signed-off-by: Tom Plowman <7210552+alsenz@users.noreply.github.com>
}

func (t TracingBucket) IsConditionNotMetErr(err error) bool {
return t.bkt.IsAccessDeniedErr(err)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Should this be IsConditionNotMetErr?

file := filepath.Join(b.rootDir, name)
bytes, err := xattr.Get(file, xAttrKey)
if err != nil {
return "", err // Legacy filesystem buckets would just return empty string for the version (until objects updated).
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

According to the comment, shouldn't nil error be returned?

// Upload writes the file specified in src to into the memory.
func (b *Bucket) Upload(ctx context.Context, name string, r io.Reader, _ ...objstore.ObjectUploadOption) (err error) {
func openSwap(name string) (swf *os.File, err error) {
for {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Why is this a loop? If creating this fails for some reason other than fs.ErrExist then this will loop indefinitely 🤔

if f == nil {
return
}
err = f.Close()
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This is immediately closed so maybe we can just call os.Stat instead?

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.

Feature request: conditions API

5 participants