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