2026-01-29 16:13:16 +00:00
|
|
|
package storage
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"crypto/sha256"
|
|
|
|
|
"encoding/hex"
|
|
|
|
|
"fmt"
|
|
|
|
|
"io"
|
2026-04-27 12:04:38 +01:00
|
|
|
"net/http"
|
2026-01-29 16:13:16 +00:00
|
|
|
"os"
|
|
|
|
|
"path/filepath"
|
2026-01-29 20:41:52 +00:00
|
|
|
"runtime"
|
2026-01-29 16:13:16 +00:00
|
|
|
"strings"
|
2026-04-27 12:04:38 +01:00
|
|
|
"time"
|
2026-01-29 16:13:16 +00:00
|
|
|
|
|
|
|
|
"gocloud.dev/blob"
|
2026-04-27 12:04:38 +01:00
|
|
|
_ "gocloud.dev/blob/azureblob"
|
2026-01-29 16:13:16 +00:00
|
|
|
_ "gocloud.dev/blob/fileblob"
|
|
|
|
|
_ "gocloud.dev/blob/s3blob"
|
|
|
|
|
"gocloud.dev/gcerrors"
|
|
|
|
|
)
|
|
|
|
|
|
2026-03-18 10:59:29 +00:00
|
|
|
const osWindows = "windows"
|
|
|
|
|
|
Stop writing fileblob's .attrs sidecar (#328)
* Stop writing fileblob's .attrs sidecar
fileblob stores blob metadata in an ".attrs" file per object and rewrites
it with os.Create, truncating in place outside the atomic rename that
protects the blob. A read overlapping a write decodes a partial file and
fails with "opening reader: EOF", served as a 502. One writer against
four readers on a single key failed 408 of 2000 reads. cacheMetadataBlob
is most exposed to it, rewriting a key on every refresh while readers
are served from it.
Nothing in the proxy reads what the sidecar holds. gocloud.dev/blob is
imported only by internal/storage, Store sets no ContentType, and
Attributes is used only for Size, which comes from os.Stat. A missing
sidecar already defaults cleanly, so "metadata=skip" removes the hazard
rather than locking around it, and saves a write per store.
* Clear .attrs sidecars left by earlier versions
metadata=skip stops fileblob rewriting sidecars but does not delete ones
already on disk, so a sidecar left partial by an interrupted write now
fails every read of its key for good. Before, a later store repaired it
by rewriting.
Store therefore removes the sidecar for the key it writes. Removal is
atomic where the rewrite was not, so a concurrent reader gets the whole
old file or nothing. Delete already removes sidecars, so the two paths
drain a cache between them.
Deriving that path is necessary because fileblob's key escaping is
unexported. It is the identity for a plain key and parts from one only
for keys that are not valid local paths, which is what filepath.Localize
rejects. That also keeps the removal inside the cache directory: without
it a key holding ".." resolves outside.
The clearing test runs one key per storage path the proxy builds, seeded
through a bucket that still writes sidecars so the path under test is
fileblob's own.
* Explain why failed sidecar cleanup does not fail the store
Move the removal into clearLegacySidecar and say why its error is
dropped rather than returned. A failed removal leaves exactly the state
this change inherited, while failing the write would turn a cleanup miss
into a failed request.
Windows makes that concrete: Go opens files with FILE_SHARE_READ and
FILE_SHARE_WRITE but not FILE_SHARE_DELETE, so a reader holding the
sidecar open blocks deletion, and that reader is the workload this
change exists to protect. Propagating would fail stores during exactly
the overlap being fixed. The next store of the key retries.
A test pins it, using a non-empty directory at the sidecar path to make
os.Remove fail with something other than not-exist on any platform.
* Fail the concurrency test if its writer stops
The writer returned silently when Store failed, so the test could pass
with no concurrent writes at all. Its error is now reported, and the
test also checks that at least one write completed.
Reporting it showed the writer had been dying on Windows at its first
collision: Go opens files without FILE_SHARE_DELETE, so a reader holding
the file open makes the writer's rename fail with access denied. The
test now skips there, since it cannot contend a writer with readers on
that platform.
* Clear legacy sidecars for keys fileblob escapes
legacySidecarPath declined any key filepath.Localize rejects, which on
Windows is every key with a colon: OCI digests and Debian epochs. Their
sidecars were never cleared there, and a truncated one kept failing
reads, since fileblob still reads a sidecar it finds under metadata=skip.
fileblob hex-escapes such characters on the way to disk. The path is now
derived the same way, so the sidecar is looked for where fileblob wrote
it. Localize still validates the escaped form, which keeps the removal
inside the cache directory.
* Drop stale comment about declining colon keys on Windows
623ff3e made legacySidecarPath escape keys the way fileblob does, so
colon-bearing keys are now cleared on Windows and the OCI and Debian
rows in TestStoreClearsLegacyAttrsSidecar prove it. The comment
described the behaviour before that commit.
---------
Co-authored-by: Andrew Nesbitt <andrewnez@gmail.com>
2026-09-15 12:07:18 -07:00
|
|
|
// attrsExt is fileblob's sidecar suffix, kept only to clear sidecars an
|
|
|
|
|
// earlier version wrote.
|
|
|
|
|
const attrsExt = ".attrs"
|
|
|
|
|
|
2026-01-29 16:13:16 +00:00
|
|
|
// Blob implements Storage using gocloud.dev/blob.
|
|
|
|
|
// Supports local filesystem (file://) and S3 (s3://) URLs.
|
|
|
|
|
type Blob struct {
|
|
|
|
|
bucket *blob.Bucket
|
|
|
|
|
url string
|
Stop writing fileblob's .attrs sidecar (#328)
* Stop writing fileblob's .attrs sidecar
fileblob stores blob metadata in an ".attrs" file per object and rewrites
it with os.Create, truncating in place outside the atomic rename that
protects the blob. A read overlapping a write decodes a partial file and
fails with "opening reader: EOF", served as a 502. One writer against
four readers on a single key failed 408 of 2000 reads. cacheMetadataBlob
is most exposed to it, rewriting a key on every refresh while readers
are served from it.
Nothing in the proxy reads what the sidecar holds. gocloud.dev/blob is
imported only by internal/storage, Store sets no ContentType, and
Attributes is used only for Size, which comes from os.Stat. A missing
sidecar already defaults cleanly, so "metadata=skip" removes the hazard
rather than locking around it, and saves a write per store.
* Clear .attrs sidecars left by earlier versions
metadata=skip stops fileblob rewriting sidecars but does not delete ones
already on disk, so a sidecar left partial by an interrupted write now
fails every read of its key for good. Before, a later store repaired it
by rewriting.
Store therefore removes the sidecar for the key it writes. Removal is
atomic where the rewrite was not, so a concurrent reader gets the whole
old file or nothing. Delete already removes sidecars, so the two paths
drain a cache between them.
Deriving that path is necessary because fileblob's key escaping is
unexported. It is the identity for a plain key and parts from one only
for keys that are not valid local paths, which is what filepath.Localize
rejects. That also keeps the removal inside the cache directory: without
it a key holding ".." resolves outside.
The clearing test runs one key per storage path the proxy builds, seeded
through a bucket that still writes sidecars so the path under test is
fileblob's own.
* Explain why failed sidecar cleanup does not fail the store
Move the removal into clearLegacySidecar and say why its error is
dropped rather than returned. A failed removal leaves exactly the state
this change inherited, while failing the write would turn a cleanup miss
into a failed request.
Windows makes that concrete: Go opens files with FILE_SHARE_READ and
FILE_SHARE_WRITE but not FILE_SHARE_DELETE, so a reader holding the
sidecar open blocks deletion, and that reader is the workload this
change exists to protect. Propagating would fail stores during exactly
the overlap being fixed. The next store of the key retries.
A test pins it, using a non-empty directory at the sidecar path to make
os.Remove fail with something other than not-exist on any platform.
* Fail the concurrency test if its writer stops
The writer returned silently when Store failed, so the test could pass
with no concurrent writes at all. Its error is now reported, and the
test also checks that at least one write completed.
Reporting it showed the writer had been dying on Windows at its first
collision: Go opens files without FILE_SHARE_DELETE, so a reader holding
the file open makes the writer's rename fail with access denied. The
test now skips there, since it cannot contend a writer with readers on
that platform.
* Clear legacy sidecars for keys fileblob escapes
legacySidecarPath declined any key filepath.Localize rejects, which on
Windows is every key with a colon: OCI digests and Debian epochs. Their
sidecars were never cleared there, and a truncated one kept failing
reads, since fileblob still reads a sidecar it finds under metadata=skip.
fileblob hex-escapes such characters on the way to disk. The path is now
derived the same way, so the sidecar is looked for where fileblob wrote
it. Localize still validates the escaped form, which keeps the removal
inside the cache directory.
* Drop stale comment about declining colon keys on Windows
623ff3e made legacySidecarPath escape keys the way fileblob does, so
colon-bearing keys are now cleared on Windows and the OCI and Debian
rows in TestStoreClearsLegacyAttrsSidecar prove it. The comment
described the behaviour before that commit.
---------
Co-authored-by: Andrew Nesbitt <andrewnez@gmail.com>
2026-09-15 12:07:18 -07:00
|
|
|
|
|
|
|
|
// fileRoot is the directory backing a file:// bucket, empty for cloud
|
|
|
|
|
// backends. Used only to clear sidecars an earlier version wrote.
|
|
|
|
|
fileRoot string
|
2026-01-29 16:13:16 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// OpenBucket opens a blob bucket from a URL.
|
|
|
|
|
//
|
|
|
|
|
// Supported URL schemes:
|
|
|
|
|
// - file:///path/to/dir - Local filesystem storage
|
|
|
|
|
// - s3://bucket-name - Amazon S3 (uses AWS_* environment variables)
|
|
|
|
|
// - s3://bucket-name?region=us-east-1&endpoint=http://localhost:9000 - S3-compatible (MinIO, etc.)
|
2026-08-29 11:23:07 +01:00
|
|
|
// - gs://bucket-name - Google Cloud Storage (uses Application Default Credentials;
|
|
|
|
|
// supports Workload Identity on GKE/GCE without any extra configuration)
|
|
|
|
|
// - azblob://container-name - Azure Blob Storage
|
2026-01-29 16:13:16 +00:00
|
|
|
//
|
|
|
|
|
// For local filesystem, the directory is created if it doesn't exist.
|
2026-08-29 11:23:07 +01:00
|
|
|
//
|
|
|
|
|
//nolint:ireturn // The URL scheme selects the storage implementation.
|
|
|
|
|
func OpenBucket(ctx context.Context, urlStr string) (Storage, error) {
|
|
|
|
|
if strings.HasPrefix(urlStr, "gs://") {
|
|
|
|
|
return OpenGCS(ctx, urlStr)
|
|
|
|
|
}
|
|
|
|
|
|
Stop writing fileblob's .attrs sidecar (#328)
* Stop writing fileblob's .attrs sidecar
fileblob stores blob metadata in an ".attrs" file per object and rewrites
it with os.Create, truncating in place outside the atomic rename that
protects the blob. A read overlapping a write decodes a partial file and
fails with "opening reader: EOF", served as a 502. One writer against
four readers on a single key failed 408 of 2000 reads. cacheMetadataBlob
is most exposed to it, rewriting a key on every refresh while readers
are served from it.
Nothing in the proxy reads what the sidecar holds. gocloud.dev/blob is
imported only by internal/storage, Store sets no ContentType, and
Attributes is used only for Size, which comes from os.Stat. A missing
sidecar already defaults cleanly, so "metadata=skip" removes the hazard
rather than locking around it, and saves a write per store.
* Clear .attrs sidecars left by earlier versions
metadata=skip stops fileblob rewriting sidecars but does not delete ones
already on disk, so a sidecar left partial by an interrupted write now
fails every read of its key for good. Before, a later store repaired it
by rewriting.
Store therefore removes the sidecar for the key it writes. Removal is
atomic where the rewrite was not, so a concurrent reader gets the whole
old file or nothing. Delete already removes sidecars, so the two paths
drain a cache between them.
Deriving that path is necessary because fileblob's key escaping is
unexported. It is the identity for a plain key and parts from one only
for keys that are not valid local paths, which is what filepath.Localize
rejects. That also keeps the removal inside the cache directory: without
it a key holding ".." resolves outside.
The clearing test runs one key per storage path the proxy builds, seeded
through a bucket that still writes sidecars so the path under test is
fileblob's own.
* Explain why failed sidecar cleanup does not fail the store
Move the removal into clearLegacySidecar and say why its error is
dropped rather than returned. A failed removal leaves exactly the state
this change inherited, while failing the write would turn a cleanup miss
into a failed request.
Windows makes that concrete: Go opens files with FILE_SHARE_READ and
FILE_SHARE_WRITE but not FILE_SHARE_DELETE, so a reader holding the
sidecar open blocks deletion, and that reader is the workload this
change exists to protect. Propagating would fail stores during exactly
the overlap being fixed. The next store of the key retries.
A test pins it, using a non-empty directory at the sidecar path to make
os.Remove fail with something other than not-exist on any platform.
* Fail the concurrency test if its writer stops
The writer returned silently when Store failed, so the test could pass
with no concurrent writes at all. Its error is now reported, and the
test also checks that at least one write completed.
Reporting it showed the writer had been dying on Windows at its first
collision: Go opens files without FILE_SHARE_DELETE, so a reader holding
the file open makes the writer's rename fail with access denied. The
test now skips there, since it cannot contend a writer with readers on
that platform.
* Clear legacy sidecars for keys fileblob escapes
legacySidecarPath declined any key filepath.Localize rejects, which on
Windows is every key with a colon: OCI digests and Debian epochs. Their
sidecars were never cleared there, and a truncated one kept failing
reads, since fileblob still reads a sidecar it finds under metadata=skip.
fileblob hex-escapes such characters on the way to disk. The path is now
derived the same way, so the sidecar is looked for where fileblob wrote
it. Localize still validates the escaped form, which keeps the removal
inside the cache directory.
* Drop stale comment about declining colon keys on Windows
623ff3e made legacySidecarPath escape keys the way fileblob does, so
colon-bearing keys are now cleared on Windows and the OCI and Debian
rows in TestStoreClearsLegacyAttrsSidecar prove it. The comment
described the behaviour before that commit.
---------
Co-authored-by: Andrew Nesbitt <andrewnez@gmail.com>
2026-09-15 12:07:18 -07:00
|
|
|
var fileRoot string
|
|
|
|
|
|
2026-01-29 16:13:16 +00:00
|
|
|
// Handle file:// URLs specially to create the directory
|
|
|
|
|
if strings.HasPrefix(urlStr, "file://") {
|
2026-01-29 20:41:52 +00:00
|
|
|
path := strings.TrimPrefix(urlStr, "file://")
|
2026-01-29 16:13:16 +00:00
|
|
|
|
2026-01-29 20:41:52 +00:00
|
|
|
// Handle file:/// (three slashes) for absolute paths
|
2026-03-18 10:59:29 +00:00
|
|
|
if strings.HasPrefix(path, "/") && runtime.GOOS != osWindows {
|
2026-01-29 20:41:52 +00:00
|
|
|
// Unix: file:///path -> /path
|
|
|
|
|
// path is already correct
|
2026-03-18 10:59:29 +00:00
|
|
|
} else if strings.HasPrefix(path, "/") && runtime.GOOS == osWindows {
|
2026-01-29 20:41:52 +00:00
|
|
|
// Windows: file:///C:/path -> C:/path
|
|
|
|
|
path = strings.TrimPrefix(path, "/")
|
2026-01-29 16:13:16 +00:00
|
|
|
}
|
|
|
|
|
|
2026-01-29 20:41:52 +00:00
|
|
|
// Convert forward slashes to native path separators for filesystem operations
|
|
|
|
|
nativePath := filepath.FromSlash(path)
|
|
|
|
|
|
2026-01-29 16:13:16 +00:00
|
|
|
// Ensure directory exists
|
2026-03-18 10:59:29 +00:00
|
|
|
if err := os.MkdirAll(nativePath, dirPermissions); err != nil {
|
2026-01-29 16:13:16 +00:00
|
|
|
return nil, fmt.Errorf("creating directory: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-29 20:41:52 +00:00
|
|
|
// fileblob requires an absolute path with forward slashes
|
|
|
|
|
absPath, err := filepath.Abs(nativePath)
|
2026-01-29 16:13:16 +00:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("resolving path: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
Stop writing fileblob's .attrs sidecar (#328)
* Stop writing fileblob's .attrs sidecar
fileblob stores blob metadata in an ".attrs" file per object and rewrites
it with os.Create, truncating in place outside the atomic rename that
protects the blob. A read overlapping a write decodes a partial file and
fails with "opening reader: EOF", served as a 502. One writer against
four readers on a single key failed 408 of 2000 reads. cacheMetadataBlob
is most exposed to it, rewriting a key on every refresh while readers
are served from it.
Nothing in the proxy reads what the sidecar holds. gocloud.dev/blob is
imported only by internal/storage, Store sets no ContentType, and
Attributes is used only for Size, which comes from os.Stat. A missing
sidecar already defaults cleanly, so "metadata=skip" removes the hazard
rather than locking around it, and saves a write per store.
* Clear .attrs sidecars left by earlier versions
metadata=skip stops fileblob rewriting sidecars but does not delete ones
already on disk, so a sidecar left partial by an interrupted write now
fails every read of its key for good. Before, a later store repaired it
by rewriting.
Store therefore removes the sidecar for the key it writes. Removal is
atomic where the rewrite was not, so a concurrent reader gets the whole
old file or nothing. Delete already removes sidecars, so the two paths
drain a cache between them.
Deriving that path is necessary because fileblob's key escaping is
unexported. It is the identity for a plain key and parts from one only
for keys that are not valid local paths, which is what filepath.Localize
rejects. That also keeps the removal inside the cache directory: without
it a key holding ".." resolves outside.
The clearing test runs one key per storage path the proxy builds, seeded
through a bucket that still writes sidecars so the path under test is
fileblob's own.
* Explain why failed sidecar cleanup does not fail the store
Move the removal into clearLegacySidecar and say why its error is
dropped rather than returned. A failed removal leaves exactly the state
this change inherited, while failing the write would turn a cleanup miss
into a failed request.
Windows makes that concrete: Go opens files with FILE_SHARE_READ and
FILE_SHARE_WRITE but not FILE_SHARE_DELETE, so a reader holding the
sidecar open blocks deletion, and that reader is the workload this
change exists to protect. Propagating would fail stores during exactly
the overlap being fixed. The next store of the key retries.
A test pins it, using a non-empty directory at the sidecar path to make
os.Remove fail with something other than not-exist on any platform.
* Fail the concurrency test if its writer stops
The writer returned silently when Store failed, so the test could pass
with no concurrent writes at all. Its error is now reported, and the
test also checks that at least one write completed.
Reporting it showed the writer had been dying on Windows at its first
collision: Go opens files without FILE_SHARE_DELETE, so a reader holding
the file open makes the writer's rename fail with access denied. The
test now skips there, since it cannot contend a writer with readers on
that platform.
* Clear legacy sidecars for keys fileblob escapes
legacySidecarPath declined any key filepath.Localize rejects, which on
Windows is every key with a colon: OCI digests and Debian epochs. Their
sidecars were never cleared there, and a truncated one kept failing
reads, since fileblob still reads a sidecar it finds under metadata=skip.
fileblob hex-escapes such characters on the way to disk. The path is now
derived the same way, so the sidecar is looked for where fileblob wrote
it. Localize still validates the escaped form, which keeps the removal
inside the cache directory.
* Drop stale comment about declining colon keys on Windows
623ff3e made legacySidecarPath escape keys the way fileblob does, so
colon-bearing keys are now cleared on Windows and the OCI and Debian
rows in TestStoreClearsLegacyAttrsSidecar prove it. The comment
described the behaviour before that commit.
---------
Co-authored-by: Andrew Nesbitt <andrewnez@gmail.com>
2026-09-15 12:07:18 -07:00
|
|
|
fileRoot = absPath
|
|
|
|
|
|
2026-01-29 20:41:52 +00:00
|
|
|
// Convert back to URL format with forward slashes
|
|
|
|
|
urlPath := filepath.ToSlash(absPath)
|
2026-03-18 10:59:29 +00:00
|
|
|
if runtime.GOOS == osWindows {
|
2026-01-29 20:41:52 +00:00
|
|
|
// Windows needs file:///C:/path format
|
|
|
|
|
urlStr = "file:///" + urlPath
|
|
|
|
|
} else {
|
|
|
|
|
urlStr = "file://" + urlPath
|
|
|
|
|
}
|
2026-04-06 13:07:31 +01:00
|
|
|
|
|
|
|
|
// Create temp files next to the final path instead of in os.TempDir.
|
|
|
|
|
// This avoids "invalid cross-device link" errors from os.Rename when
|
|
|
|
|
// the bucket directory and os.TempDir are on different filesystems
|
|
|
|
|
// (e.g. Docker volume mounts).
|
Stop writing fileblob's .attrs sidecar (#328)
* Stop writing fileblob's .attrs sidecar
fileblob stores blob metadata in an ".attrs" file per object and rewrites
it with os.Create, truncating in place outside the atomic rename that
protects the blob. A read overlapping a write decodes a partial file and
fails with "opening reader: EOF", served as a 502. One writer against
four readers on a single key failed 408 of 2000 reads. cacheMetadataBlob
is most exposed to it, rewriting a key on every refresh while readers
are served from it.
Nothing in the proxy reads what the sidecar holds. gocloud.dev/blob is
imported only by internal/storage, Store sets no ContentType, and
Attributes is used only for Size, which comes from os.Stat. A missing
sidecar already defaults cleanly, so "metadata=skip" removes the hazard
rather than locking around it, and saves a write per store.
* Clear .attrs sidecars left by earlier versions
metadata=skip stops fileblob rewriting sidecars but does not delete ones
already on disk, so a sidecar left partial by an interrupted write now
fails every read of its key for good. Before, a later store repaired it
by rewriting.
Store therefore removes the sidecar for the key it writes. Removal is
atomic where the rewrite was not, so a concurrent reader gets the whole
old file or nothing. Delete already removes sidecars, so the two paths
drain a cache between them.
Deriving that path is necessary because fileblob's key escaping is
unexported. It is the identity for a plain key and parts from one only
for keys that are not valid local paths, which is what filepath.Localize
rejects. That also keeps the removal inside the cache directory: without
it a key holding ".." resolves outside.
The clearing test runs one key per storage path the proxy builds, seeded
through a bucket that still writes sidecars so the path under test is
fileblob's own.
* Explain why failed sidecar cleanup does not fail the store
Move the removal into clearLegacySidecar and say why its error is
dropped rather than returned. A failed removal leaves exactly the state
this change inherited, while failing the write would turn a cleanup miss
into a failed request.
Windows makes that concrete: Go opens files with FILE_SHARE_READ and
FILE_SHARE_WRITE but not FILE_SHARE_DELETE, so a reader holding the
sidecar open blocks deletion, and that reader is the workload this
change exists to protect. Propagating would fail stores during exactly
the overlap being fixed. The next store of the key retries.
A test pins it, using a non-empty directory at the sidecar path to make
os.Remove fail with something other than not-exist on any platform.
* Fail the concurrency test if its writer stops
The writer returned silently when Store failed, so the test could pass
with no concurrent writes at all. Its error is now reported, and the
test also checks that at least one write completed.
Reporting it showed the writer had been dying on Windows at its first
collision: Go opens files without FILE_SHARE_DELETE, so a reader holding
the file open makes the writer's rename fail with access denied. The
test now skips there, since it cannot contend a writer with readers on
that platform.
* Clear legacy sidecars for keys fileblob escapes
legacySidecarPath declined any key filepath.Localize rejects, which on
Windows is every key with a colon: OCI digests and Debian epochs. Their
sidecars were never cleared there, and a truncated one kept failing
reads, since fileblob still reads a sidecar it finds under metadata=skip.
fileblob hex-escapes such characters on the way to disk. The path is now
derived the same way, so the sidecar is looked for where fileblob wrote
it. Localize still validates the escaped form, which keeps the removal
inside the cache directory.
* Drop stale comment about declining colon keys on Windows
623ff3e made legacySidecarPath escape keys the way fileblob does, so
colon-bearing keys are now cleared on Windows and the OCI and Debian
rows in TestStoreClearsLegacyAttrsSidecar prove it. The comment
described the behaviour before that commit.
---------
Co-authored-by: Andrew Nesbitt <andrewnez@gmail.com>
2026-09-15 12:07:18 -07:00
|
|
|
//
|
|
|
|
|
// Do not write fileblob's ".attrs" sidecar. It is rewritten with
|
|
|
|
|
// os.Create, truncating in place outside the atomic rename that
|
|
|
|
|
// protects the blob, so a read overlapping a write can decode a
|
|
|
|
|
// partial file; a missing one defaults cleanly, a truncated one does
|
|
|
|
|
// not. Nothing in the proxy needs it: Store sets no ContentType, and
|
|
|
|
|
// Size reads os.Stat via Attributes.
|
|
|
|
|
urlStr += "?no_tmp_dir=true&metadata=skip"
|
2026-01-29 16:13:16 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bucket, err := blob.OpenBucket(ctx, urlStr)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("opening bucket: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
Stop writing fileblob's .attrs sidecar (#328)
* Stop writing fileblob's .attrs sidecar
fileblob stores blob metadata in an ".attrs" file per object and rewrites
it with os.Create, truncating in place outside the atomic rename that
protects the blob. A read overlapping a write decodes a partial file and
fails with "opening reader: EOF", served as a 502. One writer against
four readers on a single key failed 408 of 2000 reads. cacheMetadataBlob
is most exposed to it, rewriting a key on every refresh while readers
are served from it.
Nothing in the proxy reads what the sidecar holds. gocloud.dev/blob is
imported only by internal/storage, Store sets no ContentType, and
Attributes is used only for Size, which comes from os.Stat. A missing
sidecar already defaults cleanly, so "metadata=skip" removes the hazard
rather than locking around it, and saves a write per store.
* Clear .attrs sidecars left by earlier versions
metadata=skip stops fileblob rewriting sidecars but does not delete ones
already on disk, so a sidecar left partial by an interrupted write now
fails every read of its key for good. Before, a later store repaired it
by rewriting.
Store therefore removes the sidecar for the key it writes. Removal is
atomic where the rewrite was not, so a concurrent reader gets the whole
old file or nothing. Delete already removes sidecars, so the two paths
drain a cache between them.
Deriving that path is necessary because fileblob's key escaping is
unexported. It is the identity for a plain key and parts from one only
for keys that are not valid local paths, which is what filepath.Localize
rejects. That also keeps the removal inside the cache directory: without
it a key holding ".." resolves outside.
The clearing test runs one key per storage path the proxy builds, seeded
through a bucket that still writes sidecars so the path under test is
fileblob's own.
* Explain why failed sidecar cleanup does not fail the store
Move the removal into clearLegacySidecar and say why its error is
dropped rather than returned. A failed removal leaves exactly the state
this change inherited, while failing the write would turn a cleanup miss
into a failed request.
Windows makes that concrete: Go opens files with FILE_SHARE_READ and
FILE_SHARE_WRITE but not FILE_SHARE_DELETE, so a reader holding the
sidecar open blocks deletion, and that reader is the workload this
change exists to protect. Propagating would fail stores during exactly
the overlap being fixed. The next store of the key retries.
A test pins it, using a non-empty directory at the sidecar path to make
os.Remove fail with something other than not-exist on any platform.
* Fail the concurrency test if its writer stops
The writer returned silently when Store failed, so the test could pass
with no concurrent writes at all. Its error is now reported, and the
test also checks that at least one write completed.
Reporting it showed the writer had been dying on Windows at its first
collision: Go opens files without FILE_SHARE_DELETE, so a reader holding
the file open makes the writer's rename fail with access denied. The
test now skips there, since it cannot contend a writer with readers on
that platform.
* Clear legacy sidecars for keys fileblob escapes
legacySidecarPath declined any key filepath.Localize rejects, which on
Windows is every key with a colon: OCI digests and Debian epochs. Their
sidecars were never cleared there, and a truncated one kept failing
reads, since fileblob still reads a sidecar it finds under metadata=skip.
fileblob hex-escapes such characters on the way to disk. The path is now
derived the same way, so the sidecar is looked for where fileblob wrote
it. Localize still validates the escaped form, which keeps the removal
inside the cache directory.
* Drop stale comment about declining colon keys on Windows
623ff3e made legacySidecarPath escape keys the way fileblob does, so
colon-bearing keys are now cleared on Windows and the OCI and Debian
rows in TestStoreClearsLegacyAttrsSidecar prove it. The comment
described the behaviour before that commit.
---------
Co-authored-by: Andrew Nesbitt <andrewnez@gmail.com>
2026-09-15 12:07:18 -07:00
|
|
|
return &Blob{bucket: bucket, url: urlStr, fileRoot: fileRoot}, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// legacySidecarPath gives the ".attrs" path an earlier version wrote for key,
|
|
|
|
|
// or "" when that path would not be a file inside fileRoot.
|
|
|
|
|
func (b *Blob) legacySidecarPath(key string) string {
|
Give each fetch its own storage path
Every fetch of an artifact wrote one path, so fetches with different URLs
or digests, which do not share a coalescing key, could overwrite each
other's bytes, serve the wrong ones, or delete them on a digest mismatch
while the other was about to open them.
storeArtifact now writes {ecosystem}/{name}/{version}/{fetch id}/{filename},
with a random id per fetch. Most ecosystems learn the digest only after
the fetch, and storage has no rename, so the id is the one rule that fits
all of them. Existing records keep their paths and stay readable. On a
file:// bucket, Delete now removes an emptied fetch directory, which
fileblob leaves behind, and no other directory, since another fetch may
be creating its own inside it.
A path a record stops pointing at is queued in pending_deletes rather
than deleted, since a request that read the record may still open it. A
loop deletes queued paths after max(1h, direct_serve_ttl), whether or not
max_size is set. UpsertArtifact and clears apply only if the record still
holds the path the caller read, so racing commits each queue the path
they replaced and a clear never orphans a newer commit. Eviction still
deletes inline, and counts space as freed only when the record still
pointed at what it deleted.
2026-09-23 14:40:13 -07:00
|
|
|
if p := b.localPath(key); p != "" {
|
|
|
|
|
return p + attrsExt
|
|
|
|
|
}
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// localPath gives the file fileblob keeps key in, or "" when that would not
|
|
|
|
|
// be a file inside fileRoot.
|
|
|
|
|
//
|
|
|
|
|
// The key is escaped the way fileblob escapes it on the way to disk.
|
|
|
|
|
// filepath.Localize then validates the escaped form: it rejects an empty,
|
|
|
|
|
// absolute or ".." path, and "." would name fileRoot itself. What it declines
|
|
|
|
|
// are keys the proxy never produces.
|
|
|
|
|
func (b *Blob) localPath(key string) string {
|
Stop writing fileblob's .attrs sidecar (#328)
* Stop writing fileblob's .attrs sidecar
fileblob stores blob metadata in an ".attrs" file per object and rewrites
it with os.Create, truncating in place outside the atomic rename that
protects the blob. A read overlapping a write decodes a partial file and
fails with "opening reader: EOF", served as a 502. One writer against
four readers on a single key failed 408 of 2000 reads. cacheMetadataBlob
is most exposed to it, rewriting a key on every refresh while readers
are served from it.
Nothing in the proxy reads what the sidecar holds. gocloud.dev/blob is
imported only by internal/storage, Store sets no ContentType, and
Attributes is used only for Size, which comes from os.Stat. A missing
sidecar already defaults cleanly, so "metadata=skip" removes the hazard
rather than locking around it, and saves a write per store.
* Clear .attrs sidecars left by earlier versions
metadata=skip stops fileblob rewriting sidecars but does not delete ones
already on disk, so a sidecar left partial by an interrupted write now
fails every read of its key for good. Before, a later store repaired it
by rewriting.
Store therefore removes the sidecar for the key it writes. Removal is
atomic where the rewrite was not, so a concurrent reader gets the whole
old file or nothing. Delete already removes sidecars, so the two paths
drain a cache between them.
Deriving that path is necessary because fileblob's key escaping is
unexported. It is the identity for a plain key and parts from one only
for keys that are not valid local paths, which is what filepath.Localize
rejects. That also keeps the removal inside the cache directory: without
it a key holding ".." resolves outside.
The clearing test runs one key per storage path the proxy builds, seeded
through a bucket that still writes sidecars so the path under test is
fileblob's own.
* Explain why failed sidecar cleanup does not fail the store
Move the removal into clearLegacySidecar and say why its error is
dropped rather than returned. A failed removal leaves exactly the state
this change inherited, while failing the write would turn a cleanup miss
into a failed request.
Windows makes that concrete: Go opens files with FILE_SHARE_READ and
FILE_SHARE_WRITE but not FILE_SHARE_DELETE, so a reader holding the
sidecar open blocks deletion, and that reader is the workload this
change exists to protect. Propagating would fail stores during exactly
the overlap being fixed. The next store of the key retries.
A test pins it, using a non-empty directory at the sidecar path to make
os.Remove fail with something other than not-exist on any platform.
* Fail the concurrency test if its writer stops
The writer returned silently when Store failed, so the test could pass
with no concurrent writes at all. Its error is now reported, and the
test also checks that at least one write completed.
Reporting it showed the writer had been dying on Windows at its first
collision: Go opens files without FILE_SHARE_DELETE, so a reader holding
the file open makes the writer's rename fail with access denied. The
test now skips there, since it cannot contend a writer with readers on
that platform.
* Clear legacy sidecars for keys fileblob escapes
legacySidecarPath declined any key filepath.Localize rejects, which on
Windows is every key with a colon: OCI digests and Debian epochs. Their
sidecars were never cleared there, and a truncated one kept failing
reads, since fileblob still reads a sidecar it finds under metadata=skip.
fileblob hex-escapes such characters on the way to disk. The path is now
derived the same way, so the sidecar is looked for where fileblob wrote
it. Localize still validates the escaped form, which keeps the removal
inside the cache directory.
* Drop stale comment about declining colon keys on Windows
623ff3e made legacySidecarPath escape keys the way fileblob does, so
colon-bearing keys are now cleared on Windows and the OCI and Debian
rows in TestStoreClearsLegacyAttrsSidecar prove it. The comment
described the behaviour before that commit.
---------
Co-authored-by: Andrew Nesbitt <andrewnez@gmail.com>
2026-09-15 12:07:18 -07:00
|
|
|
if b.fileRoot == "" {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
rel, err := filepath.Localize(escapeKey(key))
|
|
|
|
|
if err != nil || rel == "." {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
Give each fetch its own storage path
Every fetch of an artifact wrote one path, so fetches with different URLs
or digests, which do not share a coalescing key, could overwrite each
other's bytes, serve the wrong ones, or delete them on a digest mismatch
while the other was about to open them.
storeArtifact now writes {ecosystem}/{name}/{version}/{fetch id}/{filename},
with a random id per fetch. Most ecosystems learn the digest only after
the fetch, and storage has no rename, so the id is the one rule that fits
all of them. Existing records keep their paths and stay readable. On a
file:// bucket, Delete now removes an emptied fetch directory, which
fileblob leaves behind, and no other directory, since another fetch may
be creating its own inside it.
A path a record stops pointing at is queued in pending_deletes rather
than deleted, since a request that read the record may still open it. A
loop deletes queued paths after max(1h, direct_serve_ttl), whether or not
max_size is set. UpsertArtifact and clears apply only if the record still
holds the path the caller read, so racing commits each queue the path
they replaced and a clear never orphans a newer commit. Eviction still
deletes inline, and counts space as freed only when the record still
pointed at what it deleted.
2026-09-23 14:40:13 -07:00
|
|
|
return filepath.Join(b.fileRoot, rel)
|
Stop writing fileblob's .attrs sidecar (#328)
* Stop writing fileblob's .attrs sidecar
fileblob stores blob metadata in an ".attrs" file per object and rewrites
it with os.Create, truncating in place outside the atomic rename that
protects the blob. A read overlapping a write decodes a partial file and
fails with "opening reader: EOF", served as a 502. One writer against
four readers on a single key failed 408 of 2000 reads. cacheMetadataBlob
is most exposed to it, rewriting a key on every refresh while readers
are served from it.
Nothing in the proxy reads what the sidecar holds. gocloud.dev/blob is
imported only by internal/storage, Store sets no ContentType, and
Attributes is used only for Size, which comes from os.Stat. A missing
sidecar already defaults cleanly, so "metadata=skip" removes the hazard
rather than locking around it, and saves a write per store.
* Clear .attrs sidecars left by earlier versions
metadata=skip stops fileblob rewriting sidecars but does not delete ones
already on disk, so a sidecar left partial by an interrupted write now
fails every read of its key for good. Before, a later store repaired it
by rewriting.
Store therefore removes the sidecar for the key it writes. Removal is
atomic where the rewrite was not, so a concurrent reader gets the whole
old file or nothing. Delete already removes sidecars, so the two paths
drain a cache between them.
Deriving that path is necessary because fileblob's key escaping is
unexported. It is the identity for a plain key and parts from one only
for keys that are not valid local paths, which is what filepath.Localize
rejects. That also keeps the removal inside the cache directory: without
it a key holding ".." resolves outside.
The clearing test runs one key per storage path the proxy builds, seeded
through a bucket that still writes sidecars so the path under test is
fileblob's own.
* Explain why failed sidecar cleanup does not fail the store
Move the removal into clearLegacySidecar and say why its error is
dropped rather than returned. A failed removal leaves exactly the state
this change inherited, while failing the write would turn a cleanup miss
into a failed request.
Windows makes that concrete: Go opens files with FILE_SHARE_READ and
FILE_SHARE_WRITE but not FILE_SHARE_DELETE, so a reader holding the
sidecar open blocks deletion, and that reader is the workload this
change exists to protect. Propagating would fail stores during exactly
the overlap being fixed. The next store of the key retries.
A test pins it, using a non-empty directory at the sidecar path to make
os.Remove fail with something other than not-exist on any platform.
* Fail the concurrency test if its writer stops
The writer returned silently when Store failed, so the test could pass
with no concurrent writes at all. Its error is now reported, and the
test also checks that at least one write completed.
Reporting it showed the writer had been dying on Windows at its first
collision: Go opens files without FILE_SHARE_DELETE, so a reader holding
the file open makes the writer's rename fail with access denied. The
test now skips there, since it cannot contend a writer with readers on
that platform.
* Clear legacy sidecars for keys fileblob escapes
legacySidecarPath declined any key filepath.Localize rejects, which on
Windows is every key with a colon: OCI digests and Debian epochs. Their
sidecars were never cleared there, and a truncated one kept failing
reads, since fileblob still reads a sidecar it finds under metadata=skip.
fileblob hex-escapes such characters on the way to disk. The path is now
derived the same way, so the sidecar is looked for where fileblob wrote
it. Localize still validates the escaped form, which keeps the removal
inside the cache directory.
* Drop stale comment about declining colon keys on Windows
623ff3e made legacySidecarPath escape keys the way fileblob does, so
colon-bearing keys are now cleared on Windows and the OCI and Debian
rows in TestStoreClearsLegacyAttrsSidecar prove it. The comment
described the behaviour before that commit.
---------
Co-authored-by: Andrew Nesbitt <andrewnez@gmail.com>
2026-09-15 12:07:18 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// escapeKey mirrors fileblob's unexported escapeKey, which hex-escapes a rune
|
|
|
|
|
// as "__0x<hex>__". Slashes stay as "/" for filepath.Localize to convert.
|
|
|
|
|
func escapeKey(key string) string {
|
|
|
|
|
runes := []rune(key)
|
|
|
|
|
var out strings.Builder
|
|
|
|
|
for i, r := range runes {
|
|
|
|
|
if escapeRune(runes, i) {
|
|
|
|
|
fmt.Fprintf(&out, "__%#x__", r)
|
|
|
|
|
} else {
|
|
|
|
|
out.WriteRune(r)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return out.String()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// escapeRune is fileblob's rule for which runes of a key to escape: control
|
|
|
|
|
// characters, a raw path separator, a slash that would form "../", "//" or
|
|
|
|
|
// end the key, and on Windows the characters its filesystem reserves.
|
|
|
|
|
func escapeRune(r []rune, i int) bool {
|
|
|
|
|
c := r[i]
|
|
|
|
|
switch {
|
|
|
|
|
case c < ' ':
|
|
|
|
|
return true
|
|
|
|
|
case os.PathSeparator != '/' && c == os.PathSeparator:
|
|
|
|
|
return true
|
|
|
|
|
case i > 1 && c == '/' && r[i-1] == '.' && r[i-2] == '.':
|
|
|
|
|
return true
|
|
|
|
|
case i > 0 && c == '/' && r[i-1] == '/':
|
|
|
|
|
return true
|
|
|
|
|
case c == '/' && i == len(r)-1:
|
|
|
|
|
return true
|
|
|
|
|
case os.PathSeparator == '\\' && strings.ContainsRune(`<>:"|?*`, c):
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// clearLegacySidecar removes the ".attrs" file an earlier version wrote for
|
|
|
|
|
// key. Nothing rewrites one now, so a sidecar left partial by an interrupted
|
|
|
|
|
// write would fail every read of that key for good. Removing is atomic where
|
|
|
|
|
// the rewrite was not, so a concurrent reader gets the whole old file or
|
|
|
|
|
// nothing.
|
|
|
|
|
//
|
|
|
|
|
// Failure is deliberately not fatal. Usually the key never had a sidecar and
|
|
|
|
|
// os.Remove reports not-exist. A real failure leaves exactly the state this
|
|
|
|
|
// change inherited, while failing the write would turn a cleanup miss into a
|
|
|
|
|
// failed request. Windows makes that concrete: Go opens files without
|
|
|
|
|
// FILE_SHARE_DELETE, so a reader holding the sidecar open blocks deletion, and
|
|
|
|
|
// that reader is the very workload this change protects. The next store of the
|
|
|
|
|
// key retries.
|
|
|
|
|
func (b *Blob) clearLegacySidecar(key string) {
|
|
|
|
|
if sidecar := b.legacySidecarPath(key); sidecar != "" {
|
|
|
|
|
_ = os.Remove(sidecar)
|
|
|
|
|
}
|
2026-01-29 16:13:16 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (b *Blob) Store(ctx context.Context, path string, r io.Reader) (int64, string, error) {
|
Stop writing fileblob's .attrs sidecar (#328)
* Stop writing fileblob's .attrs sidecar
fileblob stores blob metadata in an ".attrs" file per object and rewrites
it with os.Create, truncating in place outside the atomic rename that
protects the blob. A read overlapping a write decodes a partial file and
fails with "opening reader: EOF", served as a 502. One writer against
four readers on a single key failed 408 of 2000 reads. cacheMetadataBlob
is most exposed to it, rewriting a key on every refresh while readers
are served from it.
Nothing in the proxy reads what the sidecar holds. gocloud.dev/blob is
imported only by internal/storage, Store sets no ContentType, and
Attributes is used only for Size, which comes from os.Stat. A missing
sidecar already defaults cleanly, so "metadata=skip" removes the hazard
rather than locking around it, and saves a write per store.
* Clear .attrs sidecars left by earlier versions
metadata=skip stops fileblob rewriting sidecars but does not delete ones
already on disk, so a sidecar left partial by an interrupted write now
fails every read of its key for good. Before, a later store repaired it
by rewriting.
Store therefore removes the sidecar for the key it writes. Removal is
atomic where the rewrite was not, so a concurrent reader gets the whole
old file or nothing. Delete already removes sidecars, so the two paths
drain a cache between them.
Deriving that path is necessary because fileblob's key escaping is
unexported. It is the identity for a plain key and parts from one only
for keys that are not valid local paths, which is what filepath.Localize
rejects. That also keeps the removal inside the cache directory: without
it a key holding ".." resolves outside.
The clearing test runs one key per storage path the proxy builds, seeded
through a bucket that still writes sidecars so the path under test is
fileblob's own.
* Explain why failed sidecar cleanup does not fail the store
Move the removal into clearLegacySidecar and say why its error is
dropped rather than returned. A failed removal leaves exactly the state
this change inherited, while failing the write would turn a cleanup miss
into a failed request.
Windows makes that concrete: Go opens files with FILE_SHARE_READ and
FILE_SHARE_WRITE but not FILE_SHARE_DELETE, so a reader holding the
sidecar open blocks deletion, and that reader is the workload this
change exists to protect. Propagating would fail stores during exactly
the overlap being fixed. The next store of the key retries.
A test pins it, using a non-empty directory at the sidecar path to make
os.Remove fail with something other than not-exist on any platform.
* Fail the concurrency test if its writer stops
The writer returned silently when Store failed, so the test could pass
with no concurrent writes at all. Its error is now reported, and the
test also checks that at least one write completed.
Reporting it showed the writer had been dying on Windows at its first
collision: Go opens files without FILE_SHARE_DELETE, so a reader holding
the file open makes the writer's rename fail with access denied. The
test now skips there, since it cannot contend a writer with readers on
that platform.
* Clear legacy sidecars for keys fileblob escapes
legacySidecarPath declined any key filepath.Localize rejects, which on
Windows is every key with a colon: OCI digests and Debian epochs. Their
sidecars were never cleared there, and a truncated one kept failing
reads, since fileblob still reads a sidecar it finds under metadata=skip.
fileblob hex-escapes such characters on the way to disk. The path is now
derived the same way, so the sidecar is looked for where fileblob wrote
it. Localize still validates the escaped form, which keeps the removal
inside the cache directory.
* Drop stale comment about declining colon keys on Windows
623ff3e made legacySidecarPath escape keys the way fileblob does, so
colon-bearing keys are now cleared on Windows and the OCI and Debian
rows in TestStoreClearsLegacyAttrsSidecar prove it. The comment
described the behaviour before that commit.
---------
Co-authored-by: Andrew Nesbitt <andrewnez@gmail.com>
2026-09-15 12:07:18 -07:00
|
|
|
b.clearLegacySidecar(path)
|
|
|
|
|
|
2026-01-29 16:13:16 +00:00
|
|
|
// Compute hash while writing
|
|
|
|
|
h := sha256.New()
|
|
|
|
|
tee := io.TeeReader(r, h)
|
|
|
|
|
|
2026-10-14 08:36:21 +02:00
|
|
|
// gocloud commits whatever was written when the writer closes, unless
|
|
|
|
|
// its context is cancelled first, so a failed copy cancels it to abort.
|
|
|
|
|
writeCtx, abort := context.WithCancel(ctx)
|
|
|
|
|
defer abort()
|
|
|
|
|
|
2026-01-29 16:13:16 +00:00
|
|
|
opts := &blob.WriterOptions{}
|
2026-10-14 08:36:21 +02:00
|
|
|
w, err := b.bucket.NewWriter(writeCtx, path, opts)
|
2026-01-29 16:13:16 +00:00
|
|
|
if err != nil {
|
|
|
|
|
return 0, "", fmt.Errorf("creating writer: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
size, err := io.Copy(w, tee)
|
|
|
|
|
if err != nil {
|
2026-10-14 08:36:21 +02:00
|
|
|
abort()
|
2026-01-29 16:13:16 +00:00
|
|
|
_ = w.Close()
|
|
|
|
|
return 0, "", fmt.Errorf("writing content: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if err := w.Close(); err != nil {
|
|
|
|
|
return 0, "", fmt.Errorf("closing writer: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
hash := hex.EncodeToString(h.Sum(nil))
|
|
|
|
|
return size, hash, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (b *Blob) Open(ctx context.Context, path string) (io.ReadCloser, error) {
|
Support HTTP byte-range requests for local file cached artifact downloads (#404)
* feat(storage): expose efficient seek for local files
Open file-backed artifacts with os.Open so local readers support
efficient seeking. Leave cloud storage readers unchanged.
Keep the legacy sidecar mapping test focused on fileblob's reader:
Blob.Open now reads local files directly and no longer consults the
sidecar. Continue checking sidecar cleanup and post-store reads.
* feat(handler): preserve efficient seek through integrity checks
Keep the cache result's single Reader field and preserve io.Seeker
when the underlying storage reader supports efficient seeking.
Sequential reads continue through whole-object integrity verification.
A successful seek switches subsequent reads to the source for range
responses, where full-object verification is not possible.
Test both modes and verify the source is closed once.
* feat(handler): add request-aware byte range responses
Add a request-aware artifact serving helper for bounded, open-ended,
and suffix byte ranges, including If-Range handling, 206 responses,
and 416 responses for valid unsatisfiable ranges.
Ignore malformed and multi-range requests by serving the full artifact.
Advertise byte-range support only for seekable readers, preserving HEAD,
redirect, and non-seekable behavior. Clarify that net/http recovers
ErrAbortHandler per request and keeps the server running.
* feat(handler): enable byte ranges across artifact downloads
Forward each artifact request to the shared range-aware response helper,
including cached generic release assets and OCI blobs. This lets the
handler honor ranges only when its reader supports efficient seeking,
without changing direct-storage redirect behavior.
Add warm-cache endpoint tests that assert partial response headers and
bytes without an upstream refetch. Document supported range behavior,
unsupported reader fallback, and the integrity limitation of partial reads.
2026-10-04 09:48:41 -07:00
|
|
|
if localPath := b.localPath(path); localPath != "" {
|
|
|
|
|
r, err := os.Open(localPath)
|
|
|
|
|
if err != nil {
|
|
|
|
|
if os.IsNotExist(err) {
|
|
|
|
|
return nil, ErrNotFound
|
|
|
|
|
}
|
|
|
|
|
return nil, fmt.Errorf("opening local reader: %w", err)
|
|
|
|
|
}
|
|
|
|
|
return r, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-29 16:13:16 +00:00
|
|
|
r, err := b.bucket.NewReader(ctx, path, nil)
|
|
|
|
|
if err != nil {
|
|
|
|
|
if isNotExist(err) {
|
|
|
|
|
return nil, ErrNotFound
|
|
|
|
|
}
|
|
|
|
|
return nil, fmt.Errorf("opening reader: %w", err)
|
|
|
|
|
}
|
|
|
|
|
return r, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (b *Blob) Exists(ctx context.Context, path string) (bool, error) {
|
|
|
|
|
exists, err := b.bucket.Exists(ctx, path)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return false, fmt.Errorf("checking existence: %w", err)
|
|
|
|
|
}
|
|
|
|
|
return exists, nil
|
|
|
|
|
}
|
|
|
|
|
|
Give each fetch its own storage path
Every fetch of an artifact wrote one path, so fetches with different URLs
or digests, which do not share a coalescing key, could overwrite each
other's bytes, serve the wrong ones, or delete them on a digest mismatch
while the other was about to open them.
storeArtifact now writes {ecosystem}/{name}/{version}/{fetch id}/{filename},
with a random id per fetch. Most ecosystems learn the digest only after
the fetch, and storage has no rename, so the id is the one rule that fits
all of them. Existing records keep their paths and stay readable. On a
file:// bucket, Delete now removes an emptied fetch directory, which
fileblob leaves behind, and no other directory, since another fetch may
be creating its own inside it.
A path a record stops pointing at is queued in pending_deletes rather
than deleted, since a request that read the record may still open it. A
loop deletes queued paths after max(1h, direct_serve_ttl), whether or not
max_size is set. UpsertArtifact and clears apply only if the record still
holds the path the caller read, so racing commits each queue the path
they replaced and a clear never orphans a newer commit. Eviction still
deletes inline, and counts space as freed only when the record still
pointed at what it deleted.
2026-09-23 14:40:13 -07:00
|
|
|
// Delete removes the object at path. On a file:// bucket it also removes the
|
|
|
|
|
// object's fetch directory once empty, since fileblob leaves directories
|
|
|
|
|
// behind. Any other directory, such as a version directory another fetch may
|
|
|
|
|
// be creating its own directory in, is left alone.
|
2026-01-29 16:13:16 +00:00
|
|
|
func (b *Blob) Delete(ctx context.Context, path string) error {
|
|
|
|
|
err := b.bucket.Delete(ctx, path)
|
|
|
|
|
if err != nil && !isNotExist(err) {
|
|
|
|
|
return fmt.Errorf("deleting object: %w", err)
|
|
|
|
|
}
|
Give each fetch its own storage path
Every fetch of an artifact wrote one path, so fetches with different URLs
or digests, which do not share a coalescing key, could overwrite each
other's bytes, serve the wrong ones, or delete them on a digest mismatch
while the other was about to open them.
storeArtifact now writes {ecosystem}/{name}/{version}/{fetch id}/{filename},
with a random id per fetch. Most ecosystems learn the digest only after
the fetch, and storage has no rename, so the id is the one rule that fits
all of them. Existing records keep their paths and stay readable. On a
file:// bucket, Delete now removes an emptied fetch directory, which
fileblob leaves behind, and no other directory, since another fetch may
be creating its own inside it.
A path a record stops pointing at is queued in pending_deletes rather
than deleted, since a request that read the record may still open it. A
loop deletes queued paths after max(1h, direct_serve_ttl), whether or not
max_size is set. UpsertArtifact and clears apply only if the record still
holds the path the caller read, so racing commits each queue the path
they replaced and a clear never orphans a newer commit. Eviction still
deletes inline, and counts space as freed only when the record still
pointed at what it deleted.
2026-09-23 14:40:13 -07:00
|
|
|
if p := b.localPath(path); p != "" {
|
|
|
|
|
if dir := filepath.Dir(p); dir != b.fileRoot && isFetchDir(filepath.Base(dir)) {
|
|
|
|
|
_ = os.Remove(dir) // fails, harmlessly, while the directory holds anything
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-01-29 16:13:16 +00:00
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-27 12:04:38 +01:00
|
|
|
func (b *Blob) SignedURL(ctx context.Context, path string, expiry time.Duration) (string, error) {
|
|
|
|
|
url, err := b.bucket.SignedURL(ctx, path, &blob.SignedURLOptions{
|
|
|
|
|
Method: http.MethodGet,
|
|
|
|
|
Expiry: expiry,
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
if gcerrors.Code(err) == gcerrors.Unimplemented {
|
|
|
|
|
return "", ErrSignedURLUnsupported
|
|
|
|
|
}
|
|
|
|
|
return "", fmt.Errorf("signing URL: %w", err)
|
|
|
|
|
}
|
|
|
|
|
return url, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-29 16:13:16 +00:00
|
|
|
func (b *Blob) Size(ctx context.Context, path string) (int64, error) {
|
|
|
|
|
attrs, err := b.bucket.Attributes(ctx, path)
|
|
|
|
|
if err != nil {
|
|
|
|
|
if isNotExist(err) {
|
|
|
|
|
return 0, ErrNotFound
|
|
|
|
|
}
|
|
|
|
|
return 0, fmt.Errorf("getting attributes: %w", err)
|
|
|
|
|
}
|
|
|
|
|
return attrs.Size, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (b *Blob) UsedSpace(ctx context.Context) (int64, error) {
|
|
|
|
|
var total int64
|
|
|
|
|
|
|
|
|
|
iter := b.bucket.List(nil)
|
|
|
|
|
for {
|
|
|
|
|
obj, err := iter.Next(ctx)
|
|
|
|
|
if err == io.EOF {
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
if err != nil {
|
|
|
|
|
return 0, fmt.Errorf("listing objects: %w", err)
|
|
|
|
|
}
|
|
|
|
|
total += obj.Size
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return total, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-04 12:15:16 +02:00
|
|
|
// ListPrefix returns object metadata for keys under a prefix.
|
|
|
|
|
func (b *Blob) ListPrefix(ctx context.Context, prefix string) ([]ObjectInfo, error) {
|
|
|
|
|
iter := b.bucket.List(&blob.ListOptions{Prefix: prefix})
|
|
|
|
|
objects := make([]ObjectInfo, 0)
|
|
|
|
|
|
|
|
|
|
for {
|
|
|
|
|
obj, err := iter.Next(ctx)
|
|
|
|
|
if err == io.EOF {
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("listing objects: %w", err)
|
|
|
|
|
}
|
|
|
|
|
if obj.IsDir {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
info := ObjectInfo{
|
|
|
|
|
Path: obj.Key,
|
|
|
|
|
Size: obj.Size,
|
|
|
|
|
ModTime: obj.ModTime,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
objects = append(objects, info)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return objects, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-29 16:13:16 +00:00
|
|
|
func (b *Blob) Close() error {
|
|
|
|
|
return b.bucket.Close()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (b *Blob) URL() string {
|
|
|
|
|
return b.url
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func isNotExist(err error) bool {
|
|
|
|
|
return gcerrors.Code(err) == gcerrors.NotFound
|
|
|
|
|
}
|