Make pending asset bookkeeping linear

This commit is contained in:
2026-11-01 19:29:17 +00:00
parent 078db6cbcb
commit b72af69baf
3 changed files with 60 additions and 23 deletions

View File

@@ -263,7 +263,14 @@ validation behavior across the two supported CI paths.
## 6. Make pending-asset bookkeeping linear — P3 ## 6. Make pending-asset bookkeeping linear — P3
Status: open. Status: complete.
**Completed:** Replaced repeated slice removals with pending-map updates and a
single deferred compaction pass on every return path. Category bookkeeping is
now expected O(`n`) with O(`n`) metadata, preserving pending observation order.
Added an early-return regression with out-of-order observations for definitive
and uncertain failures. Delivery/Gitea tests and focused facade/CLI recovery
tests passed.
**Evidence:** `uploadMissingAssets`, `reconcileAssetUpload`, and **Evidence:** `uploadMissingAssets`, `reconcileAssetUpload`, and
`removeIdentity` in [shared asset delivery](../../internal/delivery/remote_assets.go). `removeIdentity` in [shared asset delivery](../../internal/delivery/remote_assets.go).

View File

@@ -113,15 +113,26 @@ func remoteAssetDigest(ctx context.Context, backend forge.Backend, handle any) (
return hex.EncodeToString(hasher.Sum(nil)), nil return hex.EncodeToString(hasher.Sum(nil)), nil
} }
func uploadMissingAssets(ctx context.Context, backend forge.Backend, releaseHandle any, inspection Inspection) (UploadResult, error) { func uploadMissingAssets(ctx context.Context, backend forge.Backend, releaseHandle any, inspection Inspection) (result UploadResult, err error) {
expected := orderedExpected(inspection.Assets) expected := orderedExpected(inspection.Assets)
if err := artifact.ValidateExpectedAssets(expected); err != nil { if err := artifact.ValidateExpectedAssets(expected); err != nil {
return UploadResult{State: Partial}, &forge.Failure{Kind: forge.FailureInvalid, Err: err} return UploadResult{State: Partial}, &forge.Failure{Kind: forge.FailureInvalid, Err: err}
} }
result := UploadResult{State: Partial} result = UploadResult{State: Partial}
preflightConflict := false preflightConflict := false
preflightUnverifiable := false preflightUnverifiable := false
missing := make(map[string]artifact.ExpectedAsset, len(expected)) pending := make(map[string]struct{}, len(expected))
// Keep the initial observation order and compact pending identities once,
// including on early returns after partial or unresolved uploads.
defer func() {
remaining := result.Pending[:0]
for _, item := range result.Pending {
if _, ok := pending[item.Filename]; ok {
remaining = append(remaining, item)
}
}
result.Pending = remaining
}()
for _, observation := range inspection.Assets { for _, observation := range inspection.Assets {
item := identity(observation.Expected) item := identity(observation.Expected)
switch observation.State { switch observation.State {
@@ -129,7 +140,7 @@ func uploadMissingAssets(ctx context.Context, backend forge.Backend, releaseHand
result.Matching = append(result.Matching, item) result.Matching = append(result.Matching, item)
case AssetMissing: case AssetMissing:
result.Pending = append(result.Pending, item) result.Pending = append(result.Pending, item)
missing[observation.Expected.Filename] = observation.Expected pending[observation.Expected.Filename] = struct{}{}
case AssetConflicting: case AssetConflicting:
preflightConflict = true preflightConflict = true
result.Conflicting = append(result.Conflicting, item) result.Conflicting = append(result.Conflicting, item)
@@ -148,13 +159,13 @@ func uploadMissingAssets(ctx context.Context, backend forge.Backend, releaseHand
result.State = NotAttempted result.State = NotAttempted
return result, &forge.Failure{Kind: forge.FailureUnverifiable, Err: errors.New("one or more existing attachments could not be verified")} return result, &forge.Failure{Kind: forge.FailureUnverifiable, Err: errors.New("one or more existing attachments could not be verified")}
} }
if len(missing) == 0 { if len(pending) == 0 {
result.State = Complete result.State = Complete
return result, nil return result, nil
} }
for _, item := range orderMissing(expected) { for _, item := range orderMissing(expected) {
if _, needsUpload := missing[item.Filename]; !needsUpload { if _, needsUpload := pending[item.Filename]; !needsUpload {
continue continue
} }
content, err := assetContent(item) content, err := assetContent(item)
@@ -165,18 +176,19 @@ func uploadMissingAssets(ctx context.Context, backend forge.Backend, releaseHand
closeErr := content.Close() closeErr := content.Close()
if uploadErr == nil { if uploadErr == nil {
result.Uploaded = append(result.Uploaded, identity(item)) result.Uploaded = append(result.Uploaded, identity(item))
result.Pending = removeIdentity(result.Pending, item.Filename) delete(pending, item.Filename)
continue continue
} }
operationErr := errors.Join(uploadErr, closeErr) operationErr := errors.Join(uploadErr, closeErr)
if evidence.Disposition != forge.WriteConflict && evidence.Disposition != forge.WriteUncertain { if evidence.Disposition != forge.WriteConflict && evidence.Disposition != forge.WriteUncertain {
return result, operationErr return result, operationErr
} }
delete(pending, item.Filename)
if err := reconcileAssetUpload(ctx, backend, releaseHandle, item, evidence, operationErr, &result); err != nil { if err := reconcileAssetUpload(ctx, backend, releaseHandle, item, evidence, operationErr, &result); err != nil {
return result, err return result, err
} }
} }
if len(result.Pending) == 0 { if len(pending) == 0 {
result.State = Complete result.State = Complete
} }
return result, nil return result, nil
@@ -186,7 +198,6 @@ func reconcileAssetUpload(ctx context.Context, backend forge.Backend, releaseHan
inspection, inspectErr := inspectRemoteAssets(ctx, backend, releaseHandle, []artifact.ExpectedAsset{item}) inspection, inspectErr := inspectRemoteAssets(ctx, backend, releaseHandle, []artifact.ExpectedAsset{item})
if inspectErr != nil || len(inspection.Assets) != 1 { if inspectErr != nil || len(inspection.Assets) != 1 {
result.State = Unknown result.State = Unknown
result.Pending = removeIdentity(result.Pending, item.Filename)
result.Uncertain = append(result.Uncertain, identity(item)) result.Uncertain = append(result.Uncertain, identity(item))
return &forge.Failure{Kind: forge.FailureUnknown, Err: errors.Join(uploadErr, inspectErr)} return &forge.Failure{Kind: forge.FailureUnknown, Err: errors.Join(uploadErr, inspectErr)}
} }
@@ -194,15 +205,12 @@ func reconcileAssetUpload(ctx context.Context, backend forge.Backend, releaseHan
switch observation.State { switch observation.State {
case AssetMatching: case AssetMatching:
result.Matching = append(result.Matching, identity(item)) result.Matching = append(result.Matching, identity(item))
result.Pending = removeIdentity(result.Pending, item.Filename)
return nil return nil
case AssetConflicting: case AssetConflicting:
result.State = Conflict result.State = Conflict
result.Pending = removeIdentity(result.Pending, item.Filename)
result.Conflicting = append(result.Conflicting, identity(item)) result.Conflicting = append(result.Conflicting, identity(item))
return &forge.Failure{Kind: forge.FailureConflict, Err: uploadErr} return &forge.Failure{Kind: forge.FailureConflict, Err: uploadErr}
case AssetMissing: case AssetMissing:
result.Pending = removeIdentity(result.Pending, item.Filename)
if evidence.Disposition == forge.WriteConflict { if evidence.Disposition == forge.WriteConflict {
result.State = Conflict result.State = Conflict
result.Conflicting = append(result.Conflicting, identity(item)) result.Conflicting = append(result.Conflicting, identity(item))
@@ -213,7 +221,6 @@ func reconcileAssetUpload(ctx context.Context, backend forge.Backend, releaseHan
return &forge.Failure{Kind: forge.FailureUnknown, Err: uploadErr} return &forge.Failure{Kind: forge.FailureUnknown, Err: uploadErr}
default: default:
result.State = Unknown result.State = Unknown
result.Pending = removeIdentity(result.Pending, item.Filename)
result.Uncertain = append(result.Uncertain, identity(item)) result.Uncertain = append(result.Uncertain, identity(item))
return &forge.Failure{Kind: forge.FailureUnknown, Err: errors.Join(uploadErr, observation.Err)} return &forge.Failure{Kind: forge.FailureUnknown, Err: errors.Join(uploadErr, observation.Err)}
} }
@@ -251,12 +258,3 @@ func orderedExpected(observations []AssetInspection) []artifact.ExpectedAsset {
func identity(asset artifact.ExpectedAsset) AssetIdentity { func identity(asset artifact.ExpectedAsset) AssetIdentity {
return AssetIdentity{Filename: asset.Filename, SHA256: asset.SHA256} return AssetIdentity{Filename: asset.Filename, SHA256: asset.SHA256}
} }
func removeIdentity(identities []AssetIdentity, filename string) []AssetIdentity {
for index, item := range identities {
if item.Filename == filename {
return append(identities[:index], identities[index+1:]...)
}
}
return identities
}

View File

@@ -276,6 +276,38 @@ func TestUploadMissingAssetsPreservesAcknowledgedProgressOnDefinitiveFailure(t *
} }
} }
func TestUploadMissingAssetsRetainsPendingObservationOrderOnEarlyReturn(t *testing.T) {
assets := []artifact.ExpectedAsset{
expectedAsset("z.bin", []byte("z")),
expectedAsset("a.bin", []byte("a")),
expectedAsset("c.bin", []byte("c")),
expectedAsset("b.bin", []byte("b")),
}
for _, disposition := range []forge.WriteDisposition{forge.WriteRejected, forge.WriteUncertain} {
t.Run(string(disposition), func(t *testing.T) {
backend := &assetBackend{uploads: []assetUploadResult{
{evidence: forge.WriteEvidence{Attempted: true, Disposition: forge.WriteAccepted}},
{evidence: forge.WriteEvidence{Attempted: true, Disposition: disposition}, err: errors.New("upload stopped")},
}}
result, err := uploadMissingAssets(context.Background(), backend, "release", inspectionFor(assets, map[string]AssetState{
"z.bin": AssetMissing, "a.bin": AssetMissing, "c.bin": AssetMissing, "b.bin": AssetMissing,
}))
wantPending := []AssetIdentity{identity(assets[0]), identity(assets[2]), identity(assets[3])}
wantState := Partial
if disposition == forge.WriteUncertain {
wantPending = wantPending[:2]
wantState = Unknown
if !reflect.DeepEqual(result.Uncertain, []AssetIdentity{identity(assets[3])}) {
t.Fatalf("uncertain = %#v", result.Uncertain)
}
}
if err == nil || result.State != wantState || !reflect.DeepEqual(result.Pending, wantPending) || !reflect.DeepEqual(result.Uploaded, []AssetIdentity{identity(assets[1])}) {
t.Fatalf("result = %#v, error %v; want pending %#v", result, err, wantPending)
}
})
}
}
func TestUploadMissingAssetsBorrowsReaderAndClosesItAfterCall(t *testing.T) { func TestUploadMissingAssetsBorrowsReaderAndClosesItAfterCall(t *testing.T) {
filePath := t.TempDir() + "/asset.bin" filePath := t.TempDir() + "/asset.bin"
if err := os.WriteFile(filePath, []byte("asset"), 0o600); err != nil { if err := os.WriteFile(filePath, []byte("asset"), 0o600); err != nil {