Browse Source
s3: route uploads by storage class disk mapping
feature-8113-storage-class-disk-routing
s3: route uploads by storage class disk mapping
feature-8113-storage-class-disk-routing
10 changed files with 235 additions and 7 deletions
-
45docs/design/s3-storage-class-disk-routing.md
-
1weed/command/filer.go
-
1weed/command/mini.go
-
7weed/command/s3.go
-
1weed/command/server.go
-
6weed/s3api/s3api_object_handlers_multipart.go
-
16weed/s3api/s3api_object_handlers_put.go
-
9weed/s3api/s3api_server.go
-
79weed/s3api/storage_class_routing.go
-
77weed/s3api/storage_class_routing_test.go
@ -0,0 +1,45 @@ |
|||
# S3 Storage Class to Disk Routing |
|||
|
|||
## Problem |
|||
SeaweedFS already stores S3 `x-amz-storage-class` as object metadata, but write allocation (`AssignVolume`) does not use it. Objects are therefore not routed to specific disk tags by storage class. |
|||
|
|||
## Goals |
|||
1. Route new writes to disk types based on storage class. |
|||
2. Preserve current behavior when no routing map is configured. |
|||
3. Keep implementation incremental so future storage-class transitions can reuse the same decision logic. |
|||
|
|||
## Phase 1 (implemented in this PR) |
|||
### Scope |
|||
1. Add S3 server option `storageClassDiskTypeMap` (`-s3.storageClassDiskTypeMap` in composite commands, `-storageClassDiskTypeMap` in standalone `weed s3`). |
|||
2. Parse map format: `STORAGE_CLASS=diskType` comma-separated, e.g. `STANDARD_IA=ssd,GLACIER=hdd`. |
|||
3. Resolve effective storage class from: |
|||
- request header `X-Amz-Storage-Class` |
|||
- fallback to stored entry metadata (when available) |
|||
- fallback to `STANDARD` |
|||
4. Apply mapped disk type on `AssignVolume` for `putToFiler` upload path. |
|||
5. For multipart uploads, propagate storage class from upload metadata to part requests so part chunk allocation also follows routing. |
|||
|
|||
### Behavior |
|||
1. If mapping is empty or class is unmapped: unchanged behavior (`DiskType=""`). |
|||
2. Invalid storage class in request header: return `InvalidStorageClass`. |
|||
3. Metadata storage remains AWS-compatible (`X-Amz-Storage-Class` is still saved when explicitly provided). |
|||
|
|||
## Phase 2 (next) |
|||
1. Apply the same routing decision to server-side copy chunk allocation paths. |
|||
2. Ensure storage-class changes via copy (`x-amz-metadata-directive: REPLACE` + new class) move chunks to target disk type immediately. |
|||
|
|||
## Phase 3 (future) |
|||
1. Add async background transition API for in-place class change: |
|||
- mark object transition intent in metadata |
|||
- enqueue migration job |
|||
- copy chunks to target class disk |
|||
- atomically swap metadata/chunks |
|||
- garbage collect old chunks |
|||
2. Add transition job status and retry handling. |
|||
3. Add bucket policy controls for allowed transitions. |
|||
|
|||
## Non-goals for Phase 1 |
|||
1. Lifecycle-driven transitions (`STANDARD` -> `GLACIER` by age). |
|||
2. Cost-aware placement balancing. |
|||
3. Cross-cluster migration. |
|||
|
|||
@ -0,0 +1,79 @@ |
|||
package s3api |
|||
|
|||
import ( |
|||
"fmt" |
|||
"net/http" |
|||
"strings" |
|||
|
|||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" |
|||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err" |
|||
) |
|||
|
|||
const defaultStorageClass = "STANDARD" |
|||
|
|||
func normalizeStorageClass(storageClass string) string { |
|||
return strings.ToUpper(strings.TrimSpace(storageClass)) |
|||
} |
|||
|
|||
func parseStorageClassDiskTypeMap(raw string) (map[string]string, error) { |
|||
mappings := make(map[string]string) |
|||
if strings.TrimSpace(raw) == "" { |
|||
return mappings, nil |
|||
} |
|||
|
|||
for _, token := range strings.Split(raw, ",") { |
|||
token = strings.TrimSpace(token) |
|||
if token == "" { |
|||
continue |
|||
} |
|||
|
|||
parts := strings.SplitN(token, "=", 2) |
|||
if len(parts) != 2 { |
|||
return nil, fmt.Errorf("invalid mapping %q, expected STORAGE_CLASS=diskType", token) |
|||
} |
|||
|
|||
storageClass := normalizeStorageClass(parts[0]) |
|||
if !validateStorageClass(storageClass) { |
|||
return nil, fmt.Errorf("invalid storage class %q in mapping %q", storageClass, token) |
|||
} |
|||
|
|||
diskType := strings.TrimSpace(parts[1]) |
|||
if diskType == "" { |
|||
return nil, fmt.Errorf("empty disk type in mapping %q", token) |
|||
} |
|||
|
|||
mappings[storageClass] = diskType |
|||
} |
|||
|
|||
return mappings, nil |
|||
} |
|||
|
|||
func resolveEffectiveStorageClass(header http.Header, entryExtended map[string][]byte) (string, s3err.ErrorCode) { |
|||
if header != nil { |
|||
if fromHeader := strings.TrimSpace(header.Get(s3_constants.AmzStorageClass)); fromHeader != "" { |
|||
storageClass := normalizeStorageClass(fromHeader) |
|||
if !validateStorageClass(storageClass) { |
|||
return "", s3err.ErrInvalidStorageClass |
|||
} |
|||
return storageClass, s3err.ErrNone |
|||
} |
|||
} |
|||
|
|||
if entryExtended != nil { |
|||
if fromEntry := strings.TrimSpace(string(entryExtended[s3_constants.AmzStorageClass])); fromEntry != "" { |
|||
storageClass := normalizeStorageClass(fromEntry) |
|||
if validateStorageClass(storageClass) { |
|||
return storageClass, s3err.ErrNone |
|||
} |
|||
} |
|||
} |
|||
|
|||
return defaultStorageClass, s3err.ErrNone |
|||
} |
|||
|
|||
func (s3a *S3ApiServer) mapStorageClassToDiskType(storageClass string) string { |
|||
if len(s3a.storageClassDiskTypes) == 0 { |
|||
return "" |
|||
} |
|||
return s3a.storageClassDiskTypes[normalizeStorageClass(storageClass)] |
|||
} |
|||
@ -0,0 +1,77 @@ |
|||
package s3api |
|||
|
|||
import ( |
|||
"net/http" |
|||
"testing" |
|||
|
|||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" |
|||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err" |
|||
) |
|||
|
|||
func TestParseStorageClassDiskTypeMap(t *testing.T) { |
|||
mappings, err := parseStorageClassDiskTypeMap("STANDARD_IA=ssd,GLACIER=hdd") |
|||
if err != nil { |
|||
t.Fatalf("parseStorageClassDiskTypeMap returned error: %v", err) |
|||
} |
|||
|
|||
if got, want := mappings["STANDARD_IA"], "ssd"; got != want { |
|||
t.Fatalf("STANDARD_IA mapping mismatch: got %q want %q", got, want) |
|||
} |
|||
if got, want := mappings["GLACIER"], "hdd"; got != want { |
|||
t.Fatalf("GLACIER mapping mismatch: got %q want %q", got, want) |
|||
} |
|||
} |
|||
|
|||
func TestParseStorageClassDiskTypeMapRejectsInvalidInput(t *testing.T) { |
|||
testCases := []string{ |
|||
"INVALID=ssd", |
|||
"STANDARD_IA=", |
|||
"STANDARD_IA", |
|||
} |
|||
|
|||
for _, tc := range testCases { |
|||
if _, err := parseStorageClassDiskTypeMap(tc); err == nil { |
|||
t.Fatalf("expected parse failure for %q", tc) |
|||
} |
|||
} |
|||
} |
|||
|
|||
func TestResolveEffectiveStorageClass(t *testing.T) { |
|||
header := make(http.Header) |
|||
header.Set(s3_constants.AmzStorageClass, "standard_ia") |
|||
sc, code := resolveEffectiveStorageClass(header, nil) |
|||
if code != s3err.ErrNone { |
|||
t.Fatalf("expected no error, got %v", code) |
|||
} |
|||
if sc != "STANDARD_IA" { |
|||
t.Fatalf("expected STANDARD_IA, got %q", sc) |
|||
} |
|||
|
|||
header = make(http.Header) |
|||
sc, code = resolveEffectiveStorageClass(header, map[string][]byte{ |
|||
s3_constants.AmzStorageClass: []byte("GLACIER"), |
|||
}) |
|||
if code != s3err.ErrNone { |
|||
t.Fatalf("expected no error for entry metadata, got %v", code) |
|||
} |
|||
if sc != "GLACIER" { |
|||
t.Fatalf("expected GLACIER, got %q", sc) |
|||
} |
|||
|
|||
sc, code = resolveEffectiveStorageClass(header, nil) |
|||
if code != s3err.ErrNone { |
|||
t.Fatalf("expected no error for default class, got %v", code) |
|||
} |
|||
if sc != defaultStorageClass { |
|||
t.Fatalf("expected default storage class %q, got %q", defaultStorageClass, sc) |
|||
} |
|||
} |
|||
|
|||
func TestResolveEffectiveStorageClassRejectsInvalidHeader(t *testing.T) { |
|||
header := make(http.Header) |
|||
header.Set(s3_constants.AmzStorageClass, "not-a-class") |
|||
_, code := resolveEffectiveStorageClass(header, nil) |
|||
if code != s3err.ErrInvalidStorageClass { |
|||
t.Fatalf("expected ErrInvalidStorageClass, got %v", code) |
|||
} |
|||
} |
|||
Write
Preview
Loading…
Cancel
Save
Reference in new issue