Files
seaweedfs/weed/s3api/s3tables/handler_maintenance.go
T
Nguyễn Lộc Phúcandchrislusf eba2cb926d fix(s3tables): refuse a create that a namespace delete can lose (#12239)
* s3tables: refuse a create that a namespace delete can lose

DeleteNamespace checks the namespace for children and then deletes its
directory recursively in a second filer call. The filer locks each path
on its own, so a table created under the namespace between the two is
not seen by the check and is removed by the delete, while both requests
answer 200. The mirror case loses the table too: a create that passed
its namespace check before the delete finished re-created the namespace
directory through the filer's parent creation and recorded a table in a
namespace that no longer exists.

Each side now writes before it reads. DeleteNamespace claims the
namespace record with a fresh token before it scans, deletes only while
the record still holds that claim, and lifts its own claim when it
stops. CreateTable, RegisterTable and CreateView re-read the namespace
after writing their record and back out when it is claimed or gone:
they remove what they wrote and answer 409 or 404 instead of reporting
an entry that does not survive, or 500 when the record could not be
removed and may remain. An entry the scan missed was written after the
claim, so its create sees the claim; a create and a delete can both
fail, but no longer both succeed. A create is also refused up front
while a claim is held, but only after its authorization check, so a
caller that may not create there does not learn of the delete.

A create writes its record marked "create" and lifts the mark once the
namespace is confirmed open. Until then GetTable, GetView, UpdateTable
and a second create treat the record as absent, so no request is told
about a table or view that the first create may still back out.

* s3tables: keep a create's unfinished record out of reads and listings

* s3tables: refuse catalog writes on a record an operation claimed

* s3tables: answer 404 when a create's write path loses the namespace

* s3tables: confirm the rename destination's namespace like a create

* s3tables: cover unfinished-record reads, claimed-record writes, rename claim

* s3tables: lift a leftover claim when a no-token delete clears the record

* s3tables: answer 409 when a claimed record turns away a write

UpdateView, TagResource, and UntagResource surfaced ErrConcurrentUpdate
as a 500, so an expected conflict looked like a server fault to the
caller. Map it to Conflict the way UpdateTable and the policy handlers
already do.

* s3tables: name the lost source when a rename finds it deleted

* s3tables: report an entry deleted before its update as not found

The filer answers an UpdateEntry on an entry removed since the lookup
with codes.NotFound. mutateEntryExtended returned that error unchanged
instead of filer_pb.ErrNotFound, so a rename whose source was deleted
mid-claim answered 500 instead of NoSuchTable or NoSuchView.

MemFiler now answers a missing entry the same way, so the rename tests
for tables and views take the real filer's path.

* s3tables: unpublish the destination when a rename's source clear fails

* s3tables: refuse to delete a record another operation claimed

* s3tables: release catalog claims on an uncanceled cleanup context

* s3tables: answer 404 when a racing delete already removed the table

* s3tables: keep the rename destination marked until the source clears

* s3tables: guard the rename rollback against a recreated source and an unfinished destination

* s3tables: drop the unfinished destination only once the source is restored

* s3tables: re-resolve the purge target on the record a delete claims

* s3tables: cover a delete that meets a moved metadata location

* s3tables: answer 409 when a create's record changed under it

* s3tables: adopt a record still marked by its unfinished create

* s3tables: skip unfinished creates in the namespace empty check

* s3tables: delete only the record the caller was authorized on

DeleteView and DeleteTable check permissions on the record they read
first, then read the name again to claim it. A record that took the name
in between, such as a table or another owner's view created after a
concurrent delete, was claimed and removed on the first record's
authorization, and DeleteView never compared the caller's version token
with it.

The second read now repeats the first one's checks: a record of the
other kind or an unfinished create answers 404, and a token that no
longer matches answers 409. A delete without a token also compares the
record's version token with the authorized one and answers 409 when they
differ.

* s3tables: re-check the parent after a create lifts its mark

A create that passed checkParentOpen and paused before lifting its mark
could still confirm while a namespace delete held the claim: the delete
scans the still-marked record, skips it, and removes it under a create
that already answered success. confirmParent now re-reads the same
parent record after the mark clears and backs the confirmed record out
when the namespace moved on.

* s3tables: mark the rename destination with a claim of its own

claimCatalogEntry adopted every create-marked record, including a
rename's destination: a create landing after the source cleared could
replace the destination before the rename published it, and with the
source name reused the restore could leave the table on neither name.
The destination now marks with the rename's own claim, which readers
still treat as unpublished but a create must refuse, and the commit
re-checks the destination namespace after the record goes live. The
source keeps its rename claim until the commit lands so a namespace
delete scanning the cleared record still refuses.

* s3tables: compare the parent's createdAt, not its editable metadata

A namespace update rewrites ExtendedKeyMetadata in place, so a create or
rename whose second parent read landed across an UpdateNamespace saw a
changed blob and reported the still-live namespace as gone: creates backed
their record out and answered 404, renames undid their destination.
createdAt survives the edit but changes on a delete+recreate, so it still
identifies the record.

* s3tables: retry lifting the rename's claim off the cleared source

The release's only failure mode is a transient filer error — nothing else
can write the cleared record — yet a single loss left the claim behind:
the old name stayed refused to creates and the namespace counted the
record as a child. Retry the release before warning.

* s3tables: page the namespace scan before deleting it

A page of skippable entries — bare directories or unfinished creates —
ended the scan with hasChildren false, so a table on a later page was
removed with the namespace. Keep listing with StartFromFileName until a
child shows or the listing ends.

---------

Co-authored-by: chrislusf <chrislusf@users.noreply.github.com>
2026-10-30 03:18:52 -07:00

548 lines
20 KiB
Go

package s3tables
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
)
// Maintenance types are scoped: AWS configures unreferenced file removal on the
// table bucket and compaction and snapshot management on the table.
var (
bucketMaintenanceTypes = map[string]bool{
MaintenanceTypeIcebergUnreferencedFileRemoval: true,
}
tableMaintenanceTypes = map[string]bool{
MaintenanceTypeIcebergCompaction: true,
MaintenanceTypeIcebergSnapshotManagement: true,
}
)
// handlePutTableBucketMaintenanceConfiguration sets a maintenance configuration on a table bucket
func (h *S3TablesHandler) handlePutTableBucketMaintenanceConfiguration(w http.ResponseWriter, r *http.Request, filerClient FilerClient) error {
var req PutTableBucketMaintenanceConfigurationRequest
if err := h.readRequestBody(r, &req); err != nil {
h.writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, err.Error())
return err
}
if req.TableBucketARN == "" {
h.writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, "tableBucketARN is required")
return fmt.Errorf("tableBucketARN is required")
}
bucketName, err := parseBucketNameFromARN(req.TableBucketARN)
if err != nil {
h.writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, err.Error())
return err
}
if err := validateMaintenanceValue(req.Type, bucketMaintenanceTypes, req.Value); err != nil {
h.writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, err.Error())
return err
}
if _, err := h.authorizeMaintenanceBucket(r, filerClient, "PutTableBucketMaintenanceConfiguration", bucketName); err != nil {
h.writeMaintenanceError(w, err, ErrCodeNoSuchBucket, fmt.Sprintf("table bucket %s not found", bucketName))
return err
}
if err := h.putMaintenanceConfiguration(r, filerClient, GetTableBucketPath(bucketName), req.Type, req.Value); err != nil {
h.writeMaintenanceError(w, err, ErrCodeNoSuchBucket, fmt.Sprintf("table bucket %s not found", bucketName))
return err
}
h.writeJSON(w, http.StatusOK, nil)
return nil
}
// handleGetTableBucketMaintenanceConfiguration gets the maintenance configuration of a table bucket
func (h *S3TablesHandler) handleGetTableBucketMaintenanceConfiguration(w http.ResponseWriter, r *http.Request, filerClient FilerClient) error {
var req GetTableBucketMaintenanceConfigurationRequest
if err := h.readRequestBody(r, &req); err != nil {
h.writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, err.Error())
return err
}
if req.TableBucketARN == "" {
h.writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, "tableBucketARN is required")
return fmt.Errorf("tableBucketARN is required")
}
bucketName, err := parseBucketNameFromARN(req.TableBucketARN)
if err != nil {
h.writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, err.Error())
return err
}
bucketARN, err := h.authorizeMaintenanceBucket(r, filerClient, "GetTableBucketMaintenanceConfiguration", bucketName)
if err != nil {
h.writeMaintenanceError(w, err, ErrCodeNoSuchBucket, fmt.Sprintf("table bucket %s not found", bucketName))
return err
}
config, err := h.readMaintenanceConfiguration(r, filerClient, GetTableBucketPath(bucketName))
if err != nil {
h.writeMaintenanceError(w, err, ErrCodeNoSuchBucket, fmt.Sprintf("table bucket %s not found", bucketName))
return err
}
h.writeJSON(w, http.StatusOK, &GetTableBucketMaintenanceConfigurationResponse{
TableBucketARN: bucketARN,
Configuration: config,
})
return nil
}
// handlePutTableMaintenanceConfiguration sets a maintenance configuration on a table
func (h *S3TablesHandler) handlePutTableMaintenanceConfiguration(w http.ResponseWriter, r *http.Request, filerClient FilerClient) error {
var req PutTableMaintenanceConfigurationRequest
if err := h.readRequestBody(r, &req); err != nil {
h.writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, err.Error())
return err
}
bucketName, namespaceName, tableName, err := h.parseTableTarget(req.TableBucketARN, req.Namespace, req.Name)
if err != nil {
h.writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, err.Error())
return err
}
if err := validateMaintenanceValue(req.Type, tableMaintenanceTypes, req.Value); err != nil {
h.writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, err.Error())
return err
}
if _, err := h.authorizeMaintenanceTable(r, filerClient, "PutTableMaintenanceConfiguration", bucketName, namespaceName, tableName); err != nil {
h.writeMaintenanceError(w, err, ErrCodeNoSuchTable, fmt.Sprintf("table %s not found", tableName))
return err
}
tablePath := GetTablePath(bucketName, namespaceName, tableName)
if err := h.putMaintenanceConfiguration(r, filerClient, tablePath, req.Type, req.Value); err != nil {
h.writeMaintenanceError(w, err, ErrCodeNoSuchTable, fmt.Sprintf("table %s not found", tableName))
return err
}
h.writeJSON(w, http.StatusOK, nil)
return nil
}
// handleGetTableMaintenanceConfiguration gets the maintenance configuration of a table
func (h *S3TablesHandler) handleGetTableMaintenanceConfiguration(w http.ResponseWriter, r *http.Request, filerClient FilerClient) error {
var req GetTableMaintenanceConfigurationRequest
if err := h.readRequestBody(r, &req); err != nil {
h.writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, err.Error())
return err
}
bucketName, namespaceName, tableName, err := h.parseTableTarget(req.TableBucketARN, req.Namespace, req.Name)
if err != nil {
h.writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, err.Error())
return err
}
tableARN, err := h.authorizeMaintenanceTable(r, filerClient, "GetTableMaintenanceConfiguration", bucketName, namespaceName, tableName)
if err != nil {
h.writeMaintenanceError(w, err, ErrCodeNoSuchTable, fmt.Sprintf("table %s not found", tableName))
return err
}
tablePath := GetTablePath(bucketName, namespaceName, tableName)
config, err := h.readMaintenanceConfiguration(r, filerClient, tablePath)
if err != nil {
h.writeMaintenanceError(w, err, ErrCodeNoSuchTable, fmt.Sprintf("table %s not found", tableName))
return err
}
h.writeJSON(w, http.StatusOK, &GetTableMaintenanceConfigurationResponse{
TableARN: tableARN,
Namespace: []string{namespaceName},
Name: tableName,
Configuration: config,
})
return nil
}
// handleGetTableMaintenanceJobStatus reports the outcome of the last maintenance run on a table
func (h *S3TablesHandler) handleGetTableMaintenanceJobStatus(w http.ResponseWriter, r *http.Request, filerClient FilerClient) error {
var req GetTableMaintenanceJobStatusRequest
if err := h.readRequestBody(r, &req); err != nil {
h.writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, err.Error())
return err
}
bucketName, namespaceName, tableName, err := h.parseTableTarget(req.TableBucketARN, req.Namespace, req.Name)
if err != nil {
h.writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, err.Error())
return err
}
tableARN, err := h.authorizeMaintenanceTable(r, filerClient, "GetTableMaintenanceJobStatus", bucketName, namespaceName, tableName)
if err != nil {
h.writeMaintenanceError(w, err, ErrCodeNoSuchTable, fmt.Sprintf("table %s not found", tableName))
return err
}
tablePath := GetTablePath(bucketName, namespaceName, tableName)
// Unreferenced file removal is configured on the bucket, so a bucket-level
// disable has to show up here as Disabled rather than a stale table status.
bucketConfig, err := h.readMaintenanceConfiguration(r, filerClient, GetTableBucketPath(bucketName))
if err != nil {
h.writeMaintenanceError(w, err, ErrCodeNoSuchBucket, fmt.Sprintf("table bucket %s not found", bucketName))
return err
}
tableConfig, err := h.readMaintenanceConfiguration(r, filerClient, tablePath)
if err != nil {
h.writeMaintenanceError(w, err, ErrCodeNoSuchTable, fmt.Sprintf("table %s not found", tableName))
return err
}
config := MergeMaintenanceConfiguration(bucketConfig, tableConfig)
recorded := MaintenanceJobStatus{}
err = filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
data, err := h.getExtendedAttribute(r.Context(), client, tablePath, ExtendedKeyMaintenanceStatus)
if err != nil {
if errors.Is(err, ErrAttributeNotFound) {
return nil
}
return err
}
return json.Unmarshal(data, &recorded)
})
if err != nil {
h.writeMaintenanceError(w, err, ErrCodeNoSuchTable, fmt.Sprintf("table %s not found", tableName))
return err
}
h.writeJSON(w, http.StatusOK, &GetTableMaintenanceJobStatusResponse{
TableARN: tableARN,
Status: buildJobStatusResponse(recorded, config),
})
return nil
}
// buildJobStatusResponse reports every job type: what the worker recorded, or
// Disabled when the configuration switched the type off, or Not_Yet_Run.
func buildJobStatusResponse(recorded MaintenanceJobStatus, config MaintenanceConfiguration) MaintenanceJobStatus {
jobTypes := []string{
MaintenanceTypeIcebergCompaction,
MaintenanceTypeIcebergSnapshotManagement,
MaintenanceTypeIcebergUnreferencedFileRemoval,
}
status := make(MaintenanceJobStatus, len(jobTypes))
for _, jobType := range jobTypes {
if value, ok := config[jobType]; ok && value != nil && value.Status == MaintenanceStatusDisabled {
status[jobType] = &MaintenanceJobStatusValue{Status: MaintenanceJobStatusDisabled}
continue
}
if value, ok := recorded[jobType]; ok && value != nil {
status[jobType] = value
continue
}
status[jobType] = &MaintenanceJobStatusValue{Status: MaintenanceJobStatusNotYetRun}
}
return status
}
// putMaintenanceConfiguration merges one maintenance type into the stored
// configuration, leaving the other types alone. The write checks the catalog
// identity in the same conditional mutation, so it cannot land on a name a
// concurrent rename or delete has already soft-deleted.
func (h *S3TablesHandler) putMaintenanceConfiguration(
r *http.Request,
filerClient FilerClient,
resourcePath, maintenanceType string,
value *MaintenanceConfigurationValue,
) error {
return filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
return h.mutateCatalogRecord(r.Context(), client, resourcePath, func(extended map[string][]byte) error {
if len(extended[ExtendedKeyMetadata]) == 0 {
return fmt.Errorf("%w: %s", filer_pb.ErrNotFound, resourcePath)
}
config := MaintenanceConfiguration{}
if current := extended[ExtendedKeyMaintenance]; len(current) > 0 {
if err := json.Unmarshal(current, &config); err != nil {
return fmt.Errorf("failed to unmarshal maintenance configuration: %w", err)
}
}
config[maintenanceType] = value
data, err := json.Marshal(config)
if err != nil {
return err
}
extended[ExtendedKeyMaintenance] = data
return nil
})
})
}
// readMaintenanceConfiguration returns the stored configuration, or an empty
// one when the resource has never been configured.
func (h *S3TablesHandler) readMaintenanceConfiguration(r *http.Request, filerClient FilerClient, resourcePath string) (MaintenanceConfiguration, error) {
config := MaintenanceConfiguration{}
err := filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
data, err := h.getExtendedAttribute(r.Context(), client, resourcePath, ExtendedKeyMaintenance)
if err != nil {
if errors.Is(err, ErrAttributeNotFound) {
return nil
}
return err
}
return json.Unmarshal(data, &config)
})
if err != nil {
return nil, err
}
return config, nil
}
// parseTableTarget validates the bucket/namespace/table triple every
// table-scoped maintenance request carries.
func (h *S3TablesHandler) parseTableTarget(tableBucketARN string, namespace []string, name string) (bucketName, namespaceName, tableName string, err error) {
if tableBucketARN == "" || len(namespace) == 0 || name == "" {
return "", "", "", fmt.Errorf("tableBucketARN, namespace, and name are required")
}
if namespaceName, err = validateNamespace(namespace); err != nil {
return "", "", "", err
}
if bucketName, err = parseBucketNameFromARN(tableBucketARN); err != nil {
return "", "", "", err
}
if tableName, err = validateTableName(name); err != nil {
return "", "", "", err
}
return bucketName, namespaceName, tableName, nil
}
// authorizeMaintenanceBucket checks the caller may perform operation on the
// table bucket, returning the bucket ARN.
func (h *S3TablesHandler) authorizeMaintenanceBucket(r *http.Request, filerClient FilerClient, operation, bucketName string) (string, error) {
bucketPath := GetTableBucketPath(bucketName)
var bucketMetadata tableBucketMetadata
var bucketPolicy string
err := filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
data, err := h.getExtendedAttribute(r.Context(), client, bucketPath, ExtendedKeyMetadata)
if err != nil {
return err
}
if err := json.Unmarshal(data, &bucketMetadata); err != nil {
return fmt.Errorf("failed to unmarshal bucket metadata: %w", err)
}
bucketPolicy, err = h.readPolicy(r, client, bucketPath)
return err
})
if err != nil {
return "", err
}
bucketARN := h.generateTableBucketARN(bucketMetadata.OwnerAccountID, bucketName)
principal := h.getAccountID(r)
if !CheckPermissionWithContext(operation, principal, bucketMetadata.OwnerAccountID, bucketPolicy, bucketARN, &PolicyContext{
TrustedIssuers: h.trustedIssuers(r),
IsLocalAccountID: h.localAccountIDs,
TableBucketName: bucketName,
IdentityActions: getIdentityActions(r),
DefaultAllow: h.defaultAllowFor(r),
}) {
return "", NewAuthError(operation, principal, "not authorized to "+operation)
}
return bucketARN, nil
}
// authorizeMaintenanceTable checks the caller may perform operation on the
// table, returning the table ARN.
func (h *S3TablesHandler) authorizeMaintenanceTable(r *http.Request, filerClient FilerClient, operation, bucketName, namespaceName, tableName string) (string, error) {
tablePath := GetTablePath(bucketName, namespaceName, tableName)
bucketPath := GetTableBucketPath(bucketName)
var metadata tableMetadataInternal
var bucketPolicy, tablePolicy string
var bucketTags, tableTags map[string]string
err := filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
data, err := h.getExtendedAttribute(r.Context(), client, tablePath, ExtendedKeyMetadata)
if err != nil {
return err
}
if err := json.Unmarshal(data, &metadata); err != nil {
return fmt.Errorf("failed to unmarshal table metadata: %w", err)
}
if bucketPolicy, err = h.readPolicy(r, client, bucketPath); err != nil {
return err
}
if bucketTags, err = h.readTags(r.Context(), client, bucketPath); err != nil {
return err
}
if tablePolicy, err = h.readPolicy(r, client, tablePath); err != nil {
return err
}
tableTags, err = h.readTags(r.Context(), client, tablePath)
return err
})
if err != nil {
return "", err
}
tableARN := h.generateTableARN(metadata.OwnerAccountID, bucketName, namespaceName+"/"+tableName)
principal := h.getAccountID(r)
ctx := &PolicyContext{
TrustedIssuers: h.trustedIssuers(r),
IsLocalAccountID: h.localAccountIDs,
TableBucketName: bucketName,
Namespace: namespaceName,
TableName: tableName,
TableBucketTags: bucketTags,
ResourceTags: tableTags,
IdentityActions: getIdentityActions(r),
DefaultAllow: h.defaultAllowFor(r),
}
if deniedByPolicy(operation, principal, tablePolicy, tableARN, ctx) ||
!CheckPermissionWithContext(operation, principal, metadata.OwnerAccountID, bucketPolicy, tableARN, ctx) {
return "", NewAuthError(operation, principal, "not authorized to "+operation)
}
return tableARN, nil
}
// readPolicy returns the policy stored on path, or an empty string when the
// resource has none.
func (h *S3TablesHandler) readPolicy(r *http.Request, client filer_pb.SeaweedFilerClient, path string) (string, error) {
data, err := h.getExtendedAttribute(r.Context(), client, path, ExtendedKeyPolicy)
if err != nil {
if errors.Is(err, ErrAttributeNotFound) {
return "", nil
}
return "", fmt.Errorf("failed to read policy: %w", err)
}
return string(data), nil
}
// writeMaintenanceError maps the shared failure modes of the maintenance
// handlers onto responses.
func (h *S3TablesHandler) writeMaintenanceError(w http.ResponseWriter, err error, notFoundCode, notFoundMessage string) {
switch {
case errors.Is(err, filer_pb.ErrNotFound):
h.writeError(w, http.StatusNotFound, notFoundCode, notFoundMessage)
case isAuthError(err):
h.writeError(w, http.StatusForbidden, ErrCodeAccessDenied, err.Error())
case errors.Is(err, ErrConcurrentUpdate):
h.writeError(w, http.StatusConflict, ErrCodeConflict, "maintenance configuration changed concurrently, retry the request")
default:
h.writeError(w, http.StatusInternalServerError, ErrCodeInternalError, err.Error())
}
}
// validateMaintenanceValue rejects a type that does not belong to the scope and
// settings that do not name that same type.
func validateMaintenanceValue(maintenanceType string, allowed map[string]bool, value *MaintenanceConfigurationValue) error {
if maintenanceType == "" {
return fmt.Errorf("type is required")
}
if !allowed[maintenanceType] {
return fmt.Errorf("unsupported maintenance type %q", maintenanceType)
}
if value == nil {
return fmt.Errorf("value is required")
}
switch value.Status {
case MaintenanceStatusEnabled, MaintenanceStatusDisabled:
case "":
return fmt.Errorf("value.status is required")
default:
return fmt.Errorf("invalid value.status %q, expected %q or %q", value.Status, MaintenanceStatusEnabled, MaintenanceStatusDisabled)
}
if value.Settings != nil && !settingsMatchType(maintenanceType, value.Settings) {
return fmt.Errorf("value.settings must only contain %s", maintenanceType)
}
return validateMaintenanceSettings(value.Settings)
}
// AWS bounds every maintenance setting to a positive 32-bit value. Accepting
// anything else would store a number the worker then ignores or saturates, so
// the configuration read back would not be the one that runs.
const (
maintenanceSettingMin int64 = 1
maintenanceSettingMax int64 = 2147483647
)
func validateMaintenanceSettings(settings *MaintenanceSettings) error {
if settings == nil {
return nil
}
if c := settings.IcebergCompaction; c != nil {
if err := validateCompactionStrategy(c.Strategy); err != nil {
return err
}
if err := validateMaintenanceSetting("targetFileSizeMB", c.TargetFileSizeMB); err != nil {
return err
}
}
if m := settings.IcebergSnapshotManagement; m != nil {
if err := validateMaintenanceSetting("minSnapshotsToKeep", m.MinSnapshotsToKeep); err != nil {
return err
}
if err := validateMaintenanceSetting("maxSnapshotAgeHours", m.MaxSnapshotAgeHours); err != nil {
return err
}
}
if u := settings.IcebergUnreferencedFileRemoval; u != nil {
if err := validateMaintenanceSetting("unreferencedDays", u.UnreferencedDays); err != nil {
return err
}
if err := validateMaintenanceSetting("nonCurrentDays", u.NonCurrentDays); err != nil {
return err
}
}
return nil
}
func validateMaintenanceSetting(name string, value *int64) error {
if value == nil {
return nil
}
if *value < maintenanceSettingMin || *value > maintenanceSettingMax {
return fmt.Errorf("%s must be between %d and %d, got %d", name, maintenanceSettingMin, maintenanceSettingMax, *value)
}
return nil
}
// validateCompactionStrategy rejects a strategy the maintenance worker cannot
// carry out, rather than accepting it and quietly compacting some other way.
func validateCompactionStrategy(strategy string) error {
switch strategy {
case "", CompactionStrategyAuto, CompactionStrategyBinpack, CompactionStrategySort:
return nil
case CompactionStrategyZOrder:
return fmt.Errorf("compaction strategy %q is not supported", strategy)
default:
return fmt.Errorf("invalid compaction strategy %q, expected one of %q, %q, %q",
strategy, CompactionStrategyAuto, CompactionStrategyBinpack, CompactionStrategySort)
}
}
func settingsMatchType(maintenanceType string, settings *MaintenanceSettings) bool {
switch maintenanceType {
case MaintenanceTypeIcebergCompaction:
return settings.IcebergSnapshotManagement == nil && settings.IcebergUnreferencedFileRemoval == nil
case MaintenanceTypeIcebergSnapshotManagement:
return settings.IcebergCompaction == nil && settings.IcebergUnreferencedFileRemoval == nil
case MaintenanceTypeIcebergUnreferencedFileRemoval:
return settings.IcebergCompaction == nil && settings.IcebergSnapshotManagement == nil
}
return false
}