Skip to content

Commit 9126735

Browse files
authored
fix(storage): contain unavailable replica providers (#7)
1 parent 06e35cf commit 9126735

28 files changed

Lines changed: 6550 additions & 1049 deletions

docs/en/concepts/filecoin-storage-flow.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,9 @@ Retry after restoring RPC connectivity, storage provider reachability, wallet fu
5050

5151
## Provider Health
5252

53-
Health checks record storage provider and local data set status. The dashboard uses those results to show copies that are `unavailable`, `degraded`, or `unknown`. These results are observational; recovery from provider unavailability is part of the planned replica repair feature below.
53+
Health checks record storage provider and local data set status. The dashboard uses those results to show copies that are `unavailable`, `degraded`, or `unknown`.
54+
55+
If an established provider becomes temporarily unavailable while the initial copies are still being stored, SynapS3 keeps using the other assigned writable copies. The unfinished copy waits without consuming retries and resumes automatically when the original provider becomes reachable again. SynapS3 does not automatically select a replacement provider. Repairing copies that became unavailable after storage completed remains part of the planned replica repair feature below.
5456

5557
## What Users See
5658

docs/en/operations/upgrade-recovery.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ Receive write -> save object -> record metadata -> return success -> continue ba
5555

5656
| Scenario | Recovery |
5757
| --- | --- |
58+
| Established provider is temporarily unavailable during initial storage | Restore the original provider. Other assigned writable copies continue, while the unfinished copy waits without consuming retries and resumes automatically. SynapS3 does not select a replacement provider. |
5859
| Background storage task cannot reach a provider | Restore connectivity, then retry exhausted storage tasks. |
5960
| RPC node down | Restore RPC connectivity, then retry exhausted tasks. |
6061
| Private provider URL blocked | Keep blocked by default; enable `filecoin.allow_private_networks` only for trusted private deployments. |

docs/zh/concepts/filecoin-storage-flow.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,9 @@ synaps3 admin task retry 42
5050

5151
## 存储提供方健康状态
5252

53-
健康检查会记录存储提供方和本地数据集的状态。仪表盘会使用这些结果,标出 `unavailable``degraded``unknown` 的存储副本。这些结果用于观测;存储提供方不可用后的恢复属于下面计划支持的副本修复功能。
53+
健康检查会记录存储提供方和本地数据集的状态。仪表盘会使用这些结果,标出 `unavailable``degraded``unknown` 的存储副本。
54+
55+
如果已建立的存储提供方在首次副本尚未全部完成时暂时不可用,SynapS3 会继续使用其他已分配且可写的副本。未完成副本会等待且不消耗重试次数,并在原存储提供方恢复可达后自动继续;系统不会自动选择替代提供方。已完成存储的副本随后变为不可用时,其修复仍属于下面计划支持的副本修复功能。
5456

5557
## 用户能看到什么
5658

docs/zh/operations/upgrade-recovery.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ synaps3 admin task stats
5555

5656
| 场景 | 恢复方式 |
5757
| --- | --- |
58+
| 已建立的存储提供方在首次存储期间暂时不可用 | 恢复原存储提供方。其他已分配且可写的副本会继续处理;未完成副本会等待且不消耗重试次数,并在恢复后自动继续。SynapS3 不会选择替代提供方。 |
5859
| 后台存储任务无法连接存储提供方 | 恢复连接,然后重试 exhausted 存储任务。 |
5960
| RPC 节点故障 | 恢复 RPC 连接,然后重试 exhausted 任务。 |
6061
| 私有存储提供方 URL 被阻止 | 默认保持阻止;只在可信私有部署中开启 `filecoin.allow_private_networks`|

internal/admin/api_buckets_test.go

Lines changed: 10 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4307,21 +4307,6 @@ func TestAPIBucketObjectProvenance(t *testing.T) {
43074307
if err != nil || peerBinding == nil {
43084308
t.Fatalf("GetDataSetBindingByCopyIndex peer: binding=%v err=%v", peerBinding, err)
43094309
}
4310-
if _, err := srv.db.NewUpdate().
4311-
Model((*model.StorageDataSet)(nil)).
4312-
Set("status = ?", model.StorageDataSetStatusDraining).
4313-
Set("updated_at = ?", time.Now()).
4314-
Where("id = ?", peerBinding.ID).
4315-
Exec(ctx); err != nil {
4316-
t.Fatalf("mark peer dataset draining: %v", err)
4317-
}
4318-
drainingDetail, statusCode := getProvenance("provenance-bucket", versionID)
4319-
if statusCode != http.StatusOK {
4320-
t.Fatalf("draining status = %d, want %d", statusCode, http.StatusOK)
4321-
}
4322-
if drainingDetail.SuccessCopies != 2 {
4323-
t.Fatalf("draining provenance = %#v, want draining dataset counted as readable", drainingDetail)
4324-
}
43254310
if err := repos.Uploads.MarkDataSetUnavailable(ctx, peerBinding.ID, "provider dataset retired"); err != nil {
43264311
t.Fatalf("MarkDataSetUnavailable peer: %v", err)
43274312
}
@@ -4332,6 +4317,16 @@ func TestAPIBucketObjectProvenance(t *testing.T) {
43324317
if unavailableDetail.SuccessCopies != 1 {
43334318
t.Fatalf("unavailable provenance = %#v, want unavailable dataset excluded from readable copies", unavailableDetail)
43344319
}
4320+
if err := repos.Uploads.MarkDataSetDraining(ctx, peerBinding.ID, "provider service ended"); err != nil {
4321+
t.Fatalf("MarkDataSetDraining peer: %v", err)
4322+
}
4323+
drainingDetail, statusCode := getProvenance("provenance-bucket", versionID)
4324+
if statusCode != http.StatusOK {
4325+
t.Fatalf("draining status = %d, want %d", statusCode, http.StatusOK)
4326+
}
4327+
if drainingDetail.SuccessCopies != 2 {
4328+
t.Fatalf("draining provenance = %#v, want draining dataset counted as readable", drainingDetail)
4329+
}
43354330
if !reflect.DeepEqual(identityResolver.requests[0], []string{"101", "202", "303"}) {
43364331
t.Fatalf("provider identity request = %#v, want one provenance snapshot request", identityResolver.requests)
43374332
}

internal/app/runtime.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,10 @@ func NewRuntime(ctx context.Context, opts RuntimeOptions) (_ *Runtime, err error
157157
worker.NewUploader(repos, localCache, opts.Filecoin.Storage, opts.Filecoin.WalletQuery, stateMachine, evictionPolicy,
158158
cfg.Filecoin.DefaultCopies, cfg.Worker.Upload.Concurrency, cfg.Worker.Upload.PollInterval, logger,
159159
worker.WithEvictMaxRetries(cfg.Worker.Evictor.MaxRetries),
160+
worker.WithPDPStatusChecker(synapse.NewPDPStatusChecker(synapse.PDPStatusCheckerOptions{
161+
Timeout: 15 * time.Second,
162+
AllowPrivateNetworks: cfg.Filecoin.AllowPrivateNetworks,
163+
})),
160164
worker.WithEventPublisher(events)),
161165
worker.NewEvictor(repos, localCache, cacheGate, accessTracker, stateMachine,
162166
cfg.Worker.Evictor.Concurrency, cfg.Worker.Evictor.PollInterval, logger,

internal/db/repository/interfaces.go

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -339,21 +339,31 @@ type UploadCopyBindingInput struct {
339339
}
340340

341341
type MarkUploadCopyPieceReadyInput struct {
342-
UploadID int64
343-
CopyIndex int
344-
PieceCID string
345-
PieceID *types.OnChainID
346-
RetrievalURL string
342+
StorageUploadCopyID int64
343+
UploadID int64
344+
CopyIndex int
345+
PieceCID string
346+
PieceID *types.OnChainID
347+
RetrievalURL string
347348
}
348349

349350
type MarkUploadCopyCommittingInput struct {
351+
StorageUploadCopyID int64
350352
UploadID int64
351353
CopyIndex int
352354
CommitExtraDataHex string
353355
CommitTransactionID string
354356
}
355357

358+
type ResetRejectedUploadCopyCommitInput struct {
359+
UploadID int64
360+
CopyIndex int
361+
CommitTransactionID string
362+
LastError string
363+
}
364+
356365
type MarkUploadCopyCommittedInput struct {
366+
StorageUploadCopyID int64
357367
UploadID int64
358368
CopyIndex int
359369
PieceCID string
@@ -422,14 +432,21 @@ type StorageUploadRepository interface {
422432
EnsureDataSetBinding(ctx context.Context, input EnsureDataSetBindingInput) (*model.StorageDataSet, error)
423433
MarkDataSetCreating(ctx context.Context, input MarkDataSetCreatingInput) error
424434
MarkDataSetReady(ctx context.Context, input MarkDataSetReadyInput) error
435+
RecoverDataSet(ctx context.Context, input MarkDataSetReadyInput) (bool, error)
425436
MarkDataSetDraining(ctx context.Context, id int64, lastError string) error
426437
MarkDataSetFailed(ctx context.Context, id int64, lastError string) error
427438
MarkDataSetUnavailable(ctx context.Context, id int64, lastError string) error
428-
DiscardFailedDataSetCandidate(ctx context.Context, uploadID int64, copyIndex int, storageDataSetID int64) error
439+
DiscardFailedDataSetCandidate(ctx context.Context, uploadID int64, copyIndex int, storageDataSetID int64) (bool, error)
429440
CreateUploadCopiesForBindings(ctx context.Context, uploadID int64, copies []UploadCopyBindingInput) error
430441
GetUploadCopy(ctx context.Context, uploadID int64, copyIndex int) (*model.StorageUploadCopy, error)
442+
GetUploadCopyByID(ctx context.Context, id int64) (*model.StorageUploadCopy, error)
443+
NextIncompleteCopyForDataSet(ctx context.Context, storageDataSetID int64) (*model.StorageUploadCopy, error)
444+
NextFinalizableCopyForDataSet(ctx context.Context, storageDataSetID int64) (*model.StorageUploadCopy, error)
445+
ListUnavailableDataSetsWithIncompleteCopies(ctx context.Context, afterID int64, limit int) ([]model.StorageDataSet, error)
446+
ReassignIngressCopy(ctx context.Context, uploadID int64, unavailableCopyIndex int) (*model.StorageUploadCopy, error)
431447
MarkUploadCopyPieceReady(ctx context.Context, input MarkUploadCopyPieceReadyInput) error
432448
MarkUploadCopyCommitting(ctx context.Context, input MarkUploadCopyCommittingInput) error
449+
ResetRejectedUploadCopyCommit(ctx context.Context, input ResetRejectedUploadCopyCommitInput) error
433450
MarkUploadCopyCommitted(ctx context.Context, input MarkUploadCopyCommittedInput) error
434451
MarkUploadCopyFailed(ctx context.Context, uploadID int64, copyIndex int, lastError string) error
435452
BindReadableUploadForContent(ctx context.Context, input BindReadableUploadInput) ([]ObjectVersionRef, error)
@@ -453,7 +470,14 @@ type BucketACLSnapshot struct {
453470
// TaskRepository defines persistence operations for Task entities.
454471
type TaskRepository interface {
455472
Create(ctx context.Context, task *model.Task) error
473+
// EnsureRecurring creates a singleton coordinator task or reactivates its
474+
// completed row with the supplied payload. Active, failed, exhausted, and
475+
// cancelled rows are left unchanged.
476+
EnsureRecurring(ctx context.Context, task *model.Task) (bool, error)
456477
GetByID(ctx context.Context, id int64) (*model.Task, error)
478+
GetByIdempotencyKey(ctx context.Context, idempotencyKey string) (*model.Task, error)
479+
HasActiveByIdempotencyKey(ctx context.Context, idempotencyKey string) (bool, error)
480+
HasEarlierRunningUploadCopyTask(ctx context.Context, claimedTask *model.Task, uploadID int64, copyIndex int) (bool, error)
457481

458482
// ClaimReady atomically claims one ready task of the given type by
459483
// transitioning it to running and setting a lease. Returns nil if no task is available.
@@ -471,6 +495,12 @@ type TaskRepository interface {
471495
ScheduleRetryRunning(ctx context.Context, task *model.Task, lastError string, backoff time.Duration) (model.TaskStatus, error)
472496
// WaitRunning records a non-error wait and releases the running task until scheduled_at.
473497
WaitRunning(ctx context.Context, task *model.Task, reason model.TaskWaitReason, message string, delay time.Duration) error
498+
// LockRunningClaim locks the same running task claim for a cross-repository
499+
// transaction that must decide whether to continue or complete it.
500+
LockRunningClaim(ctx context.Context, task *model.Task) error
501+
// ContinueRunning completes one successful coordinator item by replacing
502+
// its version reference and payload, then returning the same task row to the queue tail.
503+
ContinueRunning(ctx context.Context, task *model.Task, refVersionID string, payload map[string]interface{}) error
474504
// ReleaseRunning releases the same running task claim back to queued without recording an error.
475505
ReleaseRunning(ctx context.Context, task *model.Task) error
476506
// CancelRunning marks the same running task claim as cancelled.

internal/db/repository/object_deletion_repo_test.go

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,112 @@ func TestObjectRepo_DeleteObjectVersionPermanentlyRemovesVersionAndQueuesStorage
8888
}
8989
}
9090

91+
func TestObjectRepo_DeleteObjectVersionPermanentlyWaitsForUnavailableReplicaRepair(t *testing.T) {
92+
db := testDB(t)
93+
repos := repository.NewRepositories(db)
94+
ctx := context.Background()
95+
bucket := seedBucket(t, db, "permanent-delete-repair-bucket")
96+
version := newObjectVersion(bucket.ID, "file.txt", "01J000000000000000000DEL0R", 10)
97+
if _, err := repos.Objects.CreateVersionAndSetCurrent(ctx, version); err != nil {
98+
t.Fatalf("CreateVersionAndSetCurrent: %v", err)
99+
}
100+
upload, err := repos.Uploads.StartObjectUploadAttempt(ctx, repository.StartObjectUploadAttemptInput{
101+
BucketID: bucket.ID, SourceVersionID: version.VersionID, ContentSize: version.Size, Checksum: version.Checksum, RequestedCopies: 2,
102+
})
103+
if err != nil {
104+
t.Fatalf("StartObjectUploadAttempt: %v", err)
105+
}
106+
primary, err := repos.Uploads.EnsureDataSetBinding(ctx, repository.EnsureDataSetBindingInput{
107+
BucketID: bucket.ID, ProviderID: onChainID(t, "101"), CopyIndex: 0, CreatedByUploadID: upload.ID,
108+
})
109+
if err != nil {
110+
t.Fatalf("EnsureDataSetBinding primary: %v", err)
111+
}
112+
repair, err := repos.Uploads.EnsureDataSetBinding(ctx, repository.EnsureDataSetBindingInput{
113+
BucketID: bucket.ID, ProviderID: onChainID(t, "202"), CopyIndex: 1, CreatedByUploadID: upload.ID,
114+
})
115+
if err != nil {
116+
t.Fatalf("EnsureDataSetBinding repair: %v", err)
117+
}
118+
for _, input := range []repository.MarkDataSetReadyInput{
119+
{ID: primary.ID, UploadID: upload.ID, DataSetID: onChainID(t, "1001")},
120+
{ID: repair.ID, UploadID: upload.ID, DataSetID: onChainID(t, "2002")},
121+
} {
122+
if err := repos.Uploads.MarkDataSetReady(ctx, input); err != nil {
123+
t.Fatalf("MarkDataSetReady(%d): %v", input.ID, err)
124+
}
125+
}
126+
if err := repos.Uploads.CreateUploadCopiesForBindings(ctx, upload.ID, []repository.UploadCopyBindingInput{
127+
{StorageDataSetID: primary.ID, CopyIndex: 0, TransferMethod: model.StorageCopyTransferMethodIngress, ProviderID: onChainID(t, "101")},
128+
{StorageDataSetID: repair.ID, CopyIndex: 1, TransferMethod: model.StorageCopyTransferMethodPeerPull, ProviderID: onChainID(t, "202")},
129+
}); err != nil {
130+
t.Fatalf("CreateUploadCopiesForBindings: %v", err)
131+
}
132+
pieceCID := "bafk2bzacepermanentrepair"
133+
if err := repos.Uploads.MarkUploadCopyCommitted(ctx, repository.MarkUploadCopyCommittedInput{
134+
UploadID: upload.ID, CopyIndex: 0, PieceCID: pieceCID, PieceID: onChainIDPtr(t, "3001"), RetrievalURL: "https://primary.example/piece",
135+
}); err != nil {
136+
t.Fatalf("MarkUploadCopyCommitted primary: %v", err)
137+
}
138+
mustExec(t, db, `UPDATE storage_uploads SET status = ? WHERE id = ?`, model.StorageUploadStatusComplete, upload.ID)
139+
if err := repos.Objects.SetVersionStorageUploadAndTransition(ctx, version.VersionID, upload.ID, model.ObjectStateCached, model.ObjectStateStored); err != nil {
140+
t.Fatalf("SetVersionStorageUploadAndTransition: %v", err)
141+
}
142+
if err := repos.Uploads.MarkDataSetUnavailable(ctx, repair.ID, "temporary outage"); err != nil {
143+
t.Fatalf("MarkDataSetUnavailable: %v", err)
144+
}
145+
146+
_, err = repos.Objects.DeleteObjectVersionPermanently(ctx, repository.DeleteObjectVersionInput{
147+
BucketID: bucket.ID, Key: version.Key, VersionID: version.VersionID,
148+
})
149+
if !errors.Is(err, repository.ErrConflict) {
150+
t.Fatalf("DeleteObjectVersionPermanently during repair error = %v, want ErrConflict", err)
151+
}
152+
if err := repos.Uploads.MarkUploadCopyCommitted(ctx, repository.MarkUploadCopyCommittedInput{
153+
UploadID: upload.ID, CopyIndex: 1, PieceCID: pieceCID, PieceID: onChainIDPtr(t, "3002"), RetrievalURL: "https://repair.example/piece",
154+
}); err != nil {
155+
t.Fatalf("MarkUploadCopyCommitted repair: %v", err)
156+
}
157+
stage := "repair_replica"
158+
repairTask := &model.Task{
159+
Type: model.TaskTypeUpload, Stage: &stage, RefType: "bucket", RefID: bucket.ID, RefVersionID: version.VersionID,
160+
IdempotencyKey: "upload:repair-data-set:permanent-delete", Status: model.TaskStatusQueued,
161+
MaxRetries: 5, ScheduledAt: time.Now(),
162+
}
163+
if err := repos.Tasks.Create(ctx, repairTask); err != nil {
164+
t.Fatalf("Create repair task: %v", err)
165+
}
166+
claimed, err := repos.Tasks.ClaimReady(ctx, model.TaskTypeUpload, time.Minute)
167+
if err != nil || claimed == nil || claimed.ID != repairTask.ID {
168+
t.Fatalf("ClaimReady repair task: task=%#v err=%v", claimed, err)
169+
}
170+
_, err = repos.Objects.DeleteObjectVersionPermanently(ctx, repository.DeleteObjectVersionInput{
171+
BucketID: bucket.ID, Key: version.Key, VersionID: version.VersionID,
172+
})
173+
if !errors.Is(err, repository.ErrConflict) {
174+
t.Fatalf("DeleteObjectVersionPermanently before repair finalization error = %v, want ErrConflict", err)
175+
}
176+
if err := repos.Tasks.Complete(ctx, claimed); err != nil {
177+
t.Fatalf("Complete repair task: %v", err)
178+
}
179+
result, err := repos.Objects.DeleteObjectVersionPermanently(ctx, repository.DeleteObjectVersionInput{
180+
BucketID: bucket.ID, Key: version.Key, VersionID: version.VersionID,
181+
})
182+
if err != nil {
183+
t.Fatalf("DeleteObjectVersionPermanently after repair: %v", err)
184+
}
185+
if result.StorageCleanupTaskID == nil {
186+
t.Fatal("expected storage cleanup task after repair")
187+
}
188+
var cleanupCopies int
189+
if err := db.NewRaw(`SELECT COUNT(*) FROM storage_cleanup_copies WHERE task_id = ?`, *result.StorageCleanupTaskID).Scan(ctx, &cleanupCopies); err != nil {
190+
t.Fatalf("count storage cleanup copies: %v", err)
191+
}
192+
if cleanupCopies != 2 {
193+
t.Fatalf("storage cleanup copies = %d, want both committed replicas", cleanupCopies)
194+
}
195+
}
196+
91197
func TestObjectRepo_DeleteObjectVersionPermanentlyUsesConfiguredStorageCleanupMaxRetries(t *testing.T) {
92198
db := testDB(t)
93199
repos := repository.NewRepositories(db)

0 commit comments

Comments
 (0)