diff --git a/buildscripts/rebrand-guard/compat-baseline.json b/buildscripts/rebrand-guard/compat-baseline.json index 33bd2b00c..e158977f3 100644 --- a/buildscripts/rebrand-guard/compat-baseline.json +++ b/buildscripts/rebrand-guard/compat-baseline.json @@ -934,6 +934,7 @@ "arn:minio:kms:::this-is-disregarded", "arn:minio:kms:::xyz-test-key", "arn:minio:replication:", + "arn:minio:replication::", "arn:minio:replication::8320b6d18f9032b4700f1f03b50d8d1853de8f22cab86931ee794e12f190852c:destinationbucket", "arn:minio:replication:::", "arn:minio:replication:::dest-bucket", diff --git a/cmd/bucket-replication-utils.go b/cmd/bucket-replication-utils.go index 8c0ca7041..be8f8665f 100644 --- a/cmd/bucket-replication-utils.go +++ b/cmd/bucket-replication-utils.go @@ -418,6 +418,9 @@ func getReplicationState(rinfos replicatedInfos, prevState ReplicationState, vID for _, rinfo := range rinfos.Targets { if rinfo.ResyncTimestamp != "" { + if rs.ResetStatusesMap == nil { + rs.ResetStatusesMap = make(map[string]string) + } rs.ResetStatusesMap[targetResetHeader(rinfo.Arn)] = rinfo.ResyncTimestamp } } diff --git a/cmd/bucket-replication.go b/cmd/bucket-replication.go index d9c97cd2b..7c781c2a3 100644 --- a/cmd/bucket-replication.go +++ b/cmd/bucket-replication.go @@ -425,6 +425,7 @@ func checkReplicateDelete(ctx context.Context, bucket string, dobj ObjectToDelet // the mere presence or absence of the target version. func replicateDelete(ctx context.Context, dobj DeletedObjectReplicationInfo, objectAPI ObjectLayer) replicatedInfos { var replicationStatus replication.StatusType + isPurge := dobj.isVersionPurge() bucket := dobj.Bucket versionID := dobj.DeleteMarkerVersionID if versionID == "" { @@ -484,6 +485,7 @@ func replicateDelete(ctx context.Context, dobj DeletedObjectReplicationInfo, obj lk := objectAPI.NewNSLock(bucket, "/[replicate]/"+dobj.ObjectName) lkctx, err := lk.GetLock(ctx, globalOperationTimeout) if err != nil { + dobj.RetryCount++ globalReplicationPool.Get().queueMRFSave(dobj.ToMRFEntry()) sendEvent(eventArgs{ BucketName: bucket, @@ -546,25 +548,38 @@ func replicateDelete(ctx context.Context, dobj DeletedObjectReplicationInfo, obj replicationStatus = rinfos.ReplicationStatus() prevStatus := dobj.DeleteMarkerReplicationStatus() - if dobj.VersionID != "" { - prevStatus = replication.StatusType(dobj.VersionPurgeStatus()) - replicationStatus = replication.StatusType(rinfos.VersionPurgeStatus()) + if isPurge { + prevStatus = purgeReplicationStatus(dobj.VersionPurgeStatus()) + replicationStatus = purgeReplicationStatus(rinfos.VersionPurgeStatus()) } // to decrement pending count later. for _, rinfo := range rinfos.Targets { - if rinfo.ReplicationStatus != rinfo.PrevReplicationStatus { - globalReplicationStats.Load().Update(dobj.Bucket, rinfo, replicationStatus, - prevStatus) + status, previous := rinfo.ReplicationStatus, rinfo.PrevReplicationStatus + if isPurge { + status = purgeReplicationStatus(rinfo.VersionPurgeStatus) + previous = purgeReplicationStatus(dobj.ReplicationState.PurgeTargets[rinfo.Arn]) + } + if status != previous { + globalReplicationStats.Load().Update(dobj.Bucket, rinfo, status, previous) } } eventName := event.ObjectReplicationComplete if replicationStatus == replication.Failed { eventName = event.ObjectReplicationFailed + dobj.RetryCount++ globalReplicationPool.Get().queueMRFSave(dobj.ToMRFEntry()) } drs := getReplicationState(rinfos, dobj.ReplicationState, dobj.VersionID) + if isPurge { + // A purge must not rewrite the marker's creation/replica metadata. + // Multiple serialized empty target statuses can parse as nonempty, + // so explicitly send an empty creation update to the metadata writer. + drs.ReplicationStatusInternal = "" + drs.Targets = nil + drs.ReplicaStatus = "" + } if replicationStatus != prevStatus { drs.ReplicationTimeStamp = UTCNow() } @@ -606,6 +621,7 @@ func replicateDelete(ctx context.Context, dobj DeletedObjectReplicationInfo, obj } func replicateDeleteToTarget(ctx context.Context, dobj DeletedObjectReplicationInfo, tgt *TargetClient) (rinfo replicatedTargetInfo) { + isPurge := dobj.isVersionPurge() versionID := dobj.DeleteMarkerVersionID if versionID == "" { versionID = dobj.VersionID @@ -615,42 +631,50 @@ func replicateDeleteToTarget(ctx context.Context, dobj DeletedObjectReplicationI rinfo.OpType = dobj.OpType rinfo.endpoint = tgt.EndpointURL().Host rinfo.secure = tgt.EndpointURL().Scheme == "https" + // A purge leaves ReplicationStatus empty: the metadata writer interprets + // that as preserving the entire creation-status block, including targets + // outside this fan-out. Only VersionPurgeStatus records the purge outcome. defer func() { - if rinfo.ReplicationStatus == replication.Completed && tgt.ResetID != "" && dobj.OpType == replication.ExistingObjectReplicationType { + completed := rinfo.ReplicationStatus == replication.Completed + if isPurge { + completed = rinfo.VersionPurgeStatus == replication.VersionPurgeComplete + } + if completed && tgt.ResetID != "" && dobj.OpType == replication.ExistingObjectReplicationType { rinfo.ResyncTimestamp = fmt.Sprintf("%s;%s", UTCNow().Format(http.TimeFormat), tgt.ResetID) } }() - if dobj.VersionID == "" && rinfo.PrevReplicationStatus == replication.Completed && dobj.OpType != replication.ExistingObjectReplicationType { + if !isPurge && rinfo.PrevReplicationStatus == replication.Completed && dobj.OpType != replication.ExistingObjectReplicationType { rinfo.ReplicationStatus = rinfo.PrevReplicationStatus return rinfo } - if dobj.VersionID != "" && rinfo.VersionPurgeStatus == replication.VersionPurgeComplete { + if isPurge && rinfo.VersionPurgeStatus == replication.VersionPurgeComplete { return rinfo } if globalBucketTargetSys.isOffline(tgt.EndpointURL()) { - replLogOnceIf(ctx, fmt.Errorf("remote target is offline for bucket:%s arn:%s", dobj.Bucket, tgt.ARN), "replication-target-offline-delete-"+tgt.ARN) + rinfo.Err = fmt.Errorf("remote target is offline for bucket:%s arn:%s", dobj.Bucket, tgt.ARN) + replLogOnceIf(ctx, rinfo.Err, "replication-target-offline-delete-"+tgt.ARN) sendEvent(eventArgs{ BucketName: dobj.Bucket, Object: ObjectInfo{ Bucket: dobj.Bucket, Name: dobj.ObjectName, - VersionID: dobj.VersionID, + VersionID: versionID, DeleteMarker: dobj.DeleteMarker, }, UserAgent: "Internal: [Replication]", Host: globalLocalNodeName, EventName: event.ObjectReplicationNotTracked, }) - if dobj.VersionID == "" { - rinfo.ReplicationStatus = replication.Failed - } else { + if isPurge { rinfo.VersionPurgeStatus = replication.VersionPurgeFailed + } else { + rinfo.ReplicationStatus = replication.Failed } return rinfo } // early return if already replicated delete marker for existing object replication/ healing delete markers - if dobj.DeleteMarkerVersionID != "" { + if !isPurge && dobj.DeleteMarkerVersionID != "" { toi, err := tgt.StatObject(ctx, tgt.Bucket, dobj.ObjectName, minio.StatObjectOptions{ VersionID: versionID, Internal: minio.AdvancedGetOptions{ @@ -662,16 +686,10 @@ func replicateDeleteToTarget(ctx context.Context, dobj DeletedObjectReplicationI switch { case isErrMethodNotAllowed(serr): // delete marker already replicated - if dobj.VersionID == "" && rinfo.VersionPurgeStatus.Empty() { - rinfo.ReplicationStatus = replication.Completed - return rinfo - } + rinfo.ReplicationStatus = replication.Completed + return rinfo case isErrObjectNotFound(serr), isErrVersionNotFound(serr): - // version being purged is already not found on target. - if !rinfo.VersionPurgeStatus.Empty() { - rinfo.VersionPurgeStatus = replication.VersionPurgeComplete - return rinfo - } + // The marker still needs to be created on the target. case isErrReadQuorum(serr), isErrWriteQuorum(serr): // destination has some quorum issues, perform removeObject() anyways // to complete the operation. @@ -691,7 +709,7 @@ func replicateDeleteToTarget(ctx context.Context, dobj DeletedObjectReplicationI rmErr := tgt.RemoveObject(ctx, tgt.Bucket, dobj.ObjectName, minio.RemoveObjectOptions{ VersionID: versionID, Internal: minio.AdvancedRemoveOptions{ - ReplicationDeleteMarker: dobj.DeleteMarkerVersionID != "", + ReplicationDeleteMarker: !isPurge && dobj.DeleteMarkerVersionID != "", ReplicationMTime: dobj.DeleteMarkerMTime.Time, ReplicationStatus: minio.ReplicationStatusReplica, ReplicationRequest: true, // always set this to distinguish between `mc mirror` replication and serverside @@ -699,20 +717,20 @@ func replicateDeleteToTarget(ctx context.Context, dobj DeletedObjectReplicationI }) if rmErr != nil { rinfo.Err = rmErr - if dobj.VersionID == "" { - rinfo.ReplicationStatus = replication.Failed - } else { + if isPurge { rinfo.VersionPurgeStatus = replication.VersionPurgeFailed + } else { + rinfo.ReplicationStatus = replication.Failed } replLogIf(ctx, fmt.Errorf("unable to replicate delete marker to %s: %s/%s(%s): %w", tgt.EndpointURL(), tgt.Bucket, dobj.ObjectName, versionID, rmErr)) if rmErr != nil && minio.IsNetworkOrHostDown(rmErr, true) && !globalBucketTargetSys.isOffline(tgt.EndpointURL()) { globalBucketTargetSys.markOffline(tgt.EndpointURL()) } } else { - if dobj.VersionID == "" { - rinfo.ReplicationStatus = replication.Completed - } else { + if isPurge { rinfo.VersionPurgeStatus = replication.VersionPurgeComplete + } else { + rinfo.ReplicationStatus = replication.Completed } } return rinfo @@ -781,6 +799,18 @@ func (m caseInsensitiveMap) Lookup(key string) (string, bool) { return "", false } +// replicationTaggingTimestamp carries a recorded removal even when tags are +// empty. Only legacy nonempty tags use ModTime; absence is not a tombstone. +func replicationTaggingTimestamp(objInfo ObjectInfo) (time.Time, error) { + if stamp, ok := caseInsensitiveMap(objInfo.UserDefined).Lookup(ReservedMetadataPrefixLower + TaggingTimestamp); ok { + return time.Parse(time.RFC3339Nano, stamp) + } + if objInfo.UserTags != "" { + return objInfo.ModTime, nil + } + return time.Time{}, nil +} + func putReplicationOpts(ctx context.Context, sc string, objInfo ObjectInfo) (putOpts minio.PutObjectOptions, isMP bool, err error) { meta := make(map[string]string) isSSEC := crypto.SSEC.IsEncrypted(objInfo.UserDefined) @@ -850,17 +880,12 @@ func putReplicationOpts(ctx context.Context, sc string, objInfo ObjectInfo) (put tag, _ := tags.ParseObjectTags(objInfo.UserTags) if tag != nil { putOpts.UserTags = tag.ToMap() - // set tag timestamp in opts - tagTimestamp := objInfo.ModTime - if tagTmstampStr, ok := objInfo.UserDefined[ReservedMetadataPrefixLower+TaggingTimestamp]; ok { - tagTimestamp, err = time.Parse(time.RFC3339Nano, tagTmstampStr) - if err != nil { - return putOpts, false, err - } - } - putOpts.Internal.TaggingTimestamp = tagTimestamp } } + putOpts.Internal.TaggingTimestamp, err = replicationTaggingTimestamp(objInfo) + if err != nil { + return putOpts, false, err + } lkMap := caseInsensitiveMap(objInfo.UserDefined) if lang, ok := lkMap.Lookup(xhttp.ContentLanguage); ok { @@ -1003,6 +1028,12 @@ func getReplicationAction(oi1 ObjectInfo, oi2 minio.ObjectInfo, opType replicati if (oi2.UserTagCount > 0 && !reflect.DeepEqual(oi2Map, t.ToMap())) || (oi2.UserTagCount != len(t.ToMap())) { return replicateMetadata } + // HEAD does not report the tag revision. Equal values can hide a newer + // deletion or re-addition, so scheduled metadata/heal work must deliver it. + // Completed objects are still excluded by the existing scanner gates. + if _, ok := caseInsensitiveMap(oi1.UserDefined).Lookup(ReservedMetadataPrefixLower + TaggingTimestamp); ok { + return replicateMetadata + } // Compare only necessary headers compareKeys := []string{ @@ -1269,9 +1300,6 @@ func replicateObject(ctx context.Context, ri ReplicateObjectInfo, objectAPI Obje oi.UserDefined[targetResetHeader(rinfo.Arn)] = rinfo.ResyncTimestamp } } - if ri.UserTags != "" { - oi.UserDefined[xhttp.AmzObjectTagging] = ri.UserTags - } return dsc, nil }, } @@ -1689,14 +1717,11 @@ applyAction: if _, ok := lkMap.Lookup(xhttp.AmzObjectLockRetainUntilDate); ok { dstOpts.Internal.RetentionTimestamp = objInfo.ModTime } - if objInfo.UserTags != "" { - dstOpts.Internal.TaggingTimestamp = objInfo.ModTime - } - if tagTmStr, ok := lkMap.Lookup(ReservedMetadataPrefixLower + TaggingTimestamp); ok { - ondiskTimestamp, err := time.Parse(time.RFC3339, tagTmStr) - if err == nil { - dstOpts.Internal.TaggingTimestamp = ondiskTimestamp - } + dstOpts.Internal.TaggingTimestamp, rinfo.Err = replicationTaggingTimestamp(objInfo) + if rinfo.Err != nil { + rinfo.ReplicationStatus = replication.Failed + replLogIf(ctx, fmt.Errorf("invalid tagging timestamp for object %s/%s(%s): %w", bucket, object, objInfo.VersionID, rinfo.Err)) + return rinfo } if retTmStr, ok := lkMap.Lookup(ReservedMetadataPrefixLower + ObjectLockRetentionTimestamp); ok { ondiskTimestamp, err := time.Parse(time.RFC3339, retTmStr) @@ -1916,11 +1941,26 @@ func filterReplicationStatusMetadata(metadata map[string]string) map[string]stri // DeletedObjectReplicationInfo has info on deleted object type DeletedObjectReplicationInfo struct { DeletedObject - Bucket string - EventType string - OpType replication.Type - ResetID string - TargetArn string + Bucket string + EventType string + OpType replication.Type + ResetID string + TargetArn string + RetryCount int +} + +// isVersionPurge also recognizes the old marker-shaped purge task. Use the +// operation's state, rather than one target's possibly missing purge entry. +func (di DeletedObjectReplicationInfo) isVersionPurge() bool { + return di.VersionID != "" || di.DeleteMarkerVersionID != "" && !di.VersionPurgeStatus().Empty() +} + +// Purge metadata uses COMPLETE; operation statistics and audit use COMPLETED. +func purgeReplicationStatus(status VersionPurgeStatusType) replication.StatusType { + if replication.StatusType(status) == replication.CompletedLegacy { + return replication.Completed + } + return replication.StatusType(status) } // ToMRFEntry returns the relevant info needed by MRF @@ -1930,9 +1970,10 @@ func (di DeletedObjectReplicationInfo) ToMRFEntry() MRFReplicateEntry { versionID = di.VersionID } return MRFReplicateEntry{ - Bucket: di.Bucket, - Object: di.ObjectName, - versionID: versionID, + Bucket: di.Bucket, + Object: di.ObjectName, + versionID: versionID, + RetryCount: di.RetryCount, } } @@ -2424,6 +2465,7 @@ func (p *ReplicationPool) queueReplicaDeleteTask(doi DeletedObjectReplicationInf case <-p.ctx.Done(): case ch <- doi: default: + doi.RetryCount++ p.queueMRFSave(doi.ToMRFEntry()) p.mu.RLock() prio := p.priority @@ -3787,9 +3829,10 @@ func queueReplicationHeal(ctx context.Context, bucket string, oi ObjectInfo, rcf DeleteMarkerMTime: DeleteMarkerMTime{roi.ModTime}, DeleteMarker: roi.DeleteMarker, }, - Bucket: roi.Bucket, - OpType: replication.HealReplicationType, - EventType: ReplicateHealDelete, + Bucket: roi.Bucket, + OpType: replication.HealReplicationType, + EventType: ReplicateHealDelete, + RetryCount: retryCount, } // heal delete marker replication failure or versioned delete replication failure if roi.ReplicationStatus == replication.Pending || @@ -4072,7 +4115,12 @@ func (p *ReplicationPool) queueMRFHeal() error { VersionID: vID, }) cancel() - if err != nil { + // A versioned marker lookup returns its metadata with a 405. Only + // accept that error with a real, matching marker identity. + validMarker := isErrMethodNotAllowed(err) && oi.DeleteMarker && + vID != "" && oi.VersionID == vID && !oi.ModTime.IsZero() && + oi.Bucket == e.Bucket && oi.Name != "" && oi.Name == decodeDirObject(e.Object) + if err != nil && !validMarker || oi.Name == "" { continue } diff --git a/cmd/common-main.go b/cmd/common-main.go index 393ab17d0..c127759fc 100644 --- a/cmd/common-main.go +++ b/cmd/common-main.go @@ -445,6 +445,7 @@ func buildServerCtxt(ctx *cli.Context, ctxt *serverCtxt) (err error) { ctxt.SendBufSize = ctx.Int("send-buf-size") ctxt.RecvBufSize = ctx.Int("recv-buf-size") ctxt.IdleTimeout = ctx.Duration("idle-timeout") + ctxt.ReadHeaderTimeout = ctx.Duration("read-header-timeout") ctxt.UserTimeout = ctx.Duration("conn-user-timeout") if conf := ctx.String("config"); len(conf) > 0 { diff --git a/cmd/erasure-object.go b/cmd/erasure-object.go index da8b6fcc4..29c6011cd 100644 --- a/cmd/erasure-object.go +++ b/cmd/erasure-object.go @@ -2327,7 +2327,11 @@ func (er erasureObjects) PutObjectTags(ctx context.Context, bucket, object strin fi.Metadata[xhttp.AmzObjectTagging] = tags fi.ReplicationState = opts.PutReplicationState() + stamp := monotonicTaggingTimestamp(opts.UserDefined[ReservedMetadataPrefixLower+TaggingTimestamp], fi.Metadata[ReservedMetadataPrefixLower+TaggingTimestamp]) maps.Copy(fi.Metadata, opts.UserDefined) + if stamp != "" { + fi.Metadata[ReservedMetadataPrefixLower+TaggingTimestamp] = stamp + } if err = er.updateObjectMeta(ctx, bucket, object, fi, onlineDisks); err != nil { return ObjectInfo{}, toObjectErr(err, bucket, object) diff --git a/cmd/erasure-server-pool-consistency.go b/cmd/erasure-server-pool-consistency.go index f668db88e..b4e587516 100644 --- a/cmd/erasure-server-pool-consistency.go +++ b/cmd/erasure-server-pool-consistency.go @@ -242,6 +242,21 @@ func reconcileStoredObjectTags(metadata map[string]string, storedTags, storedTim } } +// Local tagging mutations must advance the revision they overwrite, even when +// a request's clock or lock acquisition order is behind the stored revision. +// Replica writes use reconcileStoredObjectTags instead of minting a revision. +func monotonicTaggingTimestamp(incoming, stored string) string { + requested, err := time.Parse(time.RFC3339Nano, incoming) + if err != nil { + return incoming + } + current, err := time.Parse(time.RFC3339Nano, stored) + if err != nil || requested.After(current) { + return incoming + } + return current.Add(time.Nanosecond).UTC().Format(time.RFC3339Nano) +} + // A restored version still owns its tier reference even while IsRemote is // false. Only the last copy of a reference may schedule its contents for GC. func sharesTierObject(oi ObjectInfo, copies []PoolObjInfo) bool { diff --git a/cmd/erasure-server-pool.go b/cmd/erasure-server-pool.go index f5f612c2a..555986bc0 100644 --- a/cmd/erasure-server-pool.go +++ b/cmd/erasure-server-pool.go @@ -3051,6 +3051,15 @@ func (z *erasureServerPools) PutObjectTags(ctx context.Context, bucket, object s if err != nil { return ObjectInfo{}, err } + // Ordinary reads and replication can return any owning pool. Persist one + // revision beyond all copies, so the returned value and every pool agree. + if stamp := opts.UserDefined[ReservedMetadataPrefixLower+TaggingTimestamp]; stamp != "" { + for _, copy := range copies { + stamp = monotonicTaggingTimestamp(stamp, copy.ObjInfo.UserDefined[ReservedMetadataPrefixLower+TaggingTimestamp]) + } + opts.UserDefined = cloneMSS(opts.UserDefined) + opts.UserDefined[ReservedMetadataPrefixLower+TaggingTimestamp] = stamp + } opts.NoLock = true opts.VersionID = copies[0].ObjInfo.VersionID if opts.VersionID == "" { diff --git a/cmd/object-handlers-common.go b/cmd/object-handlers-common.go index 438ba8d3a..c7b4e9b94 100644 --- a/cmd/object-handlers-common.go +++ b/cmd/object-handlers-common.go @@ -240,7 +240,10 @@ func checkPreconditionsPUT(ctx context.Context, w http.ResponseWriter, r *http.R // updated. The predicate is the incoming request's restored SSE-C metadata, // not what the destination happens to hold. ssecReplica := isReplicaTrusted(r.Context()) && crypto.SSEC.IsEncrypted(opts.UserDefined) - if etagMatch && vidMatch && !ssecReplica { + // Matching content does not imply that its tag revision was delivered. + // Keep client preconditions above; relax only the internal duplicate check. + newerTags := isReplicaTrusted(r.Context()) && olderThan(objInfo.UserDefined[ReservedMetadataPrefixLower+TaggingTimestamp], opts.ReplicationSourceTaggingTimestamp) + if etagMatch && vidMatch && !ssecReplica && !newerTags { writeHeaders() writeErrorResponse(ctx, w, errorCodes.ToAPIErr(ErrPreconditionFailed), r.URL) return true diff --git a/cmd/object-handlers.go b/cmd/object-handlers.go index a06910684..fa44129af 100644 --- a/cmd/object-handlers.go +++ b/cmd/object-handlers.go @@ -1797,6 +1797,7 @@ func (api objectAPIHandlers) CopyObjectHandler(w http.ResponseWriter, r *http.Re // source timestamp is newer than the stored one, and a stale update must // leave the stored state in place instead of erasing it. storedLock := storedObjectLockState(srcInfo.UserDefined) + storedTagTimestamp := srcInfo.UserDefined[ReservedMetadataPrefixLower+TaggingTimestamp] srcInfo.UserDefined, err = getCpObjMetadataFromHeader(ctx, r, srcInfo.UserDefined, allowReplicationMetadata) if err != nil { @@ -1814,23 +1815,29 @@ func (api objectAPIHandlers) CopyObjectHandler(w http.ResponseWriter, r *http.Re } } - if objTags != "" { - lastTaggingTimestamp := srcInfo.UserDefined[ReservedMetadataPrefixLower+TaggingTimestamp] - if dstOpts.ReplicationRequest { - srcTimestamp := dstOpts.ReplicationSourceTaggingTimestamp - if !srcTimestamp.IsZero() { - ondiskTimestamp, err := time.Parse(time.RFC3339Nano, lastTaggingTimestamp) - // update tagging metadata only if replica timestamp is newer than what's on disk - if err != nil || (err == nil && !ondiskTimestamp.After(srcTimestamp)) { - srcInfo.UserDefined[ReservedMetadataPrefixLower+TaggingTimestamp] = srcTimestamp.UTC().Format(time.RFC3339Nano) - srcInfo.UserDefined[xhttp.AmzObjectTagging] = objTags - } - } - } else { + if dstOpts.ReplicationRequest { + srcTimestamp := dstOpts.ReplicationSourceTaggingTimestamp + if !srcTimestamp.IsZero() { + // An empty value with a timestamp is an ordered deletion. Recheck + // the captured state even if metadata REPLACE rebuilt the map. + srcInfo.UserDefined[ReservedMetadataPrefixLower+TaggingTimestamp] = srcTimestamp.UTC().Format(time.RFC3339Nano) srcInfo.UserDefined[xhttp.AmzObjectTagging] = objTags - srcInfo.UserDefined[ReservedMetadataPrefixLower+TaggingTimestamp] = UTCNow().Format(time.RFC3339Nano) + reconcileStoredObjectTags(srcInfo.UserDefined, srcInfo.UserTags, storedTagTimestamp) + } else { + srcInfo.UserDefined[xhttp.AmzObjectTagging] = srcInfo.UserTags + if storedTagTimestamp != "" { + srcInfo.UserDefined[ReservedMetadataPrefixLower+TaggingTimestamp] = storedTagTimestamp + } else { + delete(srcInfo.UserDefined, ReservedMetadataPrefixLower+TaggingTimestamp) + } } + } else { + srcInfo.UserDefined[xhttp.AmzObjectTagging] = objTags + srcInfo.UserDefined[ReservedMetadataPrefixLower+TaggingTimestamp] = UTCNow().Format(time.RFC3339Nano) } + // SSE-C rotation snapshots reserved metadata before the tag decision. Its + // later merge must not put the old timestamp back over the accepted state. + delete(encMetadata, ReservedMetadataPrefixLower+TaggingTimestamp) srcInfo.UserDefined = filterReplicationStatusMetadata(srcInfo.UserDefined) srcInfo.UserDefined = objectlock.FilterObjectLockMetadata(srcInfo.UserDefined, true, true) @@ -2313,6 +2320,9 @@ func (api objectAPIHandlers) PutObjectHandler(w http.ResponseWriter, r *http.Req writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL) return } + if opts.ReplicationRequest && !opts.ReplicationSourceTaggingTimestamp.IsZero() { + metadata[ReservedMetadataPrefixLower+TaggingTimestamp] = opts.ReplicationSourceTaggingTimestamp.UTC().Format(time.RFC3339Nano) + } actualSize := size var idxCb func() []byte @@ -3760,11 +3770,11 @@ func (api objectAPIHandlers) PutObjectTaggingHandler(w http.ResponseWriter, r *h } dsc := mustReplicate(ctx, bucket, object, getMustReplicateOptions(objInfo.UserDefined, tagsStr, objInfo.ReplicationStatus, replication.MetadataReplicationType, opts)) + stamp := UTCNow().Format(time.RFC3339Nano) + opts.UserDefined = map[string]string{ReservedMetadataPrefixLower + TaggingTimestamp: stamp} if dsc.ReplicateAny() { - opts.UserDefined = make(map[string]string) - opts.UserDefined[ReservedMetadataPrefixLower+ReplicationTimestamp] = UTCNow().Format(time.RFC3339Nano) + opts.UserDefined[ReservedMetadataPrefixLower+ReplicationTimestamp] = stamp opts.UserDefined[ReservedMetadataPrefixLower+ReplicationStatus] = dsc.PendingStatus() - opts.UserDefined[ReservedMetadataPrefixLower+TaggingTimestamp] = UTCNow().Format(time.RFC3339Nano) } // Put object tags @@ -3863,9 +3873,10 @@ func (api objectAPIHandlers) DeleteObjectTaggingHandler(w http.ResponseWriter, r } dsc := mustReplicate(ctx, bucket, object, oi.getMustReplicateOptions(replication.MetadataReplicationType, opts)) + stamp := UTCNow().Format(time.RFC3339Nano) + opts.UserDefined = map[string]string{ReservedMetadataPrefixLower + TaggingTimestamp: stamp} if dsc.ReplicateAny() { - opts.UserDefined = make(map[string]string) - opts.UserDefined[ReservedMetadataPrefixLower+ReplicationTimestamp] = UTCNow().Format(time.RFC3339Nano) + opts.UserDefined[ReservedMetadataPrefixLower+ReplicationTimestamp] = stamp opts.UserDefined[ReservedMetadataPrefixLower+ReplicationStatus] = dsc.PendingStatus() } diff --git a/cmd/object-multipart-handlers.go b/cmd/object-multipart-handlers.go index 44f61f353..e56630005 100644 --- a/cmd/object-multipart-handlers.go +++ b/cmd/object-multipart-handlers.go @@ -312,6 +312,10 @@ func (api objectAPIHandlers) NewMultipartUploadHandler(w http.ResponseWriter, r writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL) return } + // Completion orders the upload's persisted tag state under the object lock. + if opts.ReplicationRequest && !opts.ReplicationSourceTaggingTimestamp.IsZero() { + metadata[ReservedMetadataPrefixLower+TaggingTimestamp] = opts.ReplicationSourceTaggingTimestamp.UTC().Format(time.RFC3339Nano) + } if r.Header.Get(xhttp.IfMatch) != "" { opts.HasIfMatch = true diff --git a/cmd/replication-delete-marker_test.go b/cmd/replication-delete-marker_test.go index e1ac57e6f..874db6eb3 100644 --- a/cmd/replication-delete-marker_test.go +++ b/cmd/replication-delete-marker_test.go @@ -106,6 +106,7 @@ func TestReplicateDeleteMarkerTargetSemantics(t *testing.T) { func testReplicateDeleteMarkerPurge(obj ObjectLayer, instanceType, bucket string, router http.Handler, creds auth.Credentials, t *testing.T, legacy bool) { ctx := t.Context() + defer replicationTestCapacity(obj)() const arn = "arn:minio:replication::af470089-d354-4473-934c-9e1f52f6da89:bucket" const name = "marker" version := mustGetUUID() @@ -208,27 +209,12 @@ func testReplicateDeleteMarkerPurge(obj ObjectLayer, instanceType, bucket string t.Errorf("purge scheduled as marker creation: version=%q marker=%q", deletion.VersionID, deletion.DeleteMarkerVersionID) } if legacy { - // Reproduce the old producer's state and let the existing scanner/heal - // path recover it. Upgrades must also finish purges already left pending. + // Old task shapes must complete directly; scanner/MRF recovery is + // covered separately by TestReplicationMRFMarkerRecovery. deletion.VersionID, deletion.DeleteMarkerVersionID = "", version - replicateDelete(ctx, deletion, obj) - oi, _ := obj.GetObjectInfo(ctx, bucket, name, ObjectOptions{VersionID: version, Versioned: true}) - if oi.VersionPurgeStatus != replication.VersionPurgePending { - t.Fatalf("legacy source purge = %s, want PENDING", oi.VersionPurgeStatus) - } - targets, err := globalBucketTargetSys.ListBucketTargets(ctx, bucket) - if err != nil { - t.Fatal(err) - } - queueReplicationHeal(ctx, bucket, oi, replicationConfig{Config: &cfg, remotes: targets}, 0) - select { - case op := <-worker: - deletion = op.(DeletedObjectReplicationInfo) - case <-time.After(time.Second): - t.Fatal("legacy pending purge was not scheduled for healing") - } } - result := replicateDelete(context.Background(), deletion, obj) + + result := replicateDelete(context.Background(), deletion, markerPurgeUpdateLayer{ObjectLayer: obj, t: t}) if result.VersionPurgeStatus() != replication.VersionPurgeComplete { t.Errorf("remote purge result = %s, want COMPLETE", result.VersionPurgeStatus()) } diff --git a/cmd/replication-delete-mrf_test.go b/cmd/replication-delete-mrf_test.go new file mode 100644 index 000000000..951347665 --- /dev/null +++ b/cmd/replication-delete-mrf_test.go @@ -0,0 +1,628 @@ +// Copyright (c) 2026 Feng Ruohang +// +// This file is part of Silo Object Storage stack +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package cmd + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/minio/madmin-go/v3" + "github.com/minio/minio-go/v7" + "github.com/minio/minio/internal/auth" + "github.com/minio/minio/internal/bucket/replication" + xhttp "github.com/minio/minio/internal/http" + "github.com/minio/minio/internal/logger" + loghttp "github.com/minio/minio/internal/logger/target/http" + "github.com/minio/minio/internal/once" + xnet "github.com/pgsty/silo-pkg/v3/net" +) + +type markerRecoveryCase struct { + name string + legacy, creation, lostReply, partial, exhaust, lockFirst, invalidMRF, replicaSource, offline, unrecordedPurge bool + targets int +} + +func TestReplicationMRFMarkerRecovery(t *testing.T) { + for _, tc := range []markerRecoveryCase{ + {name: "canonical", targets: 1}, + {name: "lock-failure", lockFirst: true, targets: 1}, + {name: "invalid-MRF-metadata", invalidMRF: true, targets: 1}, + {name: "legacy", legacy: true, targets: 1}, + {name: "unrecorded-purge", unrecordedPurge: true, targets: 2}, + {name: "two-targets", legacy: true, targets: 2}, + {name: "two-targets-offline", offline: true, targets: 2}, + {name: "replica-source", replicaSource: true, targets: 1}, + {name: "lost-reply-canonical", lostReply: true, targets: 1}, + {name: "lost-reply-legacy", legacy: true, lostReply: true, targets: 1}, + {name: "creation", creation: true, targets: 1}, + {name: "partial-creation-block", partial: true, targets: 2}, + {name: "retry-budget-and-scanner", exhaust: true, targets: 1}, + } { + t.Run(tc.name, func(t *testing.T) { + ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{t: t, endpoints: []string{"DeleteObject"}, objAPITest: func(obj ObjectLayer, backend, bucket string, router http.Handler, creds auth.Credentials, t *testing.T) { + testReplicationMRFMarkerRecovery(t, obj, backend, bucket, router, creds, tc) + }}) + }) + } +} + +type markerRecoveryTarget struct { + arn, bucket string + client *minio.Client + reject, loseReply atomic.Bool + deletes atomic.Int32 +} + +// Only adapt host capacity accounting, using the existing real-disk adapter. +// Reads, object metadata, MRF files and writes all still use the fixture disks. +func replicationTestCapacity(obj ObjectLayer) func() { + var restore []func() + for _, pool := range obj.(*erasureServerPools).serverPools { + for _, set := range pool.sets { + original := set.getDisks + disks := append([]StorageAPI(nil), original()...) + for i, disk := range disks { + if disk != nil { + disks[i] = tagTestCapacityDisk{StorageAPI: disk} + } + } + set.getDisks = func() []StorageAPI { return disks } + restore = append(restore, func() { set.getDisks = original }) + } + } + return func() { + for _, fn := range restore { + fn() + } + } +} + +func testReplicationMRFMarkerRecovery(t *testing.T, obj ObjectLayer, backend, bucket string, router http.Handler, creds auth.Credentials, tc markerRecoveryCase) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + defer replicationTestCapacity(obj)() + stats := NewReplicationStats(ctx, nil) + oldStats := globalReplicationStats.Swap(stats) + defer globalReplicationStats.Store(oldStats) + oldPool := globalReplicationPool + defer func() { globalReplicationPool = oldPool }() + const name = "marker" + version := mustGetUUID() + creationTime := UTCNow().Add(-time.Hour).Truncate(time.Second) + if _, err := globalBucketMetadataSys.Update(ctx, bucket, bucketVersioningConfig, enabledBucketVersioningConfig); err != nil { + t.Fatal(err) + } + if _, err := obj.PutObject(ctx, bucket, name, mustGetPutObjReader(t, bytes.NewReader([]byte("data")), 4, "", ""), ObjectOptions{Versioned: true}); err != nil { + t.Fatal(err) + } + var targets []*markerRecoveryTarget + cfg := replication.Config{} + creationStates := make(map[string]replication.StatusType) + var creationInternal string + for i := 0; i < tc.targets; i++ { + target := &markerRecoveryTarget{arn: "arn:minio:replication::" + mustGetUUID() + ":bucket", bucket: getRandomBucketName()} + if err := obj.MakeBucket(ctx, target.bucket, MakeBucketOptions{}); err != nil { + t.Fatal(err) + } + if _, err := obj.PutObject(ctx, target.bucket, name, mustGetPutObjReader(t, bytes.NewReader([]byte("data")), 4, "", ""), ObjectOptions{Versioned: true}); err != nil { + t.Fatal(err) + } + if !tc.creation { + opts := ObjectOptions{VersionID: version, Versioned: true, DeleteMarker: true, ReplicationRequest: true, MTime: creationTime} + opts.SetReplicaStatus(replication.Replica) + if _, err := obj.DeleteObject(ctx, target.bucket, name, opts); err != nil { + t.Fatal(err) + } + } + remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + opts := ObjectOptions{VersionID: r.URL.Query().Get("versionId"), Versioned: true} + switch r.Method { + case http.MethodHead: + oi, err := obj.GetObjectInfo(r.Context(), target.bucket, name, opts) + if oi.DeleteMarker { + w.Header().Set(xhttp.AmzDeleteMarker, "true") + w.Header().Set(xhttp.AmzVersionID, oi.VersionID) + } + if err != nil { + writeErrorResponseHeadersOnly(w, toAPIError(r.Context(), err)) + return + } + w.WriteHeader(http.StatusOK) + case http.MethodDelete: + target.deletes.Add(1) + if got := r.Header.Get(xhttp.MinIOSourceDeleteMarker) == "true"; got != tc.creation { + t.Errorf("purge/creation wire flag=%v creation=%v", got, tc.creation) + } + if opts.VersionID == "" { + t.Error("missing remote versionId") + } + if target.reject.Load() { + w.WriteHeader(http.StatusForbidden) + fmt.Fprint(w, `AccessDenied`) + return + } + opts.DeleteMarker = r.Header.Get(xhttp.MinIOSourceDeleteMarker) == "true" + opts.ReplicationRequest = true + opts.SetReplicaStatus(replication.Replica) + _, err := obj.DeleteObject(r.Context(), target.bucket, name, opts) + if err != nil && !isErrVersionNotFound(err) && !isErrObjectNotFound(err) { + writeErrorResponse(r.Context(), w, toAPIError(r.Context(), err), r.URL) + return + } + if target.loseReply.Swap(false) { + conn, _, err := w.(http.Hijacker).Hijack() + if err != nil { + t.Error(err) + return + } + conn.Close() + return + } + w.WriteHeader(http.StatusNoContent) + default: + t.Errorf("unexpected remote method %s", r.Method) + } + })) + defer remote.Close() + client, err := minio.New(strings.TrimPrefix(remote.URL, "http://"), &minio.Options{Region: "us-east-1", MaxRetries: 1}) + if err != nil { + t.Fatal(err) + } + target.client = client + globalBucketTargetSys.Lock() + globalBucketTargetSys.arnRemotesMap[target.arn] = arnTarget{Client: &TargetClient{Client: client, ARN: target.arn, Bucket: target.bucket}, lastRefresh: UTCNow()} + globalBucketTargetSys.targetsMap[bucket] = append(globalBucketTargetSys.targetsMap[bucket], madmin.BucketTarget{Arn: target.arn, TargetBucket: target.bucket}) + globalBucketTargetSys.Unlock() + globalBucketTargetSys.hMutex.Lock() + globalBucketTargetSys.hc[client.EndpointURL().Host] = epHealth{Online: true} + globalBucketTargetSys.hMutex.Unlock() + rule := configs[0].Rules[0] + rule.Priority = i + 1 + rule.Destination = replication.Destination{ARN: target.arn, Bucket: target.bucket} + cfg.Rules = append(cfg.Rules, rule) + creationStates[target.arn] = replication.Completed + creationInternal += target.arn + "=COMPLETED;" + targets = append(targets, target) + } + if tc.targets == 1 { + cfg.RoleArn = targets[0].arn + } + if !tc.creation { + opts := ObjectOptions{VersionID: version, Versioned: true, DeleteMarker: true, MTime: creationTime, DeleteReplication: ReplicationState{Targets: creationStates, ReplicationStatusInternal: creationInternal, ReplicationTimeStamp: creationTime}} + if tc.replicaSource { + opts.DeleteReplication = ReplicationState{} + opts.SetReplicaStatus(replication.Replica) + } + if _, err := obj.DeleteObject(ctx, bucket, name, opts); err != nil { + t.Fatal(err) + } + } + meta, err := globalBucketMetadataSys.Get(bucket) + if err != nil { + t.Fatal(err) + } + meta.replicationConfig = &cfg + globalBucketMetadataSys.Set(bucket, meta) + newPool := func() *ReplicationPool { + p := &ReplicationPool{ctx: ctx, objLayer: obj, workers: []chan ReplicationWorkerOperation{make(chan ReplicationWorkerOperation, 8)}, stats: stats, mrfSaveCh: make(chan MRFReplicateEntry, 8)} + globalReplicationPool = once.NewSingleton[ReplicationPool]() + globalReplicationPool.Set(p) + return p + } + p := newPool() + receive := func() DeletedObjectReplicationInfo { + select { + case op := <-p.workers[0]: + d, ok := op.(DeletedObjectReplicationInfo) + if !ok { + t.Fatalf("wrong queued operation %T", op) + } + return d + case <-time.After(3 * time.Second): + t.Fatal("no marker task from actual MRF/handler/scanner queue") + return DeletedObjectReplicationInfo{} + } + } + uri := "/" + bucket + "/" + name + if !tc.creation { + uri += "?versionId=" + version + } + req, err := newTestSignedRequestV4(http.MethodDelete, uri, 0, nil, creds.AccessKey, creds.SecretKey, nil) + if err != nil { + t.Fatal(err) + } + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != http.StatusNoContent { + t.Fatalf("source DELETE status %d: %s", w.Code, w.Body) + } + deletion := receive() + if tc.creation { + version = deletion.DeleteMarkerVersionID + } else if deletion.VersionID != version || deletion.DeleteMarkerVersionID != "" { + t.Fatalf("current producer emitted noncanonical purge: %+v", deletion) + } + if tc.unrecordedPurge { + // Exercise a task carrying purge state while the disk marker still has + // only creation metadata. Recreate only this isolated fixture marker. + if _, err := obj.DeleteObject(ctx, bucket, name, ObjectOptions{VersionID: version, Versioned: true}); err != nil { + t.Fatal(err) + } + opts := ObjectOptions{VersionID: version, Versioned: true, DeleteMarker: true, MTime: creationTime, DeleteReplication: ReplicationState{Targets: creationStates, ReplicationStatusInternal: creationInternal, ReplicationTimeStamp: creationTime}} + if _, err := obj.DeleteObject(ctx, bucket, name, opts); err != nil { + t.Fatal(err) + } + } + if tc.legacy { + deletion.VersionID, deletion.DeleteMarkerVersionID = "", version + } + if tc.partial { + deletion.TargetArn = targets[len(targets)-1].arn + } + if tc.invalidMRF { + testReplicationMRFInvalidLookups(ctx, t, obj, p, newPool, deletion) + return + } + var auditStatuses <-chan string + if tc.name == "canonical" { + var stopAudit func() + auditStatuses, stopAudit = replicationTestAudit(ctx, t, bucket) + defer stopAudit() + } + assertAudit := func(want string) { + if auditStatuses == nil { + return + } + select { + case got := <-auditStatuses: + if got != want { + t.Fatalf("replication audit status=%q want %q", got, want) + } + case <-time.After(3 * time.Second): + t.Fatal("missing replication audit event") + } + } + deletion.OpType = replication.HealReplicationType // Exercise operation-status statistics too. + failing := targets[len(targets)-1] + failing.reject.Store(!tc.lostReply) + failing.loseReply.Store(tc.lostReply) + if tc.offline { + globalBucketTargetSys.hMutex.Lock() + globalBucketTargetSys.hc[failing.client.EndpointURL().Host] = epHealth{Online: false} + globalBucketTargetSys.hMutex.Unlock() + } + before, _ := obj.GetObjectInfo(ctx, bucket, name, ObjectOptions{VersionID: version}) + beforeCreation := before.ReplicationStatusInternal + beforeStamp := before.UserDefined[ReservedMetadataPrefixLower+ReplicationTimestamp] + beforeReplica := before.UserDefined[ReservedMetadataPrefixLower+ReplicaStatus] + beforeReplicaStamp := before.UserDefined[ReservedMetadataPrefixLower+ReplicaTimestamp] + if !tc.creation && !tc.replicaSource && (len(replicationStatusesMap(beforeCreation)) != tc.targets || beforeStamp == "") { + t.Fatalf("missing seeded creation block: %+v", before) + } + if tc.replicaSource && (beforeReplica != "REPLICA" || beforeReplicaStamp == "") { + t.Fatalf("missing replica block: %+v", before) + } + updateObj := obj + if !tc.creation { + updateObj = markerPurgeUpdateLayer{ObjectLayer: obj, t: t} + } + rounds := 2 + if tc.lostReply { + rounds = 1 + } + if tc.exhaust { + rounds = mrfRetryLimit + 1 + } + for round := 1; round <= rounds; round++ { + callObj := updateObj + if tc.lockFirst && round == 1 { + callObj = markerLockFailureLayer{ObjectLayer: updateObj} + } + result := replicateDelete(ctx, deletion, callObj) + switch { + case tc.lockFirst && round == 1: + if len(result.Targets) != 0 { + t.Fatal("lock failure attempted a target") + } + case tc.creation: + if result.ReplicationStatus() != replication.Failed { + t.Fatalf("creation result: %+v", result) + } + case result.VersionPurgeStatus() != replication.VersionPurgeFailed: + t.Fatalf("purge failure result: %+v", result) + } + assertAudit("FAILED") + if round == 1 && !tc.lockFirst && !tc.creation { + stats.RLock() + failed := stats.Cache[bucket].Stats[failing.arn].FailStats.SinceUptime + stats.RUnlock() + if failed.Count != 1 || failed.Bytes != 0 { + t.Fatalf("purge failure stats=%+v, want count 1 and zero bytes", failed) + } + } + oi, err := obj.GetObjectInfo(ctx, bucket, name, ObjectOptions{VersionID: version}) + if !isErrMethodNotAllowed(err) || !oi.DeleteMarker { + t.Fatalf("source marker metadata/405 missing: %+v %v", oi, err) + } + if !tc.creation && (oi.ReplicationStatusInternal != beforeCreation || oi.UserDefined[ReservedMetadataPrefixLower+ReplicationTimestamp] != beforeStamp) { + t.Fatalf("purge rewrote creation block: before=%q/%q after=%q/%q", beforeCreation, beforeStamp, oi.ReplicationStatusInternal, oi.UserDefined[ReservedMetadataPrefixLower+ReplicationTimestamp]) + } + if !tc.creation && (oi.UserDefined[ReservedMetadataPrefixLower+ReplicaStatus] != beforeReplica || oi.UserDefined[ReservedMetadataPrefixLower+ReplicaTimestamp] != beforeReplicaStamp) { + t.Fatal("purge rewrote replica block") + } + if tc.targets == 2 && !tc.partial { + purges := versionPurgeStatusesMap(oi.VersionPurgeStatusInternal) + if purges[targets[0].arn] != replication.VersionPurgeComplete || purges[failing.arn] != replication.VersionPurgeFailed { + t.Fatalf("incorrect persisted target states: %v", purges) + } + if targets[0].deletes.Load() != 1 { + t.Fatalf("successful target resent %d times", targets[0].deletes.Load()) + } + } + if tc.partial { + select { + case <-p.mrfSaveCh: + default: + t.Fatal("partial failure missing MRF") + } + dsc := deletion.ReplicationState.ReplicateDecisionStr + deletion.ReplicationState = oi.ReplicationState() + deletion.ReplicationState.ReplicateDecisionStr = dsc + continue + } + if tc.exhaust && round > mrfRetryLimit { + if len(p.mrfSaveCh) != 0 || atomic.LoadUint64(&stats.mrfStats.TotalDroppedCount) != 1 { + t.Fatalf("retry budget not applied: queued=%d drops=%d", len(p.mrfSaveCh), stats.mrfStats.TotalDroppedCount) + } + break + } + var entry MRFReplicateEntry + select { + case entry = <-p.mrfSaveCh: + default: + t.Fatal("failure did not enter MRF") + } + if entry.RetryCount != round || entry.versionID != version { + t.Fatalf("MRF entry lost identity/budget: %+v, round=%d", entry, round) + } + p.saveMRFEntries(ctx, map[string]MRFReplicateEntry{entry.versionID: entry}) + record, err := p.loadMRF() + if err != nil || len(record.Entries) != 1 { + t.Fatalf("disk MRF missing: %+v %v", record, err) + } + if got := record.Entries[version]; got.RetryCount != round || got.Object != name || got.Bucket != bucket { + t.Fatalf("disk MRF mismatch: %+v", got) + } + p.saveMRFEntries(ctx, record.Entries) // loadMRF consumes the file; each replay uses a fresh disk record. + p = newPool() // no in-memory entries carried to the replacement pool. + if err := p.queueMRFHeal(); err != nil { + t.Fatal(err) + } + deletion = receive() + if deletion.RetryCount != round { + t.Fatalf("MRF retry count=%d want %d", deletion.RetryCount, round) + } + if !tc.creation && (deletion.VersionID != version || deletion.DeleteMarkerVersionID != "") { + t.Fatalf("MRF emitted wrong purge: %+v", deletion) + } + } + if tc.partial { + return + } + failing.reject.Store(false) + globalBucketTargetSys.hMutex.Lock() + globalBucketTargetSys.hc[failing.client.EndpointURL().Host] = epHealth{Online: true} + globalBucketTargetSys.hMutex.Unlock() + if tc.exhaust { + oi, _ := obj.GetObjectInfo(ctx, bucket, name, ObjectOptions{VersionID: version}) + QueueReplicationHeal(ctx, bucket, oi, 0) // Separately prove the existing scanner fallback after MRF exhaustion. + deletion = receive() + if deletion.RetryCount != 0 { + t.Fatal("scanner did not start a fresh retry budget") + } + } + result := replicateDelete(ctx, deletion, updateObj) + assertAudit("COMPLETED") + if tc.creation { + if result.ReplicationStatus() != replication.Completed { + t.Fatalf("creation recovery failed: %+v", result) + } + } else if result.VersionPurgeStatus() != replication.VersionPurgeComplete { + t.Fatalf("purge recovery failed: %+v", result) + } + for _, b := range append([]string{bucket}, func() []string { + var b []string + for _, target := range targets { + b = append(b, target.bucket) + } + return b + }()...) { + oi, err := obj.GetObjectInfo(ctx, b, name, ObjectOptions{VersionID: version}) + if tc.creation { + if !oi.DeleteMarker || !isErrMethodNotAllowed(err) { + t.Fatalf("creation missing in %s: %+v %v", b, oi, err) + } + } else if !isErrVersionNotFound(err) && !isErrObjectNotFound(err) { + t.Fatalf("purge left marker in %s: %+v %v", b, oi, err) + } + } + if !tc.creation { + // Re-deliver an ambiguous purge directly. An absent marker must stay absent. + duplicate := deletion + duplicate.VersionID, duplicate.DeleteMarkerVersionID = "", version + duplicate.ReplicationState.PurgeTargets = map[string]VersionPurgeStatusType{failing.arn: replication.VersionPurgePending} + duplicate.ReplicationState.VersionPurgeStatusInternal = "" + got := replicateDeleteToTarget(ctx, duplicate, &TargetClient{Client: failing.client, ARN: failing.arn, Bucket: failing.bucket}) + if got.VersionPurgeStatus != replication.VersionPurgeComplete { + t.Fatalf("duplicate purge: %+v", got) + } + if oi, err := obj.GetObjectInfo(ctx, failing.bucket, name, ObjectOptions{VersionID: version}); !isErrVersionNotFound(err) && !isErrObjectNotFound(err) { + t.Fatalf("duplicate recreated marker: %+v %v", oi, err) + } + } + if len(p.mrfSaveCh) != 0 || len(p.workers[0]) != 0 { + t.Fatalf("completion left queued work: mrf=%d worker=%d", len(p.mrfSaveCh), len(p.workers[0])) + } + if !tc.creation && !tc.exhaust && !tc.lostReply { + stats.RLock() + got := stats.Cache[bucket].Stats[failing.arn].ReplicatedCount + size := stats.Cache[bucket].Stats[failing.arn].ReplicatedSize + stats.RUnlock() + if got != 1 || size != 0 { + t.Fatalf("purge completion statistic=%d want 1", got) + } + } + t.Logf("%s: %s recovered; %d target(s), persisted MRF, source/target metadata checked", backend, tc.name, tc.targets) +} + +func TestReplicationDeleteQueueFullRetryBudget(t *testing.T) { + stats := NewReplicationStats(t.Context(), nil) + p := &ReplicationPool{ctx: t.Context(), objLayer: &replicationMRFTestObjectLayer{}, workers: []chan ReplicationWorkerOperation{make(chan ReplicationWorkerOperation)}, stats: stats, mrfSaveCh: make(chan MRFReplicateEntry, 1), priority: "slow"} + d := DeletedObjectReplicationInfo{Bucket: "bucket", DeletedObject: DeletedObject{ObjectName: "marker", VersionID: mustGetUUID()}, RetryCount: 2} + p.queueReplicaDeleteTask(d) + if got := <-p.mrfSaveCh; got.RetryCount != 3 { + t.Fatalf("queue-full retry count=%d", got.RetryCount) + } + d.RetryCount = mrfRetryLimit + p.queueReplicaDeleteTask(d) + if len(p.mrfSaveCh) != 0 || atomic.LoadUint64(&stats.mrfStats.TotalDroppedCount) != 1 { + t.Fatal("queue-full retry exceeded budget without a visible drop") + } +} + +type markerLockFailureLayer struct{ ObjectLayer } + +func (markerLockFailureLayer) NewNSLock(string, ...string) RWLocker { return markerFailedLock{} } + +type markerFailedLock struct{ RWLocker } + +func (markerFailedLock) GetLock(context.Context, *dynamicTimeout) (LockContext, error) { + return LockContext{}, context.DeadlineExceeded +} + +type markerLookupLayer struct { + ObjectLayer + mutate func(ObjectInfo, error) (ObjectInfo, error) + lookedUp chan struct{} +} + +func (l markerLookupLayer) GetObjectInfo(ctx context.Context, bucket, object string, opts ObjectOptions) (ObjectInfo, error) { + oi, err := l.ObjectLayer.GetObjectInfo(ctx, bucket, object, opts) + defer close(l.lookedUp) + return l.mutate(oi, err) +} + +func testReplicationMRFInvalidLookups(ctx context.Context, t *testing.T, obj ObjectLayer, p *ReplicationPool, newPool func() *ReplicationPool, d DeletedObjectReplicationInfo) { + for _, tc := range []struct { + name string + mutate func(ObjectInfo, error) (ObjectInfo, error) + }{ + {"empty-info", func(_ ObjectInfo, e error) (ObjectInfo, error) { return ObjectInfo{}, e }}, + {"not-a-marker", func(o ObjectInfo, e error) (ObjectInfo, error) { o.DeleteMarker = false; return o, e }}, + {"wrong-version", func(o ObjectInfo, e error) (ObjectInfo, error) { o.VersionID = mustGetUUID(); return o, e }}, + {"wrong-bucket", func(o ObjectInfo, e error) (ObjectInfo, error) { o.Bucket = "different-bucket"; return o, e }}, + {"wrong-object", func(o ObjectInfo, e error) (ObjectInfo, error) { o.Name = "different-object"; return o, e }}, + {"zero-modtime", func(o ObjectInfo, e error) (ObjectInfo, error) { o.ModTime = time.Time{}; return o, e }}, + {"missing", func(o ObjectInfo, _ error) (ObjectInfo, error) { + return o, ObjectNotFound{Bucket: o.Bucket, Object: o.Name} + }}, + {"read-error", func(o ObjectInfo, _ error) (ObjectInfo, error) { return o, InsufficientReadQuorum{} }}, + } { + t.Run(tc.name, func(t *testing.T) { + entry := d.ToMRFEntry() + p.saveMRFEntries(ctx, map[string]MRFReplicateEntry{entry.versionID: entry}) + fresh := newPool() + looked := make(chan struct{}) + fresh.objLayer = markerLookupLayer{ObjectLayer: obj, mutate: tc.mutate, lookedUp: looked} + if err := fresh.queueMRFHeal(); err != nil { + t.Fatal(err) + } + select { + case <-looked: + case <-time.After(3 * time.Second): + t.Fatal("disk MRF entry was not read") + } + select { + case op := <-fresh.workers[0]: + t.Fatalf("invalid lookup scheduled %T", op) + case <-time.After(100 * time.Millisecond): + } + }) + } +} + +// Capture the actual internal audit event through the supported webhook sink. +func replicationTestAudit(ctx context.Context, t *testing.T, bucket string) (<-chan string, func()) { + t.Helper() + if len(logger.AuditTargets()) != 0 { + t.Fatal("unexpected pre-existing test audit targets") + } + statuses := make(chan string, 16) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var entry struct { + API struct{ Name, Bucket, Status string } + } + if err := json.NewDecoder(r.Body).Decode(&entry); err != nil { + t.Error(err) + } else if entry.API.Name == ReplicateDeleteAPI && entry.API.Bucket == bucket { + statuses <- entry.API.Status + } + w.WriteHeader(http.StatusOK) + })) + endpoint, err := xnet.ParseHTTPURL(server.URL) + if err != nil { + t.Fatal(err) + } + if errs := logger.UpdateAuditWebhooks(ctx, map[string]loghttp.Config{"r6": {Enabled: true, Name: "r6", Endpoint: endpoint, BatchSize: 1, QueueSize: 128, MaxRetry: 1, RetryIntvl: time.Millisecond, HTTPTimeout: time.Second}}); len(errs) > 0 { + t.Fatal(errs) + } + targets := logger.AuditTargets() + return statuses, func() { + logger.UpdateAuditWebhooks(ctx, nil) + for _, target := range targets { + target.Cancel() + } + server.Close() + } +} + +type markerPurgeUpdateLayer struct { + ObjectLayer + t *testing.T +} + +func (l markerPurgeUpdateLayer) DeleteObject(ctx context.Context, bucket, object string, opts ObjectOptions) (ObjectInfo, error) { + l.t.Helper() + rs := opts.DeleteReplication + if rs.ReplicationStatusInternal != "" || rs.Targets != nil || rs.ReplicaStatus != "" || !rs.CompositeReplicationStatus().Empty() { + l.t.Fatalf("purge has a nonempty creation update: %+v", rs) + } + if rs.CompositeVersionPurgeStatus().Empty() { + l.t.Fatal("purge update lost its purge status") + } + return l.ObjectLayer.DeleteObject(ctx, bucket, object, opts) +} diff --git a/cmd/replication-delete-operation_test.go b/cmd/replication-delete-operation_test.go new file mode 100644 index 000000000..90ee24b54 --- /dev/null +++ b/cmd/replication-delete-operation_test.go @@ -0,0 +1,215 @@ +// Copyright (c) 2026 Feng Ruohang +// +// This file is part of Silo Object Storage stack +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package cmd + +import ( + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/minio/minio-go/v7" + "github.com/minio/minio/internal/bucket/replication" + xhttp "github.com/minio/minio/internal/http" +) + +// Purge uses the version-delete wire form regardless of the in-memory shape. +// Creation status, purge status and successful resync markers are independent. +func TestReplicateDeleteOperationExits(t *testing.T) { + for _, shape := range []string{"marker-creation", "legacy-marker-purge", "marker-purge", "object-purge"} { + purge := shape != "marker-creation" + for _, tc := range []struct { + name string + creation replication.StatusType + priorPurge VersionPurgeStatusType + headCode, deleteCode int + headError string + offline, resync bool + }{ + {name: "pending-success", creation: replication.Pending, priorPurge: replication.VersionPurgePending, headCode: 404, deleteCode: 204}, + {name: "completed-creation", creation: replication.Completed, priorPurge: replication.VersionPurgePending, headCode: 405, deleteCode: 204}, + {name: "existing-marker", creation: replication.Pending, priorPurge: replication.VersionPurgePending, headCode: 405, deleteCode: 204}, + // Use the quorum S3 code without 503, which is classified as backend-down first. + {name: "head-read-quorum", creation: replication.Pending, priorPurge: replication.VersionPurgePending, headCode: 400, headError: "SlowDownRead", deleteCode: 204}, + {name: "replica-creation-status", creation: replication.Replica, priorPurge: replication.VersionPurgeFailed, headCode: 405, deleteCode: 204}, + {name: "head-unavailable", creation: replication.Pending, priorPurge: replication.VersionPurgePending, headCode: 503, deleteCode: 204}, + {name: "head-forbidden", creation: replication.Pending, priorPurge: replication.VersionPurgePending, headCode: 403, deleteCode: 204}, + {name: "delete-forbidden", creation: replication.Pending, priorPurge: replication.VersionPurgePending, headCode: 404, deleteCode: 403}, + {name: "delete-method-rejected", creation: replication.Pending, priorPurge: replication.VersionPurgePending, headCode: 404, deleteCode: 405}, + {name: "delete-unavailable", creation: replication.Pending, priorPurge: replication.VersionPurgePending, headCode: 404, deleteCode: 503}, + {name: "offline", creation: replication.Pending, priorPurge: replication.VersionPurgePending, headCode: 404, deleteCode: 204, offline: true}, + {name: "retry-failed", creation: replication.Completed, priorPurge: replication.VersionPurgeFailed, headCode: 404, deleteCode: 204}, + {name: "purge-complete", creation: replication.Pending, priorPurge: replication.VersionPurgeComplete, headCode: 404, deleteCode: 403}, + {name: "resync-success", creation: replication.Completed, priorPurge: replication.VersionPurgePending, headCode: 405, deleteCode: 204, resync: true}, + {name: "resync-already-purged", creation: replication.Pending, priorPurge: replication.VersionPurgeComplete, headCode: 404, deleteCode: 403, resync: true}, + {name: "resync-failure", creation: replication.Completed, priorPurge: replication.VersionPurgePending, headCode: 404, deleteCode: 403, resync: true}, + } { + t.Run(shape+"/"+tc.name, func(t *testing.T) { + version := mustGetUUID() + var heads, deletes atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("versionId") != version { + t.Errorf("wrong request version: %s", r.URL) + } + switch r.Method { + case http.MethodHead: + heads.Add(1) + w.Header().Set(xhttp.AmzVersionID, version) + w.Header().Set(xhttp.LastModified, time.Now().UTC().Format(http.TimeFormat)) + if tc.headCode == 405 { + w.Header().Set(xhttp.AmzDeleteMarker, "true") + } + if tc.headError != "" { + w.Header().Set("x-minio-error-code", tc.headError) + } + w.WriteHeader(tc.headCode) + case http.MethodDelete: + deletes.Add(1) + if got := r.Header.Get(xhttp.MinIOSourceDeleteMarker) == "true"; got == purge { + t.Errorf("source delete-marker header=%v, purge=%v", got, purge) + } + w.WriteHeader(tc.deleteCode) + if tc.deleteCode != 204 { + fmt.Fprintf(w, `%sinjected rejection`, map[int]string{403: "AccessDenied", 405: "MethodNotAllowed", 503: "ServiceUnavailable"}[tc.deleteCode]) + } + default: + t.Errorf("unexpected method %s", r.Method) + } + })) + defer server.Close() + client, err := minio.New(strings.TrimPrefix(server.URL, "http://"), &minio.Options{Region: "us-east-1", MaxRetries: 1}) + if err != nil { + t.Fatal(err) + } + old := globalBucketTargetSys + globalBucketTargetSys = &BucketTargetSys{hc: map[string]epHealth{client.EndpointURL().Host: {Online: !tc.offline}}} + defer func() { globalBucketTargetSys = old }() + d := DeletedObjectReplicationInfo{Bucket: "source", DeletedObject: DeletedObject{ObjectName: "marker", DeleteMarker: shape != "object-purge"}} + if shape == "marker-creation" || shape == "legacy-marker-purge" { + d.DeleteMarkerVersionID = version + } else { + d.VersionID = version + } + d.ReplicationState.Targets = map[string]replication.StatusType{"arn1": tc.creation} + d.ReplicationState.ResetStatusesMap = map[string]string{"arn1": "previous-reset"} + if purge { + d.ReplicationState.PurgeTargets = map[string]VersionPurgeStatusType{"arn1": tc.priorPurge} + } + if tc.resync { + d.OpType = replication.ExistingObjectReplicationType + } + got := replicateDeleteToTarget(t.Context(), d, &TargetClient{Client: client, ARN: "arn1", Bucket: "target", ResetID: "current-reset"}) + var wantCreation replication.StatusType + var wantPurge VersionPurgeStatusType + var wantHeads, wantDeletes int32 + success := false + if purge { + wantCreation = "" + wantPurge = replication.VersionPurgeComplete + switch { + case tc.priorPurge == replication.VersionPurgeComplete: + success = true + case tc.offline: + wantPurge = replication.VersionPurgeFailed + default: + wantDeletes = 1 + success = tc.deleteCode == 204 + if !success { + wantPurge = replication.VersionPurgeFailed + } + } + } else { + switch { + case tc.creation == replication.Completed && !tc.resync: + success = true + case tc.offline: + default: + wantHeads = 1 + switch tc.headCode { + case 405: + success = true + case 403, 503: + default: + wantDeletes = 1 + success = tc.deleteCode == 204 + } + } + if success { + wantCreation = replication.Completed + } else { + wantCreation = replication.Failed + } + } + if got.PrevReplicationStatus != tc.creation { + t.Error("previous creation state changed") + } + if got.ReplicationStatus != wantCreation || got.VersionPurgeStatus != wantPurge { + t.Errorf("status=%+v, want creation=%s purge=%s", got, wantCreation, wantPurge) + } + if heads.Load() != wantHeads || deletes.Load() != wantDeletes { + t.Errorf("HEAD/DELETE=%d/%d, want %d/%d", heads.Load(), deletes.Load(), wantHeads, wantDeletes) + } + if (got.Err != nil) == success { + t.Errorf("success=%v, error=%v", success, got.Err) + } + if tc.resync && success { + if !strings.HasSuffix(got.ResyncTimestamp, ";current-reset") { + t.Errorf("successful resync missing reset: %q", got.ResyncTimestamp) + } + } else if got.ResyncTimestamp != "previous-reset" { + t.Errorf("unexpected reset: %q", got.ResyncTimestamp) + } + }) + } + } +} + +func TestReplicateDeletePurgeMissingTargetState(t *testing.T) { + // Classification belongs to the operation, even if this target has no + // previous purge entry (another target supplies the tracked purge state). + var deletes atomic.Int32 + version := mustGetUUID() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete || r.URL.Query().Get("versionId") != version || r.Header.Get(xhttp.MinIOSourceDeleteMarker) == "true" { + t.Errorf("wrong purge request: %s %s %v", r.Method, r.URL, r.Header) + } + deletes.Add(1) + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + client, err := minio.New(strings.TrimPrefix(server.URL, "http://"), &minio.Options{Region: "us-east-1", MaxRetries: 1}) + if err != nil { + t.Fatal(err) + } + old := globalBucketTargetSys + globalBucketTargetSys = &BucketTargetSys{hc: map[string]epHealth{client.EndpointURL().Host: {Online: true}}} + defer func() { globalBucketTargetSys = old }() + d := DeletedObjectReplicationInfo{Bucket: "source", DeletedObject: DeletedObject{ObjectName: "marker", DeleteMarker: true, DeleteMarkerVersionID: version, ReplicationState: ReplicationState{Targets: map[string]replication.StatusType{"arn1": replication.Completed}, PurgeTargets: map[string]VersionPurgeStatusType{"arn2": replication.VersionPurgePending}}}} + got := replicateDeleteToTarget(t.Context(), d, &TargetClient{Client: client, ARN: "arn1", Bucket: "target", ResetID: "reset"}) + if deletes.Load() != 1 || got.VersionPurgeStatus != replication.VersionPurgeComplete || got.ReplicationStatus != "" { + t.Fatalf("operation misclassified: %+v, deletes=%d", got, deletes.Load()) + } + got.ResyncTimestamp = "resync;reset" + state := getReplicationState(replicatedInfos{Targets: []replicatedTargetInfo{got}}, ReplicationState{}, "") + if state.ResetStatusesMap[targetResetHeader("arn1")] != got.ResyncTimestamp { + t.Fatal("resync timestamp not preserved with nil reset map") + } +} diff --git a/cmd/replication-tagging-order_test.go b/cmd/replication-tagging-order_test.go new file mode 100644 index 000000000..7baf3b7bf --- /dev/null +++ b/cmd/replication-tagging-order_test.go @@ -0,0 +1,599 @@ +// Copyright (c) 2026 Feng Ruohang +// +// This file is part of Silo Object Storage stack +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package cmd + +import ( + "context" + "encoding/xml" + "fmt" + "maps" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "github.com/minio/minio-go/v7" + "github.com/minio/minio/internal/auth" + "github.com/minio/minio/internal/bucket/replication" + xhttp "github.com/minio/minio/internal/http" + "github.com/minio/minio/internal/kms" +) + +const r5TagStamp = ReservedMetadataPrefixLower + TaggingTimestamp + +func r5Capacity(z *erasureServerPools) func() { + var restores []func() + for _, pool := range z.serverPools { + for _, set := range pool.sets { + old := set.getDisks + disks := append([]StorageAPI(nil), old()...) + for i := range disks { + disks[i] = tagTestCapacityDisk{StorageAPI: disks[i]} + } + set.getDisks = func() []StorageAPI { return disks } + restores = append(restores, func() { set.getDisks = old }) + } + } + return func() { + for _, restore := range restores { + restore() + } + } +} + +func r5Request(t *testing.T, router http.Handler, cred auth.Credentials, method, path, body string, headers map[string]string) *httptest.ResponseRecorder { + t.Helper() + r, err := newTestSignedRequestV4(method, path, int64(len(body)), strings.NewReader(body), cred.AccessKey, cred.SecretKey, headers) + if err != nil { + t.Fatal(err) + } + w := httptest.NewRecorder() + router.ServeHTTP(w, r) + return w +} + +func r5Stored(t *testing.T, obj interface { + GetObjectInfo(context.Context, string, string, ObjectOptions) (ObjectInfo, error) +}, bucket, name, vid, wantTags, wantStamp string, +) ObjectInfo { + t.Helper() + oi, err := obj.GetObjectInfo(t.Context(), bucket, name, ObjectOptions{VersionID: vid}) + if err != nil { + t.Fatal(err) + } + if oi.UserTags != wantTags || oi.UserDefined[r5TagStamp] != wantStamp { + t.Fatalf("%s(%s): tags=%q stamp=%q, want %q %q", name, vid, oi.UserTags, oi.UserDefined[r5TagStamp], wantTags, wantStamp) + } + return oi +} + +// The request bytes are signed and enter the real API and storage implementation. +// Equal/stale retransmits may retain the existing 412 duplicate response. +func r5Receive(t *testing.T, obj ObjectLayer, router http.Handler, cred auth.Credentials, bucket, operation string, source ObjectInfo, stamp string, afterInit func()) { + t.Helper() + opts, _, err := putReplicationOpts(t.Context(), "", source) + if err != nil { + t.Fatal(err) + } + if operation == "multipart" { + opts.Internal.SourceMTime = time.Time{} + } + headers := make(map[string]string) + for k, vs := range opts.Header() { + if len(vs) > 0 { + headers[k] = vs[0] + } + } + if stamp == "" { + delete(headers, http.CanonicalHeaderKey(xhttp.MinIOSourceTaggingTimestamp)) + delete(headers, xhttp.MinIOSourceTaggingTimestamp) + } else { + headers[http.CanonicalHeaderKey(xhttp.MinIOSourceTaggingTimestamp)] = stamp + } + path := "/" + bucket + "/" + source.Name + "?versionId=" + source.VersionID + var w *httptest.ResponseRecorder + switch operation { + case "copy", "copy-default": + maps.Copy(headers, getCopyObjMetadata(source, "")) + headers[xhttp.AmzCopySource] = "/" + bucket + "/" + source.Name + "?versionId=" + source.VersionID + if operation == "copy" { + headers[xhttp.AmzMetadataDirective] = "REPLACE" + } + w = r5Request(t, router, cred, http.MethodPut, path, "", headers) + case "put": + w = r5Request(t, router, cred, http.MethodPut, path, "data", headers) + case "multipart": + w = r5Request(t, router, cred, http.MethodPost, path+"&uploads", "", headers) + if w.Code == http.StatusPreconditionFailed { + return + } + if w.Code != http.StatusOK { + t.Fatalf("init: %d %s", w.Code, w.Body.String()) + } + var init struct { + UploadID string `xml:"UploadId"` + } + if err := xml.Unmarshal(w.Body.Bytes(), &init); err != nil || init.UploadID == "" { + t.Fatalf("init XML: %v %s", err, w.Body.String()) + } + mi, err := obj.GetMultipartInfo(t.Context(), bucket, source.Name, init.UploadID, ObjectOptions{}) + if err != nil { + t.Fatal(err) + } + if stamp != "" && mi.UserDefined[r5TagStamp] != stamp { + t.Fatalf("upload persisted stamp=%q, want %q", mi.UserDefined[r5TagStamp], stamp) + } + partPath := "/" + bucket + "/" + source.Name + "?uploadId=" + url.QueryEscape(init.UploadID) + ph := map[string]string{xhttp.MinIOSourceReplicationRequest: "true"} + part := r5Request(t, router, cred, http.MethodPut, partPath+"&partNumber=1", "data", ph) + if part.Code != http.StatusOK { + t.Fatalf("part: %d %s", part.Code, part.Body.String()) + } + if afterInit != nil { + afterInit() + } + body := "1" + canonicalizeETag(part.Header()[xhttp.ETag][0]) + "" + ph[xhttp.MinIOSourceMTime] = source.ModTime.Format(time.RFC3339Nano) + ph[xhttp.MinIOSourceETag] = source.ETag + w = r5Request(t, router, cred, http.MethodPost, partPath, body, ph) + } + if w.Code != http.StatusOK && w.Code != http.StatusPreconditionFailed { + t.Fatalf("%s: %d %s", operation, w.Code, w.Body.String()) + } +} + +func TestAPITaggingReplicationOrdering(t *testing.T) { r5APIOrdering(t, false) } +func TestAPITaggingReplicationOrderingKMS(t *testing.T) { r5APIOrdering(t, true) } +func r5APIOrdering(t *testing.T, encrypted bool) { + ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{t: t, objAPITest: func(obj ObjectLayer, instance, bucket string, router http.Handler, cred auth.Credentials, t *testing.T) { + defer r5Capacity(obj.(*erasureServerPools))() + if _, err := globalBucketMetadataSys.Update(t.Context(), bucket, bucketVersioningConfig, enabledBucketVersioningConfig); err != nil { + t.Fatal(err) + } + if encrypted { + prev := GlobalKMS + GlobalKMS = kms.NewStub("r5-tag-order") + defer func() { GlobalKMS = prev }() + sse := []byte(`aws:kmsr5-tag-order`) + if _, err := globalBucketMetadataSys.Update(t.Context(), bucket, bucketSSEConfig, sse); err != nil { + t.Fatal(err) + } + } + base := time.Now().UTC().Add(-5 * time.Hour) + for _, op := range []string{"copy", "copy-default", "put", "multipart"} { + for _, version := range []string{"uuid", "null"} { + t.Run(instance+"/"+op+"/"+version, func(t *testing.T) { + name := op + "-" + version + vid := mustGetUUID() + if version == "null" { + vid = nullVersionID + } + original, err := obj.PutObject(t.Context(), bucket, name, mustGetPutObjReader(t, strings.NewReader("data"), 4, "", ""), ObjectOptions{Versioned: true, VersionID: vid, UserDefined: map[string]string{xhttp.AmzObjectTagging: "key=original", r5TagStamp: base.Format(time.RFC3339Nano)}}) + if err != nil { + t.Fatal(err) + } + if op == "multipart" { + // The production sender uses multipart only for multipart + // sources. Preserve a real multipart ETag and part layout. + metadata := maps.Clone(original.UserDefined) + metadata[xhttp.AmzObjectTagging] = original.UserTags + mp, err := obj.NewMultipartUpload(t.Context(), bucket, name, ObjectOptions{Versioned: true, VersionID: vid, UserDefined: metadata}) + if err != nil { + t.Fatal(err) + } + part, err := obj.PutObjectPart(t.Context(), bucket, name, mp.UploadID, 1, mustGetPutObjReader(t, strings.NewReader("data"), 4, "", ""), ObjectOptions{}) + if err != nil { + t.Fatal(err) + } + original, err = obj.CompleteMultipartUpload(t.Context(), bucket, name, mp.UploadID, []CompletePart{{PartNumber: 1, ETag: part.ETag}}, ObjectOptions{Versioned: true}) + if err != nil { + t.Fatal(err) + } + } + // A later unrelated version must not contribute tags to an explicitly addressed UUID/null version. + latest, err := obj.PutObject(t.Context(), bucket, name, mustGetPutObjReader(t, strings.NewReader("other"), 5, "", ""), ObjectOptions{Versioned: true, UserDefined: map[string]string{xhttp.AmzObjectTagging: "key=latest", r5TagStamp: base.Add(10 * time.Hour).Format(time.RFC3339Nano)}}) + if err != nil { + t.Fatal(err) + } + for _, event := range []struct { + name, tags string + hours int + wantTags string + wantHours int + }{ + {"delete", "", 3, "", 3}, {"stale", "key=stale", 2, "", 3}, {"equal-conflict", "key=conflict", 3, "", 3}, {"newer", "key=new", 4, "key=new", 4}, + } { + t.Run(event.name, func(t *testing.T) { + source := original + source.VersionID = vid + source.UserDefined = maps.Clone(original.UserDefined) + source.UserTags = event.tags + stamp := base.Add(time.Duration(event.hours) * time.Hour).Format(time.RFC3339Nano) + source.UserDefined[r5TagStamp] = stamp + r5Receive(t, obj, router, cred, bucket, op, source, stamp, nil) + r5Stored(t, obj, bucket, name, vid, event.wantTags, base.Add(time.Duration(event.wantHours)*time.Hour).Format(time.RFC3339Nano)) + r5Stored(t, obj, bucket, name, latest.VersionID, "key=latest", base.Add(10*time.Hour).Format(time.RFC3339Nano)) + }) + } + source := original + source.VersionID = vid + source.UserTags = "key=unversioned-event" + r5Receive(t, obj, router, cred, bucket, op, source, "", nil) + r5Stored(t, obj, bucket, name, vid, "key=new", base.Add(4*time.Hour).Format(time.RFC3339Nano)) + get := r5Request(t, router, cred, http.MethodGet, "/"+bucket+"/"+name+"?versionId="+vid, "", nil) + if get.Code != http.StatusOK || get.Body.String() != "data" { + t.Fatalf("plaintext GET: %d %q", get.Code, get.Body.String()) + } + }) + } + } + }}) +} + +func TestAPITaggingMultipartCommitRechecksRevision(t *testing.T) { + ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{t: t, objAPITest: func(obj ObjectLayer, instance, bucket string, router http.Handler, cred auth.Credentials, t *testing.T) { + defer r5Capacity(obj.(*erasureServerPools))() + if _, err := globalBucketMetadataSys.Update(t.Context(), bucket, bucketVersioningConfig, enabledBucketVersioningConfig); err != nil { + t.Fatal(err) + } + base := time.Now().UTC().Add(-time.Hour) + source := ObjectInfo{Name: "commit-recheck", VersionID: mustGetUUID(), ModTime: base, UserTags: "key=incoming", UserDefined: map[string]string{r5TagStamp: base.Format(time.RFC3339Nano)}} + later := base.Add(time.Minute).Format(time.RFC3339Nano) + r5Receive(t, obj, router, cred, bucket, "multipart", source, base.Format(time.RFC3339Nano), func() { + _, err := obj.PutObject(t.Context(), bucket, source.Name, mustGetPutObjReader(t, strings.NewReader("data"), 4, "", ""), ObjectOptions{Versioned: true, VersionID: source.VersionID, MTime: base, UserDefined: map[string]string{r5TagStamp: later}}) + if err != nil { + t.Fatal(err) + } + }) + r5Stored(t, obj, bucket, source.Name, source.VersionID, "", later) + t.Logf("%s: deletion committed between initiation and completion survived", instance) + }}) +} + +func TestAPILocalTaggingAlwaysAdvancesRevision(t *testing.T) { + ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{t: t, objAPITest: func(obj ObjectLayer, instance, bucket string, router http.Handler, cred auth.Credentials, t *testing.T) { + defer r5Capacity(obj.(*erasureServerPools))() + if _, err := globalBucketMetadataSys.Update(t.Context(), bucket, bucketVersioningConfig, enabledBucketVersioningConfig); err != nil { + t.Fatal(err) + } + oi, err := obj.PutObject(t.Context(), bucket, "local-tags", mustGetPutObjReader(t, strings.NewReader("data"), 4, "", ""), ObjectOptions{Versioned: true}) + if err != nil { + t.Fatal(err) + } + path := "/" + bucket + "/local-tags?tagging&versionId=" + oi.VersionID + for n, method := range []string{http.MethodPut, http.MethodDelete, http.MethodDelete, http.MethodPut} { + body := "" + want := "" + status := http.StatusNoContent + if method == http.MethodPut { + status = http.StatusOK + body = "" + if n == 0 { + want = "key=local" + body = "keylocal" + } + } + before := time.Now().UTC() + w := r5Request(t, router, cred, method, path, body, nil) + if w.Code != status { + t.Fatalf("%s: %d %s", method, w.Code, w.Body.String()) + } + now, err := obj.GetObjectInfo(t.Context(), bucket, "local-tags", ObjectOptions{VersionID: oi.VersionID}) + if err != nil { + t.Fatal(err) + } + stamp, err := time.Parse(time.RFC3339Nano, now.UserDefined[r5TagStamp]) + if err != nil || stamp.Before(before) || now.UserTags != want { + t.Fatalf("local %s: tags=%q timestamp=%q err=%v", method, now.UserTags, now.UserDefined[r5TagStamp], err) + } + if !now.ModTime.Equal(oi.ModTime) { + t.Fatal("tagging changed the object's data modification time") + } + t.Logf("%s mutation %d persisted tags=%q timestamp=%s", instance, n, now.UserTags, stamp) + } + // Ordinary COPY with empty REPLACE has the same local-deletion semantics. + before := time.Now().UTC() + headers := map[string]string{xhttp.AmzCopySource: "/" + bucket + "/local-tags?versionId=" + oi.VersionID, xhttp.AmzMetadataDirective: "REPLACE", xhttp.AmzTagDirective: "REPLACE"} + w := r5Request(t, router, cred, http.MethodPut, "/"+bucket+"/copied-empty", "", headers) + if w.Code != http.StatusOK { + t.Fatalf("copy: %d %s", w.Code, w.Body.String()) + } + copied, err := obj.GetObjectInfo(t.Context(), bucket, "copied-empty", ObjectOptions{}) + if err != nil { + t.Fatal(err) + } + stamp, err := time.Parse(time.RFC3339Nano, copied.UserDefined[r5TagStamp]) + if err != nil || stamp.Before(before) || copied.UserTags != "" { + t.Fatalf("local COPY: %v %+v", err, copied) + } + }}) +} + +func TestTaggingTimestampWire(t *testing.T) { + now := time.Now().UTC() + for _, tag := range []string{"", "key=value"} { + for _, stamp := range []string{"", now.Format(time.RFC3339Nano), "invalid"} { + t.Run(fmt.Sprintf("%s/%s", tag, stamp), func(t *testing.T) { + source := ObjectInfo{ModTime: now.Add(-time.Hour), UserTags: tag, UserDefined: map[string]string{}} + if stamp != "" { + source.UserDefined[r5TagStamp] = stamp + } + opts, _, err := putReplicationOpts(t.Context(), "", source) + if stamp == "invalid" { + if err == nil { + t.Fatal("invalid timestamp accepted") + } + return + } + if err != nil { + t.Fatal(err) + } + want := stamp + if want == "" && tag != "" { + want = source.ModTime.Format(time.RFC3339Nano) + } + if got := opts.Header().Get(xhttp.MinIOSourceTaggingTimestamp); got != want { + t.Fatalf("wire timestamp=%q want %q", got, want) + } + }) + } + } +} + +func TestAPIPoolsTaggingReplicaDeletion(t *testing.T) { + z, bucket := consistencyPools(t) + defer r5Capacity(z)() + if err := newTestConfig(globalMinioDefaultRegion, z); err != nil { + t.Fatal(err) + } + if _, err := globalBucketMetadataSys.Update(t.Context(), bucket, bucketVersioningConfig, enabledBucketVersioningConfig); err != nil { + t.Fatal(err) + } + router := initTestAPIEndPoints(z, nil) + base := time.Now().UTC().Add(-5 * time.Hour) + for _, op := range []string{"copy", "copy-default", "put", "multipart"} { + for _, kind := range []string{"uuid", "null"} { + t.Run(op+"/"+kind, func(t *testing.T) { + vid := mustGetUUID() + if kind == "null" { + vid = nullVersionID + } + name := "pool-tags-" + op + "-" + kind + var source ObjectInfo + for pool := range 2 { + tag := "key=stale-pool" + ts := base + if pool == 1 { + tag = "" + ts = base.Add(3 * time.Hour) + } + source = putConsistencyObject(t, z, bucket, name, pool, "data", ObjectOptions{Versioned: true, VersionID: vid, MTime: base, UserDefined: map[string]string{xhttp.AmzObjectTagging: tag, r5TagStamp: ts.Format(time.RFC3339Nano)}}) + } + // Clear all copies through the signed local handler, then replay a stale incoming state. + req := r5Request(t, router, globalActiveCred, http.MethodDelete, "/"+bucket+"/"+name+"?tagging&versionId="+vid, "", nil) + if req.Code != http.StatusNoContent { + t.Fatalf("DELETE: %d %s", req.Code, req.Body.String()) + } + current, err := z.GetObjectInfo(t.Context(), bucket, name, ObjectOptions{VersionID: vid}) + if err != nil { + t.Fatal(err) + } + deletedAt := current.UserDefined[r5TagStamp] + for pool := range 2 { + r5Stored(t, z.serverPools[pool], bucket, name, vid, "", deletedAt) + } + source.VersionID = vid + source.UserTags = "key=delayed" + source.UserDefined = map[string]string{r5TagStamp: base.Add(time.Hour).Format(time.RFC3339Nano)} + r5Receive(t, z, router, globalActiveCred, bucket, op, source, source.UserDefined[r5TagStamp], nil) + // The addressed version must remain readable through normal routing. + r5Stored(t, z, bucket, name, vid, "", deletedAt) + // Existing duplicate suppression may leave both identical tombstones; any retained copy must be correct. + for pool := range 2 { + got, err := z.serverPools[pool].GetObjectInfo(t.Context(), bucket, name, ObjectOptions{VersionID: vid}) + if isErrVersionNotFound(err) { + continue + } + if err != nil { + t.Fatal(err) + } + if got.UserTags != "" || got.UserDefined[r5TagStamp] != deletedAt { + t.Fatalf("pool %d: tags=%q stamp=%q want deletion %q", pool, got.UserTags, got.UserDefined[r5TagStamp], deletedAt) + } + } + }) + } + } +} + +func TestAPITaggingSSECRotationPreservesDeletionRevision(t *testing.T) { + ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{t: t, objAPITest: func(obj ObjectLayer, instance, bucket string, router http.Handler, cred auth.Credentials, t *testing.T) { + defer r5Capacity(obj.(*erasureServerPools))() + oldTLS := globalIsTLS + globalIsTLS = true + defer func() { globalIsTLS = oldTLS }() + if _, err := globalBucketMetadataSys.Update(t.Context(), bucket, bucketVersioningConfig, enabledBucketVersioningConfig); err != nil { + t.Fatal(err) + } + keys := [][]byte{[]byte(strings.Repeat("a", 32)), []byte(strings.Repeat("b", 32)), []byte(strings.Repeat("c", 32)), []byte(strings.Repeat("d", 32))} + const name = "tag-rotation" + putCopyChecksumSource(t, router, cred, bucket, name, []byte("data"), ssecKeyHeaders(keys[0], false)) + oi, err := obj.GetObjectInfo(t.Context(), bucket, name, ObjectOptions{}) + if err != nil { + t.Fatal(err) + } + base := time.Now().UTC().Add(-time.Hour) + for i, event := range []struct { + tags string + delta int + wantTags string + wantDelta int + }{ + {"key=live", 1, "key=live", 1}, {"", 3, "", 3}, {"key=stale", 2, "", 3}, + } { + h := ssecKeyHeaders(keys[i], true) + maps.Copy(h, ssecKeyHeaders(keys[i+1], false)) + h[xhttp.AmzObjectTagging] = event.tags + h[xhttp.AmzTagDirective] = "REPLACE" + h[xhttp.MinIOSourceTaggingTimestamp] = base.Add(time.Duration(event.delta) * time.Minute).Format(time.RFC3339Nano) + sendReplicaLockCopy(t, router, cred, bucket, name, oi.VersionID, h) + r5Stored(t, obj, bucket, name, oi.VersionID, event.wantTags, base.Add(time.Duration(event.wantDelta)*time.Minute).Format(time.RFC3339Nano)) + } + get := r5Request(t, router, cred, http.MethodGet, "/"+bucket+"/"+name+"?versionId="+oi.VersionID, "", ssecKeyHeaders(keys[3], false)) + if get.Code != http.StatusOK || get.Body.String() != "data" { + t.Fatalf("%s GET after rotations: %d %q", instance, get.Code, get.Body.String()) + } + }}) +} + +func TestTaggingRepeatedValueNeedsRevisionDelivery(t *testing.T) { + for _, tag := range []string{"", "key=same"} { + now := time.Now().UTC() + source := ObjectInfo{ModTime: now, UserTags: tag, UserDefined: map[string]string{r5TagStamp: now.Add(time.Hour).Format(time.RFC3339Nano)}} + target := minio.ObjectInfo{LastModified: now} + if tag != "" { + target.UserTags = map[string]string{"key": "same"} + target.UserTagCount = 1 + } + if got := getReplicationAction(source, target, replication.MetadataReplicationType); got != replicateMetadata { + t.Errorf("equal tags %q suppress a newer revision: got %s; an intervening delayed deletion can win", tag, got) + } + } +} + +func TestTaggingProductionCopyWireShape(t *testing.T) { + got := make(chan http.Header, 1) + peer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got <- r.Header.Clone() + w.Header().Set(xhttp.ContentType, "application/xml") + w.Write([]byte(`2026-09-15T01:00:00Z"abc"`)) + })) + defer peer.Close() + c, err := minio.New(strings.TrimPrefix(peer.URL, "http://"), &minio.Options{Region: "us-east-1", MaxRetries: 1}) + if err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + oi := ObjectInfo{Name: "object", ModTime: now, ETag: "abc", VersionID: mustGetUUID()} + core := minio.Core{Client: c} + _, err = core.CopyObject(t.Context(), "bucket", "object", "bucket", "object", getCopyObjMetadata(oi, ""), minio.CopySrcOptions{VersionID: oi.VersionID}, minio.PutObjectOptions{Internal: minio.AdvancedPutOptions{SourceVersionID: oi.VersionID, ReplicationRequest: true, TaggingTimestamp: now}}) + if err != nil { + t.Fatal(err) + } + h := <-got + t.Logf("SDK wire: metadata-directive=%q tagging-directive=%q tagging=%q time=%q", h.Get(xhttp.AmzMetadataDirective), h.Get(xhttp.AmzTagDirective), h.Get(xhttp.AmzObjectTagging), h.Get(xhttp.MinIOSourceTaggingTimestamp)) + if h.Get(xhttp.AmzMetadataDirective) != "" || h.Get(xhttp.AmzTagDirective) != "REPLACE" || h.Get(xhttp.MinIOSourceTaggingTimestamp) != now.Format(time.RFC3339Nano) { + t.Fatalf("unexpected SDK wire: %v", h) + } +} + +func TestLocalTaggingCommitCannotRegressRevision(t *testing.T) { + z, bucket := consistencyPools(t) + incoming := "2026-09-15T01:00:00Z" + newer := "2026-09-15T02:00:00Z" + newest := "2026-09-15T03:00:00Z" + vid := mustGetUUID() + name := "inverted-local-tags" + for pool := range 2 { + putConsistencyObject(t, z, bucket, name, pool, "data", ObjectOptions{Versioned: true, VersionID: vid, UserDefined: map[string]string{r5TagStamp: []string{newer, newest}[pool]}}) + } + oi, err := z.PutObjectTags(t.Context(), bucket, name, "key=after-delete", ObjectOptions{VersionID: vid, UserDefined: map[string]string{r5TagStamp: incoming}}) + if err != nil { + t.Fatal(err) + } + want := "2026-09-15T03:00:00.000000001Z" + for pool := range 2 { + r5Stored(t, z.serverPools[pool], bucket, name, vid, "key=after-delete", want) + } + if oi.UserDefined[r5TagStamp] != want { + t.Fatalf("response stamp=%q want %q", oi.UserDefined[r5TagStamp], want) + } + // The single-set guard also applies when the pools dispatcher is bypassed. + _, err = z.serverPools[0].PutObjectTags(t.Context(), bucket, name, "", ObjectOptions{VersionID: vid, UserDefined: map[string]string{r5TagStamp: incoming}}) + if err != nil { + t.Fatal(err) + } + r5Stored(t, z.serverPools[0], bucket, name, vid, "", "2026-09-15T03:00:00.000000002Z") +} + +func TestTaggingReplicaContentDuplicateGuard(t *testing.T) { + stamp := time.Date(2026, 9, 15, 1, 0, 0, 0, time.UTC) + for _, tc := range []struct { + name string + delta int + stored string + trusted, replica bool + ifMatch, ifNone string + wantSkip bool + }{ + {name: "newer", delta: 1, trusted: true, replica: true}, + {name: "equal", trusted: true, replica: true, wantSkip: true}, + {name: "older", delta: -1, trusted: true, replica: true, wantSkip: true}, + {name: "invalid-stored", stored: "invalid", delta: 1, trusted: true, replica: true}, + {name: "untrusted", delta: 1, wantSkip: true}, + {name: "marker-only", delta: 1, trusted: true, wantSkip: true}, + {name: "if-match-fails", delta: 1, trusted: true, replica: true, ifMatch: "other", wantSkip: true}, + {name: "if-none-match-fails", delta: 1, trusted: true, replica: true, ifNone: "etag", wantSkip: true}, + {name: "if-match-passes", delta: 1, trusted: true, replica: true, ifMatch: "etag"}, + } { + t.Run(tc.name, func(t *testing.T) { + ctx := withReplicationTrust(t.Context(), tc.trusted, tc.replica) + r := httptest.NewRequest(http.MethodPut, "/bucket/object", nil).WithContext(ctx) + if tc.ifMatch != "" { + r.Header.Set(xhttp.IfMatch, tc.ifMatch) + } + if tc.ifNone != "" { + r.Header.Set(xhttp.IfNoneMatch, tc.ifNone) + } + stored := tc.stored + if stored == "" { + stored = stamp.Format(time.RFC3339Nano) + } + oi := ObjectInfo{ModTime: stamp, VersionID: mustGetUUID(), ETag: "etag", UserDefined: map[string]string{r5TagStamp: stored}} + opts := ObjectOptions{VersionID: oi.VersionID, PreserveETag: oi.ETag, ReplicationRequest: tc.trusted, ReplicationSourceTaggingTimestamp: stamp.Add(time.Duration(tc.delta) * time.Second)} + if skip := checkPreconditionsPUT(ctx, httptest.NewRecorder(), r, oi, opts); skip != tc.wantSkip { + t.Fatalf("skip=%v, want %v", skip, tc.wantSkip) + } + }) + } +} + +func TestAPITaggingUnqualifiedCopyOrdering(t *testing.T) { + ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{t: t, objAPITest: func(obj ObjectLayer, instance, bucket string, router http.Handler, cred auth.Credentials, t *testing.T) { + defer r5Capacity(obj.(*erasureServerPools))() + stamp := time.Now().UTC().Add(-time.Hour) + oi, err := obj.PutObject(t.Context(), bucket, "unqualified-tags", mustGetPutObjReader(t, strings.NewReader("data"), 4, "", ""), ObjectOptions{UserDefined: map[string]string{xhttp.AmzObjectTagging: "key=stored", r5TagStamp: stamp.Format(time.RFC3339Nano)}}) + if err != nil { + t.Fatal(err) + } + source := oi + source.UserTags = "" + source.UserDefined = maps.Clone(oi.UserDefined) + r5Receive(t, obj, router, cred, bucket, "copy", source, stamp.Format(time.RFC3339Nano), nil) + r5Stored(t, obj, bucket, oi.Name, "", "key=stored", stamp.Format(time.RFC3339Nano)) + later := stamp.Add(time.Minute).Format(time.RFC3339Nano) + r5Receive(t, obj, router, cred, bucket, "copy-default", source, later, nil) + r5Stored(t, obj, bucket, oi.Name, "", "", later) + source.UserTags = "key=delayed" + r5Receive(t, obj, router, cred, bucket, "copy-default", source, stamp.Format(time.RFC3339Nano), nil) + r5Stored(t, obj, bucket, oi.Name, "", "", later) + t.Logf("%s: unqualified COPY keeps stored ties and ordered deletion", instance) + }}) +} diff --git a/cmd/replication-tagging-sender_test.go b/cmd/replication-tagging-sender_test.go new file mode 100644 index 000000000..e6ced526f --- /dev/null +++ b/cmd/replication-tagging-sender_test.go @@ -0,0 +1,154 @@ +// Copyright (c) 2026 Feng Ruohang +// +// This file is part of Silo Object Storage stack +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package cmd + +import ( + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/minio/madmin-go/v3" + "github.com/minio/minio-go/v7" + "github.com/minio/minio/internal/auth" + "github.com/minio/minio/internal/bucket/replication" + xhttp "github.com/minio/minio/internal/http" + "github.com/minio/minio/internal/once" +) + +func r5ReplicationFixture(t *testing.T, obj ObjectLayer, bucket string, client *minio.Client) (chan ReplicationWorkerOperation, func()) { + t.Helper() + const arn = "arn:minio:replication::af470089-d354-4473-934c-9e1f52f6da89:bucket" + target := &TargetClient{Client: client, ARN: arn, Bucket: bucket} + globalBucketTargetSys.arnRemotesMap[arn] = arnTarget{Client: target, lastRefresh: UTCNow()} + globalBucketTargetSys.targetsMap[bucket] = []madmin.BucketTarget{{Arn: arn, TargetBucket: bucket}} + meta, err := globalBucketMetadataSys.Get(bucket) + if err != nil { + t.Fatal(err) + } + cfg := configs[0] + cfg.RoleArn = arn + meta.replicationConfig = &cfg + globalBucketMetadataSys.Set(bucket, meta) + worker := make(chan ReplicationWorkerOperation, 10) + previous := globalReplicationPool + globalReplicationPool = once.NewSingleton[ReplicationPool]() + globalReplicationPool.Set(&ReplicationPool{ctx: t.Context(), objLayer: obj, workers: []chan ReplicationWorkerOperation{worker}, stats: globalReplicationStats.Load(), mrfSaveCh: make(chan MRFReplicateEntry, 10)}) + return worker, func() { globalReplicationPool = previous } +} + +func TestTaggingReplicationSenderRetryAndAcknowledgment(t *testing.T) { + ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{t: t, objAPITest: func(obj ObjectLayer, instance, bucket string, router http.Handler, cred auth.Credentials, t *testing.T) { + defer r5Capacity(obj.(*erasureServerPools))() + if _, err := globalBucketMetadataSys.Update(t.Context(), bucket, bucketVersioningConfig, enabledBucketVersioningConfig); err != nil { + t.Fatal(err) + } + const name = "tagging-old-queue" + oi, err := obj.PutObject(t.Context(), bucket, name, mustGetPutObjReader(t, strings.NewReader("data"), 4, "", ""), ObjectOptions{Versioned: true}) + if err != nil { + t.Fatal(err) + } + requests := make(chan http.Header, 4) + var attempts atomic.Int32 + peer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set(xhttp.AmzVersionID, oi.VersionID) + w.Header().Set(xhttp.ETag, "\""+oi.ETag+"\"") + w.Header().Set(xhttp.LastModified, oi.ModTime.Format(http.TimeFormat)) + w.Header().Set(xhttp.ContentType, oi.ContentType) + if r.Method == http.MethodHead { + w.Header().Set(xhttp.ContentLength, "4") + w.WriteHeader(http.StatusOK) + return + } + requests <- r.Header.Clone() + w.Header().Set(xhttp.ContentType, "application/xml") + if attempts.Add(1) == 1 { + w.WriteHeader(http.StatusServiceUnavailable) + w.Write([]byte(`SlowDownretry fixture`)) + return + } + w.Write([]byte("" + oi.ModTime.Format(time.RFC3339Nano) + "\"" + oi.ETag + "\"")) + })) + defer peer.Close() + client, err := minio.New(strings.TrimPrefix(peer.URL, "http://"), &minio.Options{Region: "us-east-1", MaxRetries: 1}) + if err != nil { + t.Fatal(err) + } + worker, cleanup := r5ReplicationFixture(t, obj, bucket, client) + defer cleanup() + body := `keyqueued` + w := r5Request(t, router, cred, http.MethodPut, "/"+bucket+"/"+name+"?tagging&versionId="+oi.VersionID, body, nil) + if w.Code != http.StatusOK || len(worker) != 1 { + t.Fatalf("tagging PUT: %d %s queued=%d", w.Code, w.Body.String(), len(worker)) + } + old := (<-worker).(ReplicateObjectInfo) + // Delete through the actual handler before processing the old task. + w = r5Request(t, router, cred, http.MethodDelete, "/"+bucket+"/"+name+"?tagging&versionId="+oi.VersionID, "", nil) + if w.Code != http.StatusNoContent || len(worker) != 1 { + t.Fatalf("tagging DELETE: %d %s queued=%d", w.Code, w.Body.String(), len(worker)) + } + deleted, err := obj.GetObjectInfo(t.Context(), bucket, name, ObjectOptions{VersionID: oi.VersionID}) + if err != nil { + t.Fatal(err) + } + stamp := deleted.UserDefined[r5TagStamp] + for attempt := 0; attempt < 2; attempt++ { + result := replicateObject(t.Context(), old, obj) + want := replication.Failed + if attempt == 1 { + want = replication.Completed + } + if result.ReplicationStatus() != want { + t.Fatalf("attempt %d result=%+v want %s", attempt, result, want) + } + if len(result.Targets) != 1 || result.Targets[0].ReplicationAction != replicateMetadata || (result.Targets[0].Err != nil) != (attempt == 0) { + t.Fatalf("attempt %d reported wrong action/error: %+v", attempt, result) + } + r5Stored(t, obj, bucket, name, oi.VersionID, "", stamp) + select { + case h := <-requests: + if h.Get(xhttp.MinIOSourceTaggingTimestamp) != stamp || h.Get(xhttp.AmzObjectTagging) != "" || h.Get(xhttp.AmzTagDirective) != "REPLACE" || h.Get(xhttp.AmzMetadataDirective) != "" { + t.Fatalf("sender did not carry current deletion: %v", h) + } + t.Logf("%s attempt %d sent tags=%q timestamp=%s status=%s", instance, attempt, h.Get(xhttp.AmzObjectTagging), stamp, want) + default: + t.Fatal("no metadata COPY sent for same-empty target") + } + } + // With a real outgoing rule enabled, a signed incoming replica COPY + // must not queue another outgoing event and create a feedback loop. + before := len(worker) + r5Receive(t, obj, router, cred, bucket, "copy", deleted, stamp, nil) + if len(worker) != before { + t.Fatal("incoming replica COPY scheduled another outgoing event") + } + // The metadata sender must fail malformed stored revisions before COPY, + // just as the full retransmission option builder does. + _, err = obj.PutObjectTags(t.Context(), bucket, name, "", ObjectOptions{VersionID: oi.VersionID, UserDefined: map[string]string{r5TagStamp: "invalid"}}) + if err != nil { + t.Fatal(err) + } + target := globalBucketTargetSys.GetRemoteTargetClient(bucket, globalBucketTargetSys.targetsMap[bucket][0].Arn) + invalid := old.replicateAll(t.Context(), obj, target) + if invalid.ReplicationStatus != replication.Failed || invalid.Err == nil || len(requests) != 0 { + t.Fatalf("invalid timestamp was not rejected before send: %+v", invalid) + } + }}) +} diff --git a/cmd/server-main.go b/cmd/server-main.go index 48ed0f87d..c8a3a19ca 100644 --- a/cmd/server-main.go +++ b/cmd/server-main.go @@ -901,6 +901,8 @@ func serverMain(ctx *cli.Context) { close(globalGridStart) close(globalLockGridStart) + // The HTTP/1 listener preserves absolute header deadlines and renews the + // body read/write idle limits, so transfers may outlast IdleTimeout. httpServer := xhttp.NewServer(getServerListenAddrs()). UseHandler(setCriticalErrorHandler(corsHandler(handler))). UseTLSConfig(newTLSConfig(getCert)). diff --git a/cmd/server_deadline_config_test.go b/cmd/server_deadline_config_test.go new file mode 100644 index 000000000..e67ad9372 --- /dev/null +++ b/cmd/server_deadline_config_test.go @@ -0,0 +1,97 @@ +// Copyright (c) 2026 Feng Ruohang +// +// This file is part of Silo Object Storage stack +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package cmd + +import ( + "os" + "testing" + "time" + + "github.com/minio/cli" + xhttp "github.com/minio/minio/internal/http" +) + +func TestServerReadHeaderTimeoutConfig(t *testing.T) { + for _, tc := range []struct { + name, env, idle string + args []string + want time.Duration + fmtgen bool + }{ + {name: "default", want: xhttp.DefaultReadHeaderTimeout}, + {name: "flag", args: []string{"--read-header-timeout=100ms"}, want: 100 * time.Millisecond}, + {name: "environment", env: "170ms", want: 170 * time.Millisecond}, + {name: "flag-over-environment", env: "170ms", args: []string{"--read-header-timeout=80ms"}, want: 80 * time.Millisecond}, + {name: "yaml-retains-flag", args: []string{"--config=testdata/config/1.yaml", "--read-header-timeout=100ms"}, want: 100 * time.Millisecond}, + {name: "zero-fallback", args: []string{"--read-header-timeout=0s"}}, + {name: "zero-idle-default-header", idle: "0s", want: xhttp.DefaultReadHeaderTimeout}, + {name: "negative-idle-default-header", idle: "-1s", want: xhttp.DefaultReadHeaderTimeout}, + {name: "fmt-gen-unregistered-duration", env: "100ms", fmtgen: true}, + {name: "negative-disabled", args: []string{"--read-header-timeout=-1s"}, want: -time.Second}, + } { + t.Run(tc.name, func(t *testing.T) { + for _, key := range []string{"MINIO_ARGS", "MINIO_VOLUMES", "MINIO_ENDPOINTS", "MINIO_CONFIG", "MINIO_ERASURE_SET_DRIVE_COUNT"} { + t.Setenv(key, "") + } + t.Setenv("MINIO_READ_HEADER_TIMEOUT", tc.env) + if tc.env == "" { + if err := os.Unsetenv("MINIO_READ_HEADER_TIMEOUT"); err != nil { + t.Fatal(err) + } + } + idle := tc.idle + if idle == "" { + idle = "2s" + } + t.Setenv("MINIO_IDLE_TIMEOUT", idle) + idleWant, err := time.ParseDuration(idle) + if err != nil { + t.Fatal(err) + } + commandName, flags := "server", serverCmd.Flags + if tc.fmtgen { + commandName, flags = "fmt-gen", fmtGenFlags + idleWant = 0 + } + var got serverCtxt + called := false + app := cli.NewApp() + app.Commands = []cli.Command{{Name: commandName, Flags: flags, Action: func(ctx *cli.Context) error { + called = true + if parsed := ctx.Duration("read-header-timeout"); parsed != tc.want { + t.Errorf("CLI read-header-timeout=%s, expected=%s", parsed, tc.want) + } + return buildServerCtxt(ctx, &got) + }}} + args := append([]string{"silo", commandName}, tc.args...) + args = append(args, t.TempDir()) + if err := app.Run(args); err != nil { + t.Fatal(err) + } + if !called { + t.Fatal("server action did not run") + } + if got.ReadHeaderTimeout != tc.want { + t.Errorf("parsed ReadHeaderTimeout = %s, want %s", got.ReadHeaderTimeout, tc.want) + } + if got.IdleTimeout != idleWant { + t.Errorf("parsed IdleTimeout = %s, want %s", got.IdleTimeout, idleWant) + } + }) + } +} diff --git a/docs/investigations/r4-r8-integration/README.md b/docs/investigations/r4-r8-integration/README.md new file mode 100644 index 000000000..978de884a --- /dev/null +++ b/docs/investigations/r4-r8-integration/README.md @@ -0,0 +1,95 @@ +# R4–R8 集成核验 + +## 结论与范围 + +2026-09-16,R5、R6、R8 的修复在已包含 R4、R7 的 main 基线上完成集成。 +本地完整 `cmd`、`internal` 测试、相关 race 检查、仓库 verifiers、构建和 +HTTP 超时进程探针均通过。环境中的真实 `claude-opus-5`(effort `max`)独立 +阅读合并差异与调用链,结论为 **GO_WITH_NONBLOCKING_NOTES,零阻断项**。 + +本记录对应 main 合并前的代码核验。最终 PR 的 Linux CI、DCO 和合并结果以该 +PR 的实际提交及检查为准;这里的本地结果不代表发布、部署或多站点生产验收。 + +## 提交对应关系 + +基线:`9f3037e941a49ab4cd8a0eed7c0f01083fbe4bbe`。 + +| 问题 | 原修复提交 | 集成提交 | 行为 | +| --- | --- | --- | --- | +| R4 | PR [#193](https://github.com/pgsty/silo/pull/193),已在基线 | `af2b1794d38d9e70e1d2c3ee692426e4b6cab4bd`(merge) | SSE-KMS 复制保留标签修订时间 | +| R7 | PR [#194](https://github.com/pgsty/silo/pull/194),已在基线 | `9f3037e941a49ab4cd8a0eed7c0f01083fbe4bbe`(merge) | 复制元数据恢复不再重新写入传输用 aws-chunked | +| R5 | `115fe8b12329d147adbaf817faa1737392ecbf9b` | `680eac66e40b0980bc20e70d7ad34185096e63f5` | 删除标签推进修订,接收端抵御乱序事件,重试及 ACK 保留新状态 | +| R6 | `cf381a7151ef25fc95ace5fedcd767fa19410de2` | `0c61128d23f05ce6b37e7ace713c3ffbfb68f4cb` | 旧形态 marker purge 正确分类,MRF 恢复 marker 并保留重试次数 | +| R6 核验记录 | `d38edb2c46182d3a8fa96e040604493d20a4b478` | `aea3882c95d16ec5598a07b40d593e04054137a9` | 保存 v3 共识及验证边界 | +| R8 | `0d48d32d7e038ae1ea5966f3d7e0cb86780a6311` | `055030ea53ca92ee22ce1e601ef4757c247edde8` | 配置绑定到读头绝对超时,正文继续采用滚动空闲超时 | + +各原任务先取得 Opus 方案共识,再实施修复。原始方案、实现复核及验证记录保留在 +[R5](../r5/verification.md)、[R6](../r6/README.md)、[R8](../r8/README.md)。 +R5、R6、R8 原任务又分别只读核验了集成后的交叉影响,未发现新增生产阻断项。 + +集成使用 `git cherry-pick -x -s`,保留原作者、来源及 DCO。后续 +`80684fed59f556d579e268c5a855d936c1347b68` 仅处理两类贡献规范问题: + +- 六个新建测试文件统一使用实际贡献者姓名及 AGPL-3.0-or-later 头部;从 + `package` 开始的内容逐字节不变,Linux build tag 保留。 +- 按 CONTRIBUTING 的规则更新兼容标识清单。唯一新增条目是 R6 测试拼接既有 + replication ARN 所用的 `arn:minio:replication::`,没有新增协议名称或生产行为。 + +22 个源码/测试文件的最终哈希见 [manifest.json](manifest.json)。共享文件中的 +R5、R6 补丁与原修复具有相同稳定 patch ID;其余源文件直接比较,六个测试仅允许 +上述头部差异。[等价检查](evidence/integration-equivalence.json)全部通过。 + +## Opus 集成复核与处置 + +实际 CLI 为 2.1.270,显式指定 `claude-opus-5 --effort max`;只允许 Read、Grep、 +Glob,未执行测试或修改代码。实际返回模型为 `claude-opus-5`,进程和结果均成功。 +复核基于 `055030ea53ca92ee22ce1e601ef4757c247edde8` 的 22 个文件及完整差异; +此后的代码变化仅为上文已证明等价的头部与兼容清单调整。 + +原文、调用元数据及提示词分别见 [复核结果](evidence/opus-review.md)、 +[metadata](evidence/opus-integration.metadata.json)、[prompt](evidence/opus-integration.prompt.md)。 +保留原文中的判断,再用直接证据逐项处置,避免把模型意见当作测试结果: + +| 非阻断意见 | 核验与决定 | +| --- | --- | +| 非法或空的历史标签时间戳可能使复制失败并重试 | 保留 R5 共识中的失败关闭行为;历史异常数据修复另行处理 | +| 带标签修订的版本在 resync 时可能多一次 metadata COPY | R5 已接受的可靠性成本;正常 COMPLETED 路径保持原有门控 | +| purge 审计状态由 COMPLETE 规范为 COMPLETED,统计开始记录实际目标结果 | R6 的预期行为;后续发布说明应告知审计/指标使用者 | +| 配置的较短 ReadHeaderTimeout 同时缩短 TLS 握手窗口 | Go net/http 的预期语义,已在 R8 共识中说明 | +| 新增多池标签测试单独运行可能缺少全局初始化 | **未成立**:精确单独运行通过;`consistencyPools` 经 `prepareErasurePoolsWithContext` → `initObjectLayer` → `newTestObjectLayer` 调用 `initAllSubsystems`。保留测试原样 | +| 审计 fixture 重复取消可能输出栈信息 | 本地完整及 race 测试通过;不扩大本次生产修复范围 | +| 新测试文件头部应按实际贡献者整理 | 已在 `80684fed` 修正,测试代码及 build tag 不变 | + +## 本地直接验证 + +下表全部针对 `80684fed59f556d579e268c5a855d936c1347b68`,未使用额外的源码或 +容量 overlay;测试代码自身的容量 fixture 保留。限制并行度仅为 +`GOMAXPROCS=4`、`GOFLAGS=-p=2`,并使用 +仓库 CI 的 `MINIO_API_REQUESTS_MAX=10000`。详细命令、时间和日志哈希在 +[validation-results.json](evidence/validation-results.json)。 + +| 检查 | 结果 | +| --- | --- | +| `make verifiers`:lint、生成文件、rebrand guard | 通过,76.9 秒;可选 typos 工具按现有 Makefile 规则跳过 | +| `make build`、`./silo --version` | 通过,产物为 silo | +| `CGO_ENABLED=0 go test -p 2 ./cmd ./internal/... -count=1 -timeout=30m` | 全部通过,340.2 秒,50 个有测试的包 | +| `CGO_ENABLED=1 go test -race`,cmd/deadlineconn/http 中变更测试的函数集合 | 通过,46.3 秒;Linux build tag 用例由最终 Linux CI 覆盖 | +| `TestAPIPoolsTaggingReplicaDeletion` 精确单独执行 | 通过,无需其他测试预先运行 | +| 实际 silo 进程的 CLI/环境变量读头超时探针 | 两种配置均在 100ms 读头限制下拒绝 400ms 才完成的请求头;空闲超时为 2s,随后健康请求成功 | + +进程探针使用二进制 SHA-256 +`1cc536f1a3c8d8372ff2d5b140b1fd2bc98a299324fea0f73f67288d48104ce4`。 +原始输出见 [runtime-probe.json](evidence/runtime-probe.json)。 + +各子任务较早遇到的磁盘容量不足或筛选测试初始化问题,不作为这次通过的证据。 +本地完整测试已重新执行并成功;原失败记录仍保留在各自调查档案。 + +## 验收边界 + +- 最终 PR 必须通过实际提交的全部仓库检查,尤其 Linux internal 测试、完整 cmd + 测试、构建/vet、lint/生成文件、交叉编译、S3 Select race、DCO 和漏洞检查。 +- 既有无时间戳对象、异常时间戳、标签筛选的目标选择、任意站点时钟偏差等不由本次 + 修复追溯重建。共享状态解析器的历史限制按 R6 v3 共识在写入点规避。 +- R8 原先未完成的 S3 长传输脚本不计为通过;本次进程探针验证配置生效,不替代 + S3 长传输、独立多进程、多节点或跨区域生产验收。 +- 本次不引入依赖变更或上游 MinIO 兼容硬门槛;R9 不属于这五项修复。 diff --git a/docs/investigations/r4-r8-integration/evidence/full-tests.log b/docs/investigations/r4-r8-integration/evidence/full-tests.log new file mode 100644 index 000000000..03138b03a --- /dev/null +++ b/docs/investigations/r4-r8-integration/evidence/full-tests.log @@ -0,0 +1,79 @@ +ok github.com/minio/minio/cmd 320.458s +ok github.com/minio/minio/internal/amztime 1.605s +ok github.com/minio/minio/internal/arn 0.392s +ok github.com/minio/minio/internal/auth 0.440s +ok github.com/minio/minio/internal/bpool 0.422s +ok github.com/minio/minio/internal/bucket/bandwidth 0.450s +ok github.com/minio/minio/internal/bucket/cors 0.484s +ok github.com/minio/minio/internal/bucket/encryption 0.644s +ok github.com/minio/minio/internal/bucket/lifecycle 0.647s +ok github.com/minio/minio/internal/bucket/object/lock 0.653s +ok github.com/minio/minio/internal/bucket/replication 0.496s +ok github.com/minio/minio/internal/bucket/versioning 0.449s +ok github.com/minio/minio/internal/cachevalue 5.457s +? github.com/minio/minio/internal/color [no test files] +ok github.com/minio/minio/internal/config 0.656s +? github.com/minio/minio/internal/config/api [no test files] +? github.com/minio/minio/internal/config/batch [no test files] +? github.com/minio/minio/internal/config/browser [no test files] +? github.com/minio/minio/internal/config/callhome [no test files] +ok github.com/minio/minio/internal/config/compress 0.624s +ok github.com/minio/minio/internal/config/dns 0.731s +? github.com/minio/minio/internal/config/drive [no test files] +ok github.com/minio/minio/internal/config/etcd 1.015s +? github.com/minio/minio/internal/config/heal [no test files] +ok github.com/minio/minio/internal/config/identity/ldap 0.593s +ok github.com/minio/minio/internal/config/identity/openid 0.675s +? github.com/minio/minio/internal/config/identity/openid/provider [no test files] +? github.com/minio/minio/internal/config/identity/plugin [no test files] +? github.com/minio/minio/internal/config/identity/tls [no test files] +ok github.com/minio/minio/internal/config/ilm 1.057s +? github.com/minio/minio/internal/config/lambda [no test files] +ok github.com/minio/minio/internal/config/lambda/event 0.485s +? github.com/minio/minio/internal/config/lambda/target [no test files] +ok github.com/minio/minio/internal/config/notify 0.752s +? github.com/minio/minio/internal/config/policy/opa [no test files] +? github.com/minio/minio/internal/config/policy/plugin [no test files] +? github.com/minio/minio/internal/config/scanner [no test files] +ok github.com/minio/minio/internal/config/storageclass 0.585s +ok github.com/minio/minio/internal/config/subnet 0.582s +ok github.com/minio/minio/internal/crypto 0.843s +ok github.com/minio/minio/internal/deadlineconn 4.934s +ok github.com/minio/minio/internal/disk 0.418s +ok github.com/minio/minio/internal/dsync 131.144s +ok github.com/minio/minio/internal/etag 0.504s +ok github.com/minio/minio/internal/event 0.595s +ok github.com/minio/minio/internal/event/target 0.957s +ok github.com/minio/minio/internal/grid 7.616s +ok github.com/minio/minio/internal/handlers 0.717s +ok github.com/minio/minio/internal/hash 0.700s +? github.com/minio/minio/internal/hash/sha256 [no test files] +ok github.com/minio/minio/internal/http 13.643s +? github.com/minio/minio/internal/init [no test files] +ok github.com/minio/minio/internal/ioutil 1.921s +ok github.com/minio/minio/internal/jwt 0.423s +ok github.com/minio/minio/internal/kms 0.538s +ok github.com/minio/minio/internal/lock 1.077s +ok github.com/minio/minio/internal/logger 0.546s +? github.com/minio/minio/internal/logger/message/audit [no test files] +? github.com/minio/minio/internal/logger/target/console [no test files] +? github.com/minio/minio/internal/logger/target/http [no test files] +? github.com/minio/minio/internal/logger/target/kafka [no test files] +? github.com/minio/minio/internal/logger/target/loggertypes [no test files] +? github.com/minio/minio/internal/logger/target/testlogger [no test files] +ok github.com/minio/minio/internal/lsync 10.501s +? github.com/minio/minio/internal/mcontext [no test files] +? github.com/minio/minio/internal/mountinfo [no test files] +? github.com/minio/minio/internal/net [no test files] +? github.com/minio/minio/internal/once [no test files] +ok github.com/minio/minio/internal/pubsub 0.511s +ok github.com/minio/minio/internal/rest 0.525s +ok github.com/minio/minio/internal/ringbuffer 1.316s +ok github.com/minio/minio/internal/s3select 0.603s +ok github.com/minio/minio/internal/s3select/csv 0.446s +ok github.com/minio/minio/internal/s3select/json 0.439s +ok github.com/minio/minio/internal/s3select/jstream 0.417s +? github.com/minio/minio/internal/s3select/parquet [no test files] +? github.com/minio/minio/internal/s3select/simdj [no test files] +ok github.com/minio/minio/internal/s3select/sql 0.425s +ok github.com/minio/minio/internal/store 1.451s diff --git a/docs/investigations/r4-r8-integration/evidence/header-equivalence.json b/docs/investigations/r4-r8-integration/evidence/header-equivalence.json new file mode 100644 index 000000000..ca464a36f --- /dev/null +++ b/docs/investigations/r4-r8-integration/evidence/header-equivalence.json @@ -0,0 +1,50 @@ +[ + { + "file": "cmd/replication-delete-mrf_test.go", + "before_sha256": "5e160f7e19cbbb8cd5fa4e7ffd9cff9e09361b3fc4c5ee5ae61458f99c777781", + "after_sha256": "6fcfaf505d28f98e42dff9c0d965895be2c2d112e9e996220d424aea1f76d691", + "body_sha256": "5b3cecec74273540f4a5f82ca55e8a28545d0252822af3568f7e36b19011bda8", + "body_identical": true, + "build_prefix_preserved": true + }, + { + "file": "cmd/replication-delete-operation_test.go", + "before_sha256": "2888a04a543324776041316de2821f388d28c3c1a6f5d0031e5ac9d34f58d504", + "after_sha256": "2e674cab5ca4dbc38cb2c1ddcca117269276e6419e1869c154c3bd6a05676715", + "body_sha256": "7921af42a9f123450e3e567d0bf65cd030678404709c77fde4a9cbb366230c35", + "body_identical": true, + "build_prefix_preserved": true + }, + { + "file": "cmd/server_deadline_config_test.go", + "before_sha256": "1013157f83baa5f7882ec2d41c7b1fccb9e05fb418d0fa61263953037c9698c4", + "after_sha256": "a8259d273922a8973b44d9a468791761d373fe265fff8b2b53871e4626b11405", + "body_sha256": "5714a9275aee7230d54ba8ed03e3705a3cf6e150bda122929a9131b4b2b8dd5c", + "body_identical": true, + "build_prefix_preserved": true + }, + { + "file": "internal/deadlineconn/deadlineconn_strict_test.go", + "before_sha256": "f405690c9ff044595f48323d68f4a9b33ce695b3ad6820f54f17151067bfae5e", + "after_sha256": "b4aec28c5daddb36e6ebb98dd8af2a44b7a2c48f0660068534f69669609f67a7", + "body_sha256": "ab2b54e29ed34de6e59ba2fa4984d61ed5f93d74ae4f6cd22dd44ba77a37e569", + "body_identical": true, + "build_prefix_preserved": true + }, + { + "file": "internal/http/dial_deadline_linux_test.go", + "before_sha256": "0939d05b72a09760d53fcdf249775989e3f89bca824b9961d0b2a657ebfdf41e", + "after_sha256": "c4c83bda92ba9bac53166453920456134b3d8265cd59101b4203067c67eca59e", + "body_sha256": "fac091ef08f29fe32c2668eccd8cd505106c9fe3c4715e4c3ed9063d4b71c86e", + "body_identical": true, + "build_prefix_preserved": true + }, + { + "file": "internal/http/server_deadline_test.go", + "before_sha256": "a6e687b3904a876fa92a4c5b86453159f3e5a38a4b9412dc213c7772f47fbf0b", + "after_sha256": "87303cc389bf1cf759898489f06b001073de2dd9c4b3687c4eca14aa186b62ab", + "body_sha256": "20b238f34090c356b2254388e863d77d0a316f33b849291d15d94fad260f23dd", + "body_identical": true, + "build_prefix_preserved": true + } +] diff --git a/docs/investigations/r4-r8-integration/evidence/integration-equivalence.json b/docs/investigations/r4-r8-integration/evidence/integration-equivalence.json new file mode 100644 index 000000000..4c5037152 --- /dev/null +++ b/docs/investigations/r4-r8-integration/evidence/integration-equivalence.json @@ -0,0 +1,35 @@ +{ + "head": "80684fed59f556d579e268c5a855d936c1347b68", + "checks": { + "r6_shared_file_patch": true, + "r5_shared_file_patch": true, + "cmd/erasure-object.go": true, + "cmd/erasure-server-pool-consistency.go": true, + "cmd/erasure-server-pool.go": true, + "cmd/object-handlers-common.go": true, + "cmd/object-handlers.go": true, + "cmd/object-multipart-handlers.go": true, + "cmd/replication-tagging-order_test.go": true, + "cmd/replication-tagging-sender_test.go": true, + "cmd/bucket-replication-utils.go": true, + "cmd/replication-delete-marker_test.go": true, + "cmd/replication-delete-operation_test.go": true, + "cmd/replication-delete-mrf_test.go": true, + "cmd/common-main.go": true, + "cmd/server-main.go": true, + "internal/deadlineconn/deadlineconn.go": true, + "internal/http/listener.go": true, + "internal/http/server.go": true, + "cmd/server_deadline_config_test.go": true, + "internal/deadlineconn/deadlineconn_strict_test.go": true, + "internal/http/dial_deadline_linux_test.go": true, + "internal/http/server_deadline_test.go": true, + "only_reviewed_hygiene_changes": true, + "dco:80684fed59f556d579e268c5a855d936c1347b68": true, + "dco:055030ea53ca92ee22ce1e601ef4757c247edde8": true, + "dco:aea3882c95d16ec5598a07b40d593e04054137a9": true, + "dco:0c61128d23f05ce6b37e7ace713c3ffbfb68f4cb": true, + "dco:680eac66e40b0980bc20e70d7ad34185096e63f5": true + }, + "all_pass": true +} diff --git a/docs/investigations/r4-r8-integration/evidence/isolated-pools-before.log b/docs/investigations/r4-r8-integration/evidence/isolated-pools-before.log new file mode 100644 index 000000000..2d59ab2de --- /dev/null +++ b/docs/investigations/r4-r8-integration/evidence/isolated-pools-before.log @@ -0,0 +1 @@ +ok github.com/minio/minio/cmd 1.583s diff --git a/docs/investigations/r4-r8-integration/evidence/isolated-pools-before.result.json b/docs/investigations/r4-r8-integration/evidence/isolated-pools-before.result.json new file mode 100644 index 000000000..7bf02c351 --- /dev/null +++ b/docs/investigations/r4-r8-integration/evidence/isolated-pools-before.result.json @@ -0,0 +1,17 @@ +{ + "command": [ + "go", + "test", + "-p", + "2", + "./cmd", + "-run", + "^TestAPIPoolsTaggingReplicaDeletion$", + "-count=1", + "-timeout=2m" + ], + "exit_code": 0, + "seconds": 5.096, + "log": "/Users/vonng/tmp/silo-r4-r8-main-20260916-01a0a5ab/isolated-pools-before.log", + "log_sha256": "f014a1a72dda7b973cd1e0b7e77c70b470eeebca0d04805d98f07c2622c87b1e" +} diff --git a/docs/investigations/r4-r8-integration/evidence/make-build.log b/docs/investigations/r4-r8-integration/evidence/make-build.log new file mode 100644 index 000000000..1c3f2c153 --- /dev/null +++ b/docs/investigations/r4-r8-integration/evidence/make-build.log @@ -0,0 +1,2 @@ +Checking dependencies +Building Silo binary to './silo' diff --git a/docs/investigations/r4-r8-integration/evidence/make-verifiers-final.log b/docs/investigations/r4-r8-integration/evidence/make-verifiers-final.log new file mode 100644 index 000000000..759785f46 --- /dev/null +++ b/docs/investigations/r4-r8-integration/evidence/make-verifiers-final.log @@ -0,0 +1,7 @@ +Running lint check +0 issues. +typos binary is not found.. skipping.. +compatibility manifest: imports=119 env=428 metrics=19 headers=87 routes=224 roots=1 grid=3 storage=16 policy=59 brand=181 sha256=ad05829578cf879b462a12fa65f3c10c8c7c6aa8d6c329e04779459eec2645be +Silo rebrand compatibility baseline is unchanged +Silo delivery and runtime rebrand checks passed +docker entrypoint argv compatibility tests passed diff --git a/docs/investigations/r4-r8-integration/evidence/opus-integration.metadata.json b/docs/investigations/r4-r8-integration/evidence/opus-integration.metadata.json new file mode 100644 index 000000000..ca3ef9796 --- /dev/null +++ b/docs/investigations/r4-r8-integration/evidence/opus-integration.metadata.json @@ -0,0 +1,66 @@ +{ + "candidate": "055030ea53ca92ee22ce1e601ef4757c247edde8", + "base": "9f3037e941a49ab4cd8a0eed7c0f01083fbe4bbe", + "requested_model": "claude-opus-5", + "requested_effort": "max", + "cli_version": "2.1.270", + "command": [ + "/opt/homebrew/bin/claude", + "--print", + "--model", + "claude-opus-5", + "--effort", + "max", + "--safe-mode", + "--permission-mode", + "plan", + "--tools", + "Read,Grep,Glob", + "--strict-mcp-config", + "--no-session-persistence", + "--add-dir", + "/Users/vonng/tmp/silo-r4-r8-main-20260916-01a0a5ab", + "--add-dir", + "/Users/vonng/pgsty/silo", + "--output-format", + "stream-json", + "--verbose" + ], + "source_sha256": { + "cmd/bucket-replication-utils.go": "365641c760901641e8320cc5123697ab92a46f1add612048b991ed2d1cfad43b", + "cmd/bucket-replication.go": "2e766c5946dcabaea79455b50a8e426f404e55c92dcbccbe28d55906e2e43843", + "cmd/common-main.go": "f8777fe8a07d175aceee07b4dd13792b2384449c404c004c38a06893be997843", + "cmd/erasure-object.go": "4bc848685ea714d88cabbd5d1b8585fbcc06f7b19c775e1a811030e783d0e1a4", + "cmd/erasure-server-pool-consistency.go": "d2736ef6bffbb5c5758eba8df38f8d4ecb888a838ab0de8ad3cf015c051f8ad7", + "cmd/erasure-server-pool.go": "87ad0b25dfa3081d0e63d0073b788614a9c88e2498a2ce0956b93f8a0a03ef53", + "cmd/object-handlers-common.go": "101bd7d7447072d13fed50983b69b562e4725632645e623d7fdd490f388ecdec", + "cmd/object-handlers.go": "61897a260f3f5f660f41edcb50956c60e914ef98f9a987da824f16d78171fde2", + "cmd/object-multipart-handlers.go": "d9622c69c540ab32dd23916e3f534b6886473a98370c9dd17673e69a423b2a7e", + "cmd/replication-delete-marker_test.go": "d967787804d558ac6266b113228fdf4a4f9fcb7cab39138a4fb07558814ccca4", + "cmd/replication-delete-mrf_test.go": "5e160f7e19cbbb8cd5fa4e7ffd9cff9e09361b3fc4c5ee5ae61458f99c777781", + "cmd/replication-delete-operation_test.go": "2888a04a543324776041316de2821f388d28c3c1a6f5d0031e5ac9d34f58d504", + "cmd/replication-tagging-order_test.go": "c8260b4ccf82fa615e1e24b35a07f2d1aacbcf776e5c6f9dadffea4a09ad6ea8", + "cmd/replication-tagging-sender_test.go": "3770a1a48a6efe58fe8127e1e4fdf6bd7cf171e17db20f15222ea2f7b85db1af", + "cmd/server-main.go": "04c265de211412ba0297396928096d7f2d971244a957a3126154846035263514", + "cmd/server_deadline_config_test.go": "1013157f83baa5f7882ec2d41c7b1fccb9e05fb418d0fa61263953037c9698c4", + "internal/deadlineconn/deadlineconn.go": "b9272ef640f1d4403b3d0af6cdbaba9186c51ad9a0226dfe449e8ef738e1ec4b", + "internal/deadlineconn/deadlineconn_strict_test.go": "f405690c9ff044595f48323d68f4a9b33ce695b3ad6820f54f17151067bfae5e", + "internal/http/dial_deadline_linux_test.go": "0939d05b72a09760d53fcdf249775989e3f89bca824b9961d0b2a657ebfdf41e", + "internal/http/listener.go": "49628575367f6ab9b6986caf594726d74d370f7d2ac4eed582903600b6eb3fa2", + "internal/http/server.go": "b7b0355f2781f8c5f7c77bc910cd4180cd3e5f22a87de41bd35ef119d36b4cdf", + "internal/http/server_deadline_test.go": "a6e687b3904a876fa92a4c5b86453159f3e5a38a4b9412dc213c7772f47fbf0b" + }, + "diff_sha256": "d8c4e60f9e4a5b1338f3e6e07b758b1bb279db80eec26847e3b35fde0d049485", + "prompt_sha256": "f95edcf71afedceab190ff57bfbec812b06c8c18b5370d887131a760228c2b2c", + "started_at": "2026-09-15T16:41:37.757693+00:00", + "status": "completed", + "exit_code": 0, + "actual_models": [ + "claude-opus-5" + ], + "finished_at": "2026-09-15T16:53:09.761932+00:00", + "raw_sha256": "3e1a45ebdf3f4420fb05647bb383c00d0a86452a6c357a92679485c99d8f4eb3", + "stderr_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "result_subtype": "success", + "is_error": false +} diff --git a/docs/investigations/r4-r8-integration/evidence/opus-integration.prompt.md b/docs/investigations/r4-r8-integration/evidence/opus-integration.prompt.md new file mode 100644 index 000000000..0a0dc3ffa --- /dev/null +++ b/docs/investigations/r4-r8-integration/evidence/opus-integration.prompt.md @@ -0,0 +1,18 @@ +Independently review the complete SILO R4-R8 integration candidate for a user-authorized merge to main. You are the real Claude Opus reviewer; provide your own conclusion from source inspection. Read-only: no edits, no GitHub actions, no test execution claims. + +Exact candidate: 055030ea53ca92ee22ce1e601ef4757c247edde8; integration branch codex/merge-r4-r8. Base: 9f3037e941a49ab4cd8a0eed7c0f01083fbe4bbe, already contains separately reviewed and CI-accepted R4 (SSE-KMS tag timestamp) and R7 (replication metadata/aws-chunked). This candidate adds the final R5, R6, and R8 local repairs, cherry-picked without conflicts and with provenance/DCO preserved. + +Read /Users/vonng/pgsty/silo/AGENTS.md and CONTRIBUTING.md. The maintained PGSTY stack is the release target; upstream MinIO compatibility is best effort. Read /Users/vonng/tmp/silo-r4-r8-main-20260916-01a0a5ab/integration-code.diff and /Users/vonng/tmp/silo-r4-r8-main-20260916-01a0a5ab/reviewed-source.json, then inspect complete relevant functions and tests in this worktree. Each repair already has real same-version Opus plan consensus: docs/investigations/r5/plan-v2.md and consensus.md; r6/plan-v3.md and consensus.md; r8/plan-v2.md and consensus.md. Prior implementation reviews and validation reports are supporting evidence, not substitutes for this integration review. The R6 v2 multi-target parser proof was disproved by a real storage counterexample and fixed only after v3 consensus; verify the final empty creation-update invariant. + +Focus on concrete integration correctness: +1. R5 tag revision persistence and empty/nonempty ordering through COPY, PUT, multipart, retries and source ACK, with R6 purge/MRF state writes and shared bucket-replication.go functions. +2. R7 restores only six replication-specific fields. Verify tag values/timestamps remain handled correctly and aws-chunked is not reintroduced, including R4 KMS options. +3. R6 marker creation versus canonical/legacy purge, all exits, per-target statistics, disk creation/replica metadata preservation, identity-checked marker 405 recovery, retry counts and bounded scanner fallback. +4. R8 absolute request-header deadlines and CLI/env propagation, HTTP/1 streaming bodies, keep-alive/TLS/h2 boundaries, default DeadlineConn callers and effects on replication I/O. +5. Full-package test global state, cleanup/initialization ordering and any compile/dependency conflicts that separate scoped tests would miss. + +Current local make verifiers is running; full candidate runtime tests and actual PR CI are still pending. Do not label them as passed. The host previously had ENOSPC; it now has more than 700 GiB free, so standard full-package tests can run without capacity overlays. Historical reports accurately retain earlier environment limits and incomplete multi-process deployment acceptance. + +Two new R6 and four new R8 test files have boilerplate copyright headers queued for repository-policy normalization, after confirming original authorship. They will be header-only changes with package-and-body byte equivalence checked; do not conflate them with production semantic edits. Any actual implementation blocker will be fixed and re-reviewed as needed. + +Return GO_WITH_NONBLOCKING_NOTES or REQUEST_CHANGES, number of blocking findings, exact candidate identity and inspected scope. For each blocker give path/line, real triggering conditions, impact and smallest compatible correction. Clearly distinguish inherited out-of-scope limitations from newly introduced defects and required CI from optional further deployment tests. Do not invent a blocker merely from absence of production deployment. This request is main merge, not release/deploy. diff --git a/docs/investigations/r4-r8-integration/evidence/opus-review.md b/docs/investigations/r4-r8-integration/evidence/opus-review.md new file mode 100644 index 000000000..4130b73d6 --- /dev/null +++ b/docs/investigations/r4-r8-integration/evidence/opus-review.md @@ -0,0 +1,49 @@ +The plan file can't be written (Write is disabled this session), so here is the complete review inline. + +# SILO R4–R8 integration review — independent, source-only + +**Verdict: GO_WITH_NONBLOCKING_NOTES — 0 blocking findings.** + +**Candidate identity:** head `055030ea53ca92ee22ce1e601ef4757c247edde8`, branch `codex/merge-r4-r8`, base `9f3037e941a49ab4cd8a0eed7c0f01083fbe4bbe`, worktree `/Users/vonng/tmp/silo-r4-r8-main-20260916-01a0a5ab/silo`. Scope of this review is a **main merge**, not a release or deployment. I executed nothing — no tests, no builds; every statement below is from reading source. + +**Inspected scope:** `integration-code.diff` in full (3367 lines) and all 22 files in `reviewed-source.json`, then the complete surrounding functions in the worktree: `cmd/bucket-replication.go` (`replicateDelete`, `replicateDeleteToTarget`, `replicateObject`, `replicateAll`, `putReplicationOpts`, `getReplicationAction`, `queueMRFHeal`, `queueReplicaDeleteTask`, `queueMRFSave`, `queueReplicationHeal`), `bucket-replication-utils.go`, `bucket-replication-stats.go`, `xl-storage-format-v2.go DeleteVersion`, `erasure-object.go`, `erasure-multipart.go`, `erasure-server-pool.go`, `erasure-server-pool-consistency.go`, `object-handlers.go`, `object-handlers-common.go`, `object-multipart-handlers.go`, `handler-utils.go`, `object-api-options.go`, `bucket-handlers.go`, `common-main.go`, `server-main.go`, `internal/http/{server,listener}.go`, `internal/deadlineconn/deadlineconn.go`, all six new test files plus the base tests they interact with, `Makefile`, `.golangci.yml`, `AGENTS.md`, `CONTRIBUTING.md`, and the r5/r6/r8 plan + consensus records. Cross-checked against Go 1.27.1 `net/http/server.go` and pinned `minio-go/v7 v7.3.1-0.20260910142817-60bd07042d49`. + +## Independent confirmation of the key claims + +**R6 final empty creation-update invariant (the disproved-v2 point).** The v2 proof fails exactly as recorded: `replStatusRegex` (`bucket-replication-utils.go:168`) matches `arn1=;arn2=;` yielding `{arn1: ";arn2="}` — non-empty — while single-target `arn1=;` does not match at all. The v3 fix at `bucket-replication.go:575-582` sets `ReplicationStatusInternal=""`, `Targets=nil`, `ReplicaStatus=""`; `CompositeReplicationStatus` (`bucket-replication-utils.go:356-379`) then returns empty via both the internal string and the replica fallback, so `xlMetaV2.DeleteVersion` (`xl-storage-format-v2.go:1396-1405`, `1438-1447`) skips the creation/replica write while still writing `VersionPurgeStatusKey` (`1406-1408`, `1448-1450`). `ReplicationTimeStamp` is therefore inert (consensus N5 holds). I also confirmed the counterexample's precondition independently: `erasure-object.go:2099-2105` leaves `deleteMarker=true` when the stored marker carries no purge status, which is what sets `fi.Deleted=true` and reaches the rewriting branch. COMPLETE purges still remove the version (`1379-1393`, `1457-1459`). + +**R6 classification / all exits.** `isVersionPurge()` (`1954-1956`) parses as `VersionID != "" || (DeleteMarkerVersionID != "" && !VersionPurgeStatus().Empty())`. Every live producer emits only one shape (`object-handlers.go:3232-3248`, `bucket-replication.go:3362-3378`, `3814-3836`), and the dir-object `nullVersionID` re-add (`bucket-handlers.go:552-555`, `679-681`) classifies as a purge under both old and new code, so no wire-form change there. All exits of `replicateDeleteToTarget` select the purge field consistently, HEAD probing is creation-only, `ReplicationDeleteMarker` is false for purges. `purgeReplicationStatus` maps only the legacy `COMPLETE` spelling; `ReplicationStats.Update` (`bucket-replication-stats.go:179-237`) consumes the passed status and never `rinfo.ReplicationStatus`, with `ri.Size == 0` giving count-only/zero-byte deltas. The `ResetStatusesMap` nil guard fixes a genuine nil-map assignment panic, since `ObjectToDelete.ReplicationState()` never initialises that map. + +**R6 MRF recovery / budget.** `queueMRFHeal:4118-4125` parses as `(err != nil && !validMarker) || oi.Name == ""`; `decodeDirObject` is identity for both `obj` and `dir/`, matching `GetObjectInfo`'s decoded name, and `erasure-server-pool-consistency.go:143-145` is what returns a populated marker `ObjectInfo` with `MethodNotAllowed`. `RetryCount int` matches the persisted `MRFReplicateEntry.RetryCount` and `QueueReplicationHeal`'s parameter — no on-disk format change. All three increment sites feed the existing `> mrfRetryLimit` drop accounting (`3926-3931`), and the scanner fallback restarts with a fresh budget. + +**R5 tag revision flow.** Sender: `replicationTaggingTimestamp` (`804-812`) serves both the full retransmit and the metadata-COPY branch, the latter now failing closed symmetrically (`1720-1725`). `getCopyObjMetadata:765-766` always emits `X-Amz-Tagging` (possibly empty) + `REPLACE`; minio-go `copyObjectDo` forwards empty header values; the receiver's `getRequestHeaderOrQueryValue` (`handler-utils.go:160-171`) treats presence-with-empty-value as authoritative — so an ordered deletion is genuinely representable on the wire. Receiver: `CopyObjectHandler:1799-1840` captures the stored stamp before REPLACE rebuilds the map, every branch writes or deletes the key explicitly, and the unconditional `delete(encMetadata, …)` is safe *because* of that, blocking the SSE-C rotation snapshot (`1655-1659`) from re-merging at `1910`. `PutObjectHandler:2323-2325` and `NewMultipartUploadHandler:315-318` mutate the same map that becomes `opts.UserDefined` (`object-api-options.go:451`), and the header is parsed only under trusted replication (`388-396`). Ordering is re-applied under the write lock by the existing `reconcileStoredObjectTags` callers (`erasure-object.go:136-139`, `1312-1315`; `erasure-multipart.go:1161-1189`; `erasure-server-pool.go:1443-1456`) — which is also what keeps the base R4 KMS test's `missing-timestamp` expectation intact. Dropping the `ri.UserTags` re-injection in the source ACK (`1294-1304`) is right: `cleanMetadata` strips the tagging key from `UserDefined`, so stored tags are now left alone, and the pools path re-derives them from merged `UserTags`. + +**R7 boundary.** `replicationToInternalHeaders` has exactly six entries (`handler-utils.go:106-114`); `extractReplicationMetadataFromMime` restores only those and re-extracts no ordinary metadata, so the `aws-chunked` normalisation in `extractMetadata:225-241` is not undone. R5 touches neither, and R4's KMS options (`object-api-options.go:449-460`) still carry the three replication timestamps unmodified. + +**R8 deadlines.** Against Go 1.27.1: header window set at `server.go:2038`/`2177`, whole-request deadline unconditionally at `1103`, `StateActive` at `2056-2058` firing after *every* successful `readRequest` because `readRequest` calls `setInfiniteReadLimit()` at `1067`. That is the one place where the naive reading of the `c.r.remain` comment is wrong — the pipelined/fully-buffered request does get the strict→rolling flip, so consensus N2 is correct. `startBackgroundRead` (`741`) and `hijackLocked` zero the deadline, which `infReads` honours — that is why background reads and hijacked grid/websocket conns still work. The strict cap only shortens, never extends; zero/past semantics unchanged; `readExplicit`/`readDeadlineStrict` only touched under `mu`. The h2 skip is defensive rather than load-bearing (net/http uses `skipHooks` for ALPN h2; the h2 server zeroes the conn deadline), and a nil `raw` fails the type assertion safely. Strict mode is opt-in, so every other `DeadlineConn` caller — the optional Linux internode dialer (`dial_linux.go:126-131`, currently disabled at `server-main.go:422`) and all outbound replication transports — keeps legacy rolling reads. The real fix is propagation: flag, field and `UseReadHeaderTimeout` already existed; `ctxt.ReadHeaderTimeout` was simply never populated before `common-main.go:448`. + +**Full-package test state.** No duplicate symbols (`tagTestCapacityDisk` defined once in base `erasure-server-pool-tags_test.go:258`); no helper collisions in `internal/http`; `testdata/config/1.yaml`, `fmtGenFlags`, `serverCmd.Flags` all exist; `buildServerCtxt` mutates no globals. Globals are swapped/restored, and `prepareFS`/`prepareErasure`/`initAPIHandlerTest` re-run `initAllSubsystems` between backends, so leaked target-sys entries can't cross a fixture boundary. `logger.UpdateAuditWebhooks(ctx, nil)` really clears the list (`targets.go:227-273`), so the audit fixture is re-enterable across the SD and Erasure passes. `make verifiers` = lint + check-gen + rebrand-guard, and `.golangci.yml` enables no header linter. + +## Blocking findings + +**None.** + +## Non-blocking notes (newly introduced here) + +1. `bucket-replication.go:804-812` — an unparseable stored tagging timestamp now fails replication for *untagged* objects too. Trigger: a corrupt/legacy value, or the empty value `erasure-server-pool-consistency.go:211-214` can persist when a non-primary pool holds a malformed stamp. Fail-closed (FAILED + MRF retry), never silent divergence. Optional hardening: treat missing-or-empty as absent, keeping the hard error for non-empty malformed values. +2. `bucket-replication.go:1031-1036` — any version with a tag revision now resolves to `replicateMetadata` instead of `replicateNone`; since `object-handlers.go:1835-1836` gives every non-replica COPY a revision, large resyncs issue one extra metadata COPY per object. Steady state is unaffected (COMPLETED gate at `3810`). Accepted in r5 consensus; flagged as operational cost. +3. `bucket-replication.go:435-445`, `551-566`, `1958-1964` — purge audit status changes from legacy `COMPLETE` to `COMPLETED`, and per-target purge outcomes now reach `globalReplicationStats` where the empty creation status previously recorded nothing. Intended (r6 dispositions 1–2); worth one release-note line for audit/metric consumers. +4. `common-main.go:448` — now that `ReadHeaderTimeout` is populated it also participates in `net/http`'s `tlsHandshakeTimeout()` minimum. No change at defaults (30s vs 30s); a small configured header timeout also shortens the TLS handshake window. Documented in r8 consensus N1. +5. `replication-tagging-order_test.go:1805-1814` — `TestAPIPoolsTaggingReplicaDeletion` calls `globalBucketMetadataSys.Update` after `consistencyPools`, which (unlike `initAPIHandlerTest`) does not run `initAllSubsystems`; run in isolation with `-run` it would nil-dereference. Harmless in full-package CI. Smallest fix: use `initAPIHandlerTest`, as the neighbouring consistency tests do. +6. `replication-delete-mrf_test.go:1208-1215` — the audit cleanup cancels targets twice; non-panicking because `SafeClose(nil)` only prints a stack (`internal/ioutil/ioutil.go:463-471`), but it can add stderr noise. +7. Headers (already queued, header-only): the two new R6 test files use `Copyright (c) 2026 PGSTY` + SPDX-only, and the four new R8 test files carry `Copyright (c) 2015-2021 MinIO, Inc.` despite being new original work; CONTRIBUTING.md asks new original files to name their actual holders with AGPL boilerplate (the two new R5 files already comply). No verifier enforces headers, so this does not gate CI and is distinct from production semantics. + +## Inherited / out-of-scope limitations (not introduced by this candidate) + +Purge-target subset merging under narrowed fan-out; nil/missing target clients cannot restore already-lost tracking; the `ResetStatusesMap` key asymmetry between `targetResetHeader(arn)` writes and bare-`arn` reads (`bucket-replication-utils.go:392-399` vs `419-426`) — the candidate only adds the panic-preventing nil guard; the shared `replStatusRegex` still mis-parses serialized empty statuses (R6 deliberately works around it at the write site); unqualified (no `versionId`) replica PUT has no stored-tag reconcile (`object-handlers.go:2442`); TLS handshake *write* deadlines keep rolling behaviour (r8 N9); historical objects without a revision, tag-filter target selection and real multi-site clock skew remain unsolved (r5 consensus). + +## Verification still owed (I ran nothing; nothing below is "passed") + +**Required for this merge — repository gates only:** `make verifiers` (lint, check-gen, rebrand-guard), `make build` producing `silo`, and full-package tests, at minimum `./cmd`, `./internal/http`, `./internal/deadlineconn`; a Linux CI leg to compile and run the build-tagged `TestInternodeDialReadDeadline`; DCO sign-off/authorship checks on the cherry-picked commits. With >700 GiB free, no capacity overlay is needed. + +**Optional, explicitly not a merge gate:** `SILO_TEST_LONG_UPLOAD=1` (>30s default-idle transfer regressions), multi-process/multi-node deployment acceptance, cross-region replication mesh. Their absence is a coverage boundary, not a defect; I do not treat missing production deployment as a blocker. diff --git a/docs/investigations/r4-r8-integration/evidence/runtime-probe.json b/docs/investigations/r4-r8-integration/evidence/runtime-probe.json new file mode 100644 index 000000000..ec291b4c7 --- /dev/null +++ b/docs/investigations/r4-r8-integration/evidence/runtime-probe.json @@ -0,0 +1,27 @@ +{ + "binary": "/Users/vonng/tmp/silo-r4-r8-main-20260916-01a0a5ab/silo/silo", + "binary_sha256": "1cc536f1a3c8d8372ff2d5b140b1fd2bc98a299324fea0f73f67288d48104ce4", + "expected_rejection": true, + "cases": [ + { + "source": "flag", + "header_timeout_ms": 100, + "idle_timeout_ms": 2000, + "header_completion_delay_ms": 400, + "rejected": true, + "status": "", + "elapsed_seconds": 0.403, + "still_alive": true + }, + { + "source": "environment", + "header_timeout_ms": 100, + "idle_timeout_ms": 2000, + "header_completion_delay_ms": 400, + "rejected": true, + "status": "", + "elapsed_seconds": 0.402, + "still_alive": true + } + ] +} diff --git a/docs/investigations/r4-r8-integration/evidence/runtime-probe.result.json b/docs/investigations/r4-r8-integration/evidence/runtime-probe.result.json new file mode 100644 index 000000000..b9fb764a6 --- /dev/null +++ b/docs/investigations/r4-r8-integration/evidence/runtime-probe.result.json @@ -0,0 +1,12 @@ +{ + "command": [ + "python3", + "docs/investigations/r8/evidence/runtime_probe.py", + "/Users/vonng/tmp/silo-r4-r8-main-20260916-01a0a5ab/silo/silo", + "fixed", + "/Users/vonng/tmp/silo-r4-r8-main-20260916-01a0a5ab/runtime-probe" + ], + "exit_code": 0, + "seconds": 1.638, + "stdout_sha256": "e493d9757c130c2531072dc0eaee42b8fc1fa0f45fc43d0d0e098aac55f0d387" +} diff --git a/docs/investigations/r4-r8-integration/evidence/silo-version.log b/docs/investigations/r4-r8-integration/evidence/silo-version.log new file mode 100644 index 000000000..345279356 --- /dev/null +++ b/docs/investigations/r4-r8-integration/evidence/silo-version.log @@ -0,0 +1,6 @@ +silo version DEVELOPMENT.2026-09-15T16-44-34Z (commit-id=80684fed59f556d579e268c5a855d936c1347b68) +Runtime: go1.27.1 darwin/arm64 +License: GNU AGPLv3 - https://www.gnu.org/licenses/agpl-3.0.html +Copyright: 2015-2025 MinIO, Inc. +Modifications: Copyright 2025-2026 PGSTY +Source compatibility: based on MinIO technology diff --git a/docs/investigations/r4-r8-integration/evidence/targeted-race.log b/docs/investigations/r4-r8-integration/evidence/targeted-race.log new file mode 100644 index 000000000..aa23bb22f --- /dev/null +++ b/docs/investigations/r4-r8-integration/evidence/targeted-race.log @@ -0,0 +1,3 @@ +ok github.com/minio/minio/cmd 13.654s +ok github.com/minio/minio/internal/deadlineconn 2.528s +ok github.com/minio/minio/internal/http 14.301s diff --git a/docs/investigations/r4-r8-integration/evidence/validation-results.json b/docs/investigations/r4-r8-integration/evidence/validation-results.json new file mode 100644 index 000000000..fadd64e5d --- /dev/null +++ b/docs/investigations/r4-r8-integration/evidence/validation-results.json @@ -0,0 +1,175 @@ +{ + "head": "80684fed59f556d579e268c5a855d936c1347b68", + "baseline": "9f3037e941a49ab4cd8a0eed7c0f01083fbe4bbe", + "source_sha256": { + "cmd/bucket-replication-utils.go": "365641c760901641e8320cc5123697ab92a46f1add612048b991ed2d1cfad43b", + "cmd/bucket-replication.go": "2e766c5946dcabaea79455b50a8e426f404e55c92dcbccbe28d55906e2e43843", + "cmd/common-main.go": "f8777fe8a07d175aceee07b4dd13792b2384449c404c004c38a06893be997843", + "cmd/erasure-object.go": "4bc848685ea714d88cabbd5d1b8585fbcc06f7b19c775e1a811030e783d0e1a4", + "cmd/erasure-server-pool-consistency.go": "d2736ef6bffbb5c5758eba8df38f8d4ecb888a838ab0de8ad3cf015c051f8ad7", + "cmd/erasure-server-pool.go": "87ad0b25dfa3081d0e63d0073b788614a9c88e2498a2ce0956b93f8a0a03ef53", + "cmd/object-handlers-common.go": "101bd7d7447072d13fed50983b69b562e4725632645e623d7fdd490f388ecdec", + "cmd/object-handlers.go": "61897a260f3f5f660f41edcb50956c60e914ef98f9a987da824f16d78171fde2", + "cmd/object-multipart-handlers.go": "d9622c69c540ab32dd23916e3f534b6886473a98370c9dd17673e69a423b2a7e", + "cmd/replication-delete-marker_test.go": "d967787804d558ac6266b113228fdf4a4f9fcb7cab39138a4fb07558814ccca4", + "cmd/replication-delete-mrf_test.go": "6fcfaf505d28f98e42dff9c0d965895be2c2d112e9e996220d424aea1f76d691", + "cmd/replication-delete-operation_test.go": "2e674cab5ca4dbc38cb2c1ddcca117269276e6419e1869c154c3bd6a05676715", + "cmd/replication-tagging-order_test.go": "c8260b4ccf82fa615e1e24b35a07f2d1aacbcf776e5c6f9dadffea4a09ad6ea8", + "cmd/replication-tagging-sender_test.go": "3770a1a48a6efe58fe8127e1e4fdf6bd7cf171e17db20f15222ea2f7b85db1af", + "cmd/server-main.go": "04c265de211412ba0297396928096d7f2d971244a957a3126154846035263514", + "cmd/server_deadline_config_test.go": "a8259d273922a8973b44d9a468791761d373fe265fff8b2b53871e4626b11405", + "internal/deadlineconn/deadlineconn.go": "b9272ef640f1d4403b3d0af6cdbaba9186c51ad9a0226dfe449e8ef738e1ec4b", + "internal/deadlineconn/deadlineconn_strict_test.go": "b4aec28c5daddb36e6ebb98dd8af2a44b7a2c48f0660068534f69669609f67a7", + "internal/http/dial_deadline_linux_test.go": "c4c83bda92ba9bac53166453920456134b3d8265cd59101b4203067c67eca59e", + "internal/http/listener.go": "49628575367f6ab9b6986caf594726d74d370f7d2ac4eed582903600b6eb3fa2", + "internal/http/server.go": "b7b0355f2781f8c5f7c77bc910cd4180cd3e5f22a87de41bd35ef119d36b4cdf", + "internal/http/server_deadline_test.go": "87303cc389bf1cf759898489f06b001073de2dd9c4b3687c4eca14aa186b62ab" + }, + "no_capacity_overlay": true, + "race_test_selection": [ + "TestAPILocalTaggingAlwaysAdvancesRevision", + "TestAPIPoolsTaggingReplicaDeletion", + "TestAPITaggingMultipartCommitRechecksRevision", + "TestAPITaggingReplicationOrdering", + "TestAPITaggingReplicationOrderingKMS", + "TestAPITaggingSSECRotationPreservesDeletionRevision", + "TestAPITaggingUnqualifiedCopyOrdering", + "TestConcurrentStrictReadDeadline", + "TestDefaultReadDeadlineStillRenews", + "TestInternodeDialReadDeadline", + "TestLocalTaggingCommitCannotRegressRevision", + "TestReplicateDeleteMarkerPurge", + "TestReplicateDeleteMarkerTargetSemantics", + "TestReplicateDeleteOperationExits", + "TestReplicateDeletePurgeMissingTargetState", + "TestReplicationDeleteQueueFullRetryBudget", + "TestReplicationMRFMarkerRecovery", + "TestServerBackgroundReadNoDeadline", + "TestServerConnStateHook", + "TestServerContinuousDownload", + "TestServerContinuousUpload", + "TestServerDefaultIdleLongDownload", + "TestServerDefaultIdleLongUpload", + "TestServerEarlyBodyClose", + "TestServerHTTP2Deadlines", + "TestServerHijackedDeadline", + "TestServerIdleBodyDeadline", + "TestServerKeepAliveDeadline", + "TestServerPipelinedDeadline", + "TestServerReadHeaderDeadline", + "TestServerReadHeaderTimeoutConfig", + "TestServerTLSHandshakeReadDeadline", + "TestStrictExpiredFutureReadDeadline", + "TestStrictReadDeadline", + "TestStrictReadDeadlineRepeatedRenewal", + "TestTaggingProductionCopyWireShape", + "TestTaggingRepeatedValueNeedsRevisionDelivery", + "TestTaggingReplicaContentDuplicateGuard", + "TestTaggingReplicationSenderRetryAndAcknowledgment", + "TestTaggingTimestampWire" + ], + "started_at": "2026-09-15T16:45:09.640646+00:00", + "checks": [ + { + "name": "make-verifiers-final", + "command": [ + "make", + "verifiers", + "GOLANGCI=/Users/vonng/tmp/silo-r4-r8-main-20260916-01a0a5ab/golangci-serial" + ], + "env": { + "GOMAXPROCS": "4", + "GOFLAGS": "-p=2", + "MINIO_API_REQUESTS_MAX": "10000" + }, + "exit_code": 0, + "seconds": 76.933, + "log": "/Users/vonng/tmp/silo-r4-r8-main-20260916-01a0a5ab/make-verifiers-final.log", + "log_sha256": "54c906cff1d33d0148fbc4cac918c0785a3bcc8f7a4eb7e04a2f774c2f010bb4" + }, + { + "name": "make-build", + "command": [ + "make", + "build" + ], + "env": { + "GOMAXPROCS": "4", + "GOFLAGS": "-p=2", + "MINIO_API_REQUESTS_MAX": "10000" + }, + "exit_code": 0, + "seconds": 19.366, + "log": "/Users/vonng/tmp/silo-r4-r8-main-20260916-01a0a5ab/make-build.log", + "log_sha256": "6ba9b545236be964861749c72e7609edf12b8f470df30d1ede8fd62f497e629b" + }, + { + "name": "silo-version", + "command": [ + "./silo", + "--version" + ], + "env": { + "GOMAXPROCS": "4", + "GOFLAGS": "-p=2", + "MINIO_API_REQUESTS_MAX": "10000" + }, + "exit_code": 0, + "seconds": 1.259, + "log": "/Users/vonng/tmp/silo-r4-r8-main-20260916-01a0a5ab/silo-version.log", + "log_sha256": "ada27f2be570c33df5712e86782a7be2ce3acfef54c8bb22a9230db3606513af" + }, + { + "name": "full-tests", + "command": [ + "go", + "test", + "-p", + "2", + "./cmd", + "./internal/...", + "-count=1", + "-timeout=30m" + ], + "env": { + "GOMAXPROCS": "4", + "GOFLAGS": "-p=2", + "MINIO_API_REQUESTS_MAX": "10000", + "CGO_ENABLED": "0" + }, + "exit_code": 0, + "seconds": 340.193, + "log": "/Users/vonng/tmp/silo-r4-r8-main-20260916-01a0a5ab/full-tests.log", + "log_sha256": "24b986e2f5aef0e668116abaab5024bf19ac664aec010ca7aa5ef56886f47ad1" + }, + { + "name": "targeted-race", + "command": [ + "go", + "test", + "-race", + "-p", + "2", + "./cmd", + "./internal/deadlineconn", + "./internal/http", + "-run", + "^(TestAPILocalTaggingAlwaysAdvancesRevision|TestAPIPoolsTaggingReplicaDeletion|TestAPITaggingMultipartCommitRechecksRevision|TestAPITaggingReplicationOrdering|TestAPITaggingReplicationOrderingKMS|TestAPITaggingSSECRotationPreservesDeletionRevision|TestAPITaggingUnqualifiedCopyOrdering|TestConcurrentStrictReadDeadline|TestDefaultReadDeadlineStillRenews|TestInternodeDialReadDeadline|TestLocalTaggingCommitCannotRegressRevision|TestReplicateDeleteMarkerPurge|TestReplicateDeleteMarkerTargetSemantics|TestReplicateDeleteOperationExits|TestReplicateDeletePurgeMissingTargetState|TestReplicationDeleteQueueFullRetryBudget|TestReplicationMRFMarkerRecovery|TestServerBackgroundReadNoDeadline|TestServerConnStateHook|TestServerContinuousDownload|TestServerContinuousUpload|TestServerDefaultIdleLongDownload|TestServerDefaultIdleLongUpload|TestServerEarlyBodyClose|TestServerHTTP2Deadlines|TestServerHijackedDeadline|TestServerIdleBodyDeadline|TestServerKeepAliveDeadline|TestServerPipelinedDeadline|TestServerReadHeaderDeadline|TestServerReadHeaderTimeoutConfig|TestServerTLSHandshakeReadDeadline|TestStrictExpiredFutureReadDeadline|TestStrictReadDeadline|TestStrictReadDeadlineRepeatedRenewal|TestTaggingProductionCopyWireShape|TestTaggingRepeatedValueNeedsRevisionDelivery|TestTaggingReplicaContentDuplicateGuard|TestTaggingReplicationSenderRetryAndAcknowledgment|TestTaggingTimestampWire)$", + "-count=1", + "-timeout=15m" + ], + "env": { + "GOMAXPROCS": "4", + "GOFLAGS": "-p=2", + "MINIO_API_REQUESTS_MAX": "10000", + "CGO_ENABLED": "1" + }, + "exit_code": 0, + "seconds": 46.286, + "log": "/Users/vonng/tmp/silo-r4-r8-main-20260916-01a0a5ab/targeted-race.log", + "log_sha256": "7d021e57e513ea81617df44a6253703622145bf5e72e6180de84c2bd3c7186d3" + } + ], + "source_unchanged": true, + "finished_at": "2026-09-15T16:53:13.682876+00:00" +} diff --git a/docs/investigations/r4-r8-integration/manifest.json b/docs/investigations/r4-r8-integration/manifest.json new file mode 100644 index 000000000..cdf0b950f --- /dev/null +++ b/docs/investigations/r4-r8-integration/manifest.json @@ -0,0 +1,59 @@ +{ + "baseline": "9f3037e941a49ab4cd8a0eed7c0f01083fbe4bbe", + "reviewed_code_commit": "055030ea53ca92ee22ce1e601ef4757c247edde8", + "tested_commit": "80684fed59f556d579e268c5a855d936c1347b68", + "integration_branch": "codex/merge-r4-r8", + "current_source_matches_tested_commit": true, + "production_or_test_body_changes_after_review": false, + "source_sha256": { + "cmd/bucket-replication-utils.go": "365641c760901641e8320cc5123697ab92a46f1add612048b991ed2d1cfad43b", + "cmd/bucket-replication.go": "2e766c5946dcabaea79455b50a8e426f404e55c92dcbccbe28d55906e2e43843", + "cmd/common-main.go": "f8777fe8a07d175aceee07b4dd13792b2384449c404c004c38a06893be997843", + "cmd/erasure-object.go": "4bc848685ea714d88cabbd5d1b8585fbcc06f7b19c775e1a811030e783d0e1a4", + "cmd/erasure-server-pool-consistency.go": "d2736ef6bffbb5c5758eba8df38f8d4ecb888a838ab0de8ad3cf015c051f8ad7", + "cmd/erasure-server-pool.go": "87ad0b25dfa3081d0e63d0073b788614a9c88e2498a2ce0956b93f8a0a03ef53", + "cmd/object-handlers-common.go": "101bd7d7447072d13fed50983b69b562e4725632645e623d7fdd490f388ecdec", + "cmd/object-handlers.go": "61897a260f3f5f660f41edcb50956c60e914ef98f9a987da824f16d78171fde2", + "cmd/object-multipart-handlers.go": "d9622c69c540ab32dd23916e3f534b6886473a98370c9dd17673e69a423b2a7e", + "cmd/replication-delete-marker_test.go": "d967787804d558ac6266b113228fdf4a4f9fcb7cab39138a4fb07558814ccca4", + "cmd/replication-delete-mrf_test.go": "6fcfaf505d28f98e42dff9c0d965895be2c2d112e9e996220d424aea1f76d691", + "cmd/replication-delete-operation_test.go": "2e674cab5ca4dbc38cb2c1ddcca117269276e6419e1869c154c3bd6a05676715", + "cmd/replication-tagging-order_test.go": "c8260b4ccf82fa615e1e24b35a07f2d1aacbcf776e5c6f9dadffea4a09ad6ea8", + "cmd/replication-tagging-sender_test.go": "3770a1a48a6efe58fe8127e1e4fdf6bd7cf171e17db20f15222ea2f7b85db1af", + "cmd/server-main.go": "04c265de211412ba0297396928096d7f2d971244a957a3126154846035263514", + "cmd/server_deadline_config_test.go": "a8259d273922a8973b44d9a468791761d373fe265fff8b2b53871e4626b11405", + "internal/deadlineconn/deadlineconn.go": "b9272ef640f1d4403b3d0af6cdbaba9186c51ad9a0226dfe449e8ef738e1ec4b", + "internal/deadlineconn/deadlineconn_strict_test.go": "b4aec28c5daddb36e6ebb98dd8af2a44b7a2c48f0660068534f69669609f67a7", + "internal/http/dial_deadline_linux_test.go": "c4c83bda92ba9bac53166453920456134b3d8265cd59101b4203067c67eca59e", + "internal/http/listener.go": "49628575367f6ab9b6986caf594726d74d370f7d2ac4eed582903600b6eb3fa2", + "internal/http/server.go": "b7b0355f2781f8c5f7c77bc910cd4180cd3e5f22a87de41bd35ef119d36b4cdf", + "internal/http/server_deadline_test.go": "87303cc389bf1cf759898489f06b001073de2dd9c4b3687c4eca14aa186b62ab" + }, + "compatibility_inventory_sha256": "208c78a9e9fc98d6de7f1e0f03cfec97d8491df65ad4de3d88f04c36c555f819", + "evidence_sha256": { + "evidence/full-tests.log": "24b986e2f5aef0e668116abaab5024bf19ac664aec010ca7aa5ef56886f47ad1", + "evidence/header-equivalence.json": "0aee56aa978515579aa59215152b685f614cd5bda323fe26690a4c21f837f109", + "evidence/integration-equivalence.json": "473711fa8660504275242b70e80622277a32c73e0c7df1de6f5ba2d6bbfcc54e", + "evidence/isolated-pools-before.log": "f014a1a72dda7b973cd1e0b7e77c70b470eeebca0d04805d98f07c2622c87b1e", + "evidence/isolated-pools-before.result.json": "3a761d8a6fd1869a9a9d2b3d506ddec4fdaaad098da2b6a2f85252acf9561774", + "evidence/make-build.log": "6ba9b545236be964861749c72e7609edf12b8f470df30d1ede8fd62f497e629b", + "evidence/make-verifiers-final.log": "54c906cff1d33d0148fbc4cac918c0785a3bcc8f7a4eb7e04a2f774c2f010bb4", + "evidence/opus-integration.metadata.json": "c468e952e2364625ffe04a7221489185705857ad073f6fdb8dee3fe6aad9345c", + "evidence/opus-integration.prompt.md": "f95edcf71afedceab190ff57bfbec812b06c8c18b5370d887131a760228c2b2c", + "evidence/opus-review.md": "463f6b97e8d19929187242724e6bcfc2406217cd82702739716b65e19a8f2360", + "evidence/runtime-probe.json": "e493d9757c130c2531072dc0eaee42b8fc1fa0f45fc43d0d0e098aac55f0d387", + "evidence/runtime-probe.result.json": "d8703c201493cf865d758d53cbc6d92b71535345dafe3c2f27ca2a29ad53e353", + "evidence/silo-version.log": "ada27f2be570c33df5712e86782a7be2ce3acfef54c8bb22a9230db3606513af", + "evidence/targeted-race.log": "7d021e57e513ea81617df44a6253703622145bf5e72e6180de84c2bd3c7186d3", + "evidence/validation-results.json": "29182a752a8ffb75367e8cce51e1f2ef080e453a059248ae2c868aa66627aede" + }, + "original_raw_review": { + "path": "/Users/vonng/tmp/silo-r4-r8-main-20260916-01a0a5ab/opus-integration.jsonl", + "sha256": "3e1a45ebdf3f4420fb05647bb383c00d0a86452a6c357a92679485c99d8f4eb3" + }, + "original_review_diff": { + "path": "/Users/vonng/tmp/silo-r4-r8-main-20260916-01a0a5ab/integration-code.diff", + "sha256": "d8c4e60f9e4a5b1338f3e6e07b758b1bb279db80eec26847e3b35fde0d049485" + }, + "scope": "Local integration validation and independent source review. Final PR checks and remote merge are recorded separately." +} diff --git a/docs/investigations/r5/baseline.md b/docs/investigations/r5/baseline.md new file mode 100644 index 000000000..7211fd88c --- /dev/null +++ b/docs/investigations/r5/baseline.md @@ -0,0 +1,17 @@ +# R5 investigation baseline + +- Worktree: `/Users/vonng/.codex/worktrees/77ad/silo` +- HEAD: `9ebe81c1b3611f9cc73e676b5b741c2be62c467a`; GitHub main checked live on 2026-09-15. +- Branch: `codex/r5-tag-deletion-ordering`. +- WORKFLOW: `/Users/vonng/tmp/silo-r4-r8-20260915-01a0a5ab/WORKFLOW.md` read completely. +- This isolated worktree has no AGENTS.md. Read `/Users/vonng/pgsty/silo/AGENTS.md`: PGSTY supported stack, minimal compatible changes, separate local/merge/release gates. +- Existing tag storage reconciliation is already in HEAD; inspect and reuse it. +- No open R5 PR in live `gh pr list`; unrelated open PRs #184 and #187 belong to R6/R7. +- Parent reproduction: `/Users/vonng/tmp/silo-r4-r8-20260915-01a0a5ab/baseline-evidence/r5-handler.log`. +- Current reproduction overlay and raw output: `/Users/vonng/tmp/silo-r5-20260915-77ad/`. +- Claude Code actual version: 2.1.270 at `/opt/homebrew/bin/claude`. Required model `claude-opus-5`, effort `max`; model identity must be checked in assistant messages. +- Toolchain: go1.27.1 darwin/arm64. Targeted tests use GOMAXPROCS=2 and -p 1 to share the host. + +## Ownership + +R4 owns `cmd/object-api-options.go` KMS common-field preservation and option tests. R5 does not edit that file. R5 owns tag state generation, wire propagation, COPY/PUT/multipart persistence and ordered replay tests. Coordination requested through parent while R4 actual task ID is pending. diff --git a/docs/investigations/r5/consensus.md b/docs/investigations/r5/consensus.md new file mode 100644 index 000000000..913ef144e --- /dev/null +++ b/docs/investigations/r5/consensus.md @@ -0,0 +1,32 @@ +# R5 plan consensus + +Date: 2026-09-15. Research base: `9ebe81c1b3611f9cc73e676b5b741c2be62c467a`. + +## Accepted plan + +- Version: **v2**, `plan-v2.md`. +- SHA256: `5a782acf3f285b23d1ae43a73481c4eb772a9a6d917fc5a550ecfc7cbf7446ca`. +- Actual reviewer: **claude-opus-5**, explicitly invoked **--effort max** through `/opt/homebrew/bin/claude` 2.1.270. All assistant messages in both reviews identify this model. The auxiliary Haiku usage in CLI bookkeeping is separately retained in modelUsage and is not the reviewer. +- Actual result: **APPROVE_WITH_NONBLOCKING_NOTES; 0 blocking items** in opus-v2-review.md. +- Codex accepts this exact v2 and its bounded per-hop scope. Plan hash was checked locally immediately before implementation. Opus's read-only tools did not run hashing; the original caveat is retained in raw review. +- Workflow permission: after this written consensus, local implementation and verification proceed without another user approval. No main merge, remote push, release, deployment or production state rewrite. + +## Discussion and resolved differences + +V1 was REQUEST_CHANGES with five blockers. See opus-v1-response.md for individual treatment and source evidence. V2 resolves all five. Opus explicitly withdrew its empty-only transfer proposal after the same-value re-addition counterexample, corrected its KMS COPY statement after inspecting bucket-default/auto encryption, and accepted that per-pool-only local clock guards are insufficient for ordinary source reads. + +## Nonblocking notes accepted during implementation + +- Extra metadata I/O occurs on scheduled metadata/heal/existing-object work with a recorded revision; ordinary object replication dispatches straight to full transfer. Completed scanner gates and incoming replication suppression avoid a feedback loop. Test the incoming no-reschedule decision. +- Pin unchanged object ModTime for local tagging changes. +- Keep a single-set monotonic guard and one uniform multi-pool candidate; direct-to-set writes outside the pool lock can transiently differ and re-converge on the next pooled mutation. +- Check actual failed COPY status/action and subsequent retry. Malformed timestamps fail both PUT and metadata COPY construction. +- Preserve scope limitations: tag-filter target selection, historical missing revisions, arbitrary unversioned content overwrites, and real multi-site/host-clock skew are not solved or production-accepted here. + +## R4 dependency + +Reuse reviewed local R4 commit `dbcf8dec589deb5d91e17d295cb70997635f5b55` on this isolated branch before implementation. Its only production change is the KMS options field, already examined against the provided patch SHA256 `2d4806d986bbd94ba4bc3951f3aeee48401ee1921c28ded0988fa09ca76ca26f`. R5 does not reimplement or modify that field. This makes R4+R5 tests run on actual combined source, with R5's eventual commit measured against the R4 dependency. + +## Raw records + +`/Users/vonng/tmp/silo-r5-20260915-77ad/opus-v1.jsonl`, `opus-v1.stderr.log`, `opus-v1-request.json`, and matching `opus-v2.*`. In-repository review texts, prompts and metadata preserve plan hashes, model identity, usage and verdicts. V1 failure is not treated as approval. diff --git a/docs/investigations/r5/dependency-handoff.json b/docs/investigations/r5/dependency-handoff.json new file mode 100644 index 000000000..8c97d6ee2 --- /dev/null +++ b/docs/investigations/r5/dependency-handoff.json @@ -0,0 +1,35 @@ +{ + "tested_dependency": "dbcf8dec589deb5d91e17d295cb70997635f5b55", + "merged_dependency": "af2b1794d38d9e70e1d2c3ee692426e4b6cab4bd", + "pr": "https://github.com/pgsty/silo/pull/193", + "files": { + "cmd/object-api-options.go": { + "tested_sha256": "25e2e9484fafd94d1b2c857b94373758e481893ad93fb1a063edf7746277accc", + "merged_sha256": "25e2e9484fafd94d1b2c857b94373758e481893ad93fb1a063edf7746277accc", + "package_body_sha256": "23fa2a25307bf1e41b665217a3f39aa7a8b860686a54209092fa9c0f1f13243a", + "package_body_identical": true + }, + "cmd/object-api-options-replication_test.go": { + "tested_sha256": "1ea2a060987e32a4c76fce96ee974df475944c2d6ab482a4893e33daf7bca849", + "merged_sha256": "c21fc8889a079085d9a882499a1cbe868278a3517580651f3bed1102e2a6aef8", + "package_body_sha256": "1a57a47bdd370042fa0f0d2d90efe447abedee9b9ef48a938d4bed631d83ec0b", + "package_body_identical": true + }, + "cmd/object-copy-replication-tagging_test.go": { + "tested_sha256": "5437a77e68736b4ce69de9c777675251fef24b0352dfe30bd8a836fc7ee810e3", + "merged_sha256": "73f066ed7258d430f078ecc90e551ece878bd3d4672bc762094d434ff8fec23d", + "package_body_sha256": "ef77de91cd91d1bd1c3cb10e4fee171c724c4f548749a23c7e48dcfcfb6a1585", + "package_body_identical": true + } + }, + "other_changes": [ + "cmd/object-api-options-replication_test.go", + "cmd/object-copy-replication-tagging_test.go", + "docs/investigations/r4/implementation-review.md", + "docs/investigations/r4/implementation-review.metadata.json", + "docs/investigations/r4/merge-verification.json", + "docs/investigations/r4/merge-verification.md", + "docs/investigations/r4/verification.md" + ], + "scope": "R5 local branch will rebase onto this exact R4 merge; no R5 push or merge." +} diff --git a/docs/investigations/r5/evidence-manifest.json b/docs/investigations/r5/evidence-manifest.json new file mode 100644 index 000000000..8d9e0558a --- /dev/null +++ b/docs/investigations/r5/evidence-manifest.json @@ -0,0 +1,340 @@ +{ + "raw_files": [ + { + "path": "/Users/vonng/tmp/silo-r5-20260915-77ad/baseline-ack.log", + "bytes": 607, + "sha256": "ef698b8f46c850928057a31bf4e92f64f9a0dcebb8753bab00e9dcef2b44ffb6" + }, + { + "path": "/Users/vonng/tmp/silo-r5-20260915-77ad/baseline-extended.log", + "bytes": 2764, + "sha256": "a52bb6644b82a1986c233deeb9fb7b6cd3f4975337aa11446a1b27c459355db9" + }, + { + "path": "/Users/vonng/tmp/silo-r5-20260915-77ad/baseline-matrix.log", + "bytes": 20974, + "sha256": "5348c2ae4a20238ae50f70bcaea3aa55169b3479f60eab522692bdabe3420ab0" + }, + { + "path": "/Users/vonng/tmp/silo-r5-20260915-77ad/baseline-pools-rotation.log", + "bytes": 2249, + "sha256": "1d47acbe4d759b0f413f90589ff51b1f844f1885885d45d11c72a6295f5a4653" + }, + { + "path": "/Users/vonng/tmp/silo-r5-20260915-77ad/baseline-pools.log", + "bytes": 358, + "sha256": "56dd5efc0c833070576c4c7e2cb2abca8a82380060596be58871067526557c32" + }, + { + "path": "/Users/vonng/tmp/silo-r5-20260915-77ad/baseline-production/cmd/bucket-replication.go", + "bytes": 137081, + "sha256": "1e4d27c9eb2bff51eb28460d167faa3279b541d43c77ce35ad010dcab58bf7c5" + }, + { + "path": "/Users/vonng/tmp/silo-r5-20260915-77ad/baseline-production/cmd/erasure-object.go", + "bytes": 84089, + "sha256": "1012ae265e2453db125c6f2d16f866c72760b58d61c18f79dff4a551c208e3ce" + }, + { + "path": "/Users/vonng/tmp/silo-r5-20260915-77ad/baseline-production/cmd/erasure-server-pool-consistency.go", + "bytes": 14240, + "sha256": "78e63ca1117ea2d3e3e93864a4365dfa9bb303b4707de5087bdc144d0502a0db" + }, + { + "path": "/Users/vonng/tmp/silo-r5-20260915-77ad/baseline-production/cmd/erasure-server-pool.go", + "bytes": 99706, + "sha256": "2bfe0899fe3e42840fa4f078887184e4d7d2332d780ce63c31c9495af7056406" + }, + { + "path": "/Users/vonng/tmp/silo-r5-20260915-77ad/baseline-production/cmd/object-handlers-common.go", + "bytes": 19143, + "sha256": "00bf8409d25f9cf6a098a90f9e0bd7d2b237be7adc12622f0b9a66146af0a828" + }, + { + "path": "/Users/vonng/tmp/silo-r5-20260915-77ad/baseline-production/cmd/object-handlers.go", + "bytes": 146644, + "sha256": "27d47a17e12088f41b35de51da875f28a89a7e821bc27d0f88e6064ec386c993" + }, + { + "path": "/Users/vonng/tmp/silo-r5-20260915-77ad/baseline-production/cmd/object-multipart-handlers.go", + "bytes": 48935, + "sha256": "c818ce72d9115ed4f9cbe51571e7737957010a3934a4d000000895e45100edc7" + }, + { + "path": "/Users/vonng/tmp/silo-r5-20260915-77ad/baseline-production/empty_test.go", + "bytes": 12, + "sha256": "9c78355c4da37df8f708f143fe19173dc146adcd99d1636594d265c5407755bf" + }, + { + "path": "/Users/vonng/tmp/silo-r5-20260915-77ad/baseline-production-overlay.json", + "bytes": 1508, + "sha256": "874d728abc9cb67c5db17ef4c3ce875d789ae4b5000dcbb593fe8dcd47094533" + }, + { + "path": "/Users/vonng/tmp/silo-r5-20260915-77ad/baseline-related-suite.log", + "bytes": 3521, + "sha256": "68b23b21c4de8b8252689f841376dec990257d929f0277d3087f467a246728e8" + }, + { + "path": "/Users/vonng/tmp/silo-r5-20260915-77ad/baseline-resync.log", + "bytes": 116, + "sha256": "a620c0ecd112aceac9fd17b989b6283604a3866928ed51e9ed0b16079284fcbf" + }, + { + "path": "/Users/vonng/tmp/silo-r5-20260915-77ad/baseline.log", + "bytes": 1629, + "sha256": "29d80ad52b0302d4eb4993db7a63c88bbc920923afa9775634d3c2fe000c066f" + }, + { + "path": "/Users/vonng/tmp/silo-r5-20260915-77ad/baseline_test.go", + "bytes": 18743, + "sha256": "ce609764fa53f7a86e77dc8f2c00c6d8b878c4c9b6f885fb2618bf8f932cad04" + }, + { + "path": "/Users/vonng/tmp/silo-r5-20260915-77ad/build-result.json", + "bytes": 729, + "sha256": "2df4873188d94c3745631b6e774e76a7646b3366fab7a3badf7ac1be136f7bd9" + }, + { + "path": "/Users/vonng/tmp/silo-r5-20260915-77ad/cache-reclaim.json", + "bytes": 2482128, + "sha256": "8dc7866b30bfd7fed339a3cd4a2dd40c0ec8f3b471cb004fc14f303a41642ad8" + }, + { + "path": "/Users/vonng/tmp/silo-r5-20260915-77ad/capacity-fixture/cmd/erasure-server-pool-consistency_test.go", + "bytes": 54157, + "sha256": "c3c1bf441e5f97fd2648c5fc9b89cb11679018e349daa4d0eef4ebbfada322db" + }, + { + "path": "/Users/vonng/tmp/silo-r5-20260915-77ad/capacity-fixture/cmd/post-policy_test.go", + "bytes": 33420, + "sha256": "697f8a08dceae481fd1aae7b5e7f3906b55b34fe9928688456eaa943eba79020" + }, + { + "path": "/Users/vonng/tmp/silo-r5-20260915-77ad/capacity-fixture/cmd/test-utils_test.go", + "bytes": 79596, + "sha256": "fb4847b10d3d59c7d62ee79a61d54e8c0bc93d6eb32cb520f5662bb7b52750f5" + }, + { + "path": "/Users/vonng/tmp/silo-r5-20260915-77ad/capacity-fixture-manifest.json", + "bytes": 426, + "sha256": "8940a56a6f46b7e9c236c3a8d39d927735954ae42601ca52c4a190339fb1f21f" + }, + { + "path": "/Users/vonng/tmp/silo-r5-20260915-77ad/capacity-fixture-overlay.json", + "bytes": 368, + "sha256": "3b8586973d426f78145aa25f0c3c9d48faf93678ffe9410fc9aa089cb6167af0" + }, + { + "path": "/Users/vonng/tmp/silo-r5-20260915-77ad/capacity-fixture.patch", + "bytes": 842, + "sha256": "3e3732c7fab2b95b9f2e80a6ee973a588600f84eeb2b74c2f15a09373228247f" + }, + { + "path": "/Users/vonng/tmp/silo-r5-20260915-77ad/capacity-post-manifest.json", + "bytes": 205, + "sha256": "34ae2c860a5cc1a9615d7b410eff781f5f01d54dba65f66edae0f724d3d232c7" + }, + { + "path": "/Users/vonng/tmp/silo-r5-20260915-77ad/capacity-post-overlay.json", + "bytes": 522, + "sha256": "8d0b7594481fa9028fbc4284e1e94cb853828925423d2a7b3cd6f5cbabb82e3c" + }, + { + "path": "/Users/vonng/tmp/silo-r5-20260915-77ad/capacity-post.patch", + "bytes": 358, + "sha256": "f4aa03d4a0a9f0bb220fa3b3b988a8dda1ad7d6764daaeb4e60eb4ee4e996674" + }, + { + "path": "/Users/vonng/tmp/silo-r5-20260915-77ad/discussion-baseline.log", + "bytes": 1029, + "sha256": "9581de36ec9a403d304c32192d17265b1e9c9414c60e9f2cb57321faebbe23dc" + }, + { + "path": "/Users/vonng/tmp/silo-r5-20260915-77ad/final-check-results.json", + "bytes": 939, + "sha256": "6652d2db1ac98d65232e37eda7738972a74f9569ac63b534f1af798ebf19f642" + }, + { + "path": "/Users/vonng/tmp/silo-r5-20260915-77ad/final-post-and-pools.log", + "bytes": 1280, + "sha256": "e7e5fec0761976470eafbf31bcefe4abc241525514f6c3b9a2baa035354144fa" + }, + { + "path": "/Users/vonng/tmp/silo-r5-20260915-77ad/fixed-resync-isolated.log", + "bytes": 116, + "sha256": "bed15b381379998bbe2a0aa1be19c2cfec1b0063886143dbe82918f9652c28ff" + }, + { + "path": "/Users/vonng/tmp/silo-r5-20260915-77ad/fixed-targeted-final.log", + "bytes": 25221, + "sha256": "32283f5d7eea5ce4974fefa0724a1c4de565bb0ef9a0f4fbcad68140bccc32b1" + }, + { + "path": "/Users/vonng/tmp/silo-r5-20260915-77ad/fixed-targeted-latest.log", + "bytes": 32772, + "sha256": "45f362258e21b631cb5ebcd15dec98bd2fa3186f509f18fe216d7cd0ebdb5a9c" + }, + { + "path": "/Users/vonng/tmp/silo-r5-20260915-77ad/fixed-targeted.log", + "bytes": 27709, + "sha256": "a30f7754f90cfade50ed80a066c5e2bd53faf225bd615cd07b9cf1350b2e3139" + }, + { + "path": "/Users/vonng/tmp/silo-r5-20260915-77ad/golangci-lint-serial", + "bytes": 103, + "sha256": "b2a00c2702468165a7851ee3a6addbef9e581833a33492d44ac66d531cfc4fff" + }, + { + "path": "/Users/vonng/tmp/silo-r5-20260915-77ad/implementation-files.json", + "bytes": 936, + "sha256": "7bd1279e1c99da9da4562cda6e0265d36d53a9bb546f10d42c4174c1a34b5c70" + }, + { + "path": "/Users/vonng/tmp/silo-r5-20260915-77ad/implementation-v1.patch", + "bytes": 48399, + "sha256": "8f6f76ee874c43b0827fde272e8a947f118efb1c1af92bf4a02ef88f93554c1b" + }, + { + "path": "/Users/vonng/tmp/silo-r5-20260915-77ad/make-build.log", + "bytes": 55, + "sha256": "6ba9b545236be964861749c72e7609edf12b8f470df30d1ede8fd62f497e629b" + }, + { + "path": "/Users/vonng/tmp/silo-r5-20260915-77ad/make-verifiers-final.log", + "bytes": 217, + "sha256": "d973482061716daa245e7d7162765dec0e6ecd5fee41249d2702a2d8bfce1c32" + }, + { + "path": "/Users/vonng/tmp/silo-r5-20260915-77ad/make-verifiers-serial.log", + "bytes": 410, + "sha256": "e42a5bb55f5c1ebfcf02cebebf6d82cf1ec5a2d74590cdf838deba16dd80bfdf" + }, + { + "path": "/Users/vonng/tmp/silo-r5-20260915-77ad/make-verifiers-success.log", + "bytes": 162, + "sha256": "b4982b6a7302e733c7bec4a5fb36b8ee8865fe95f1595e7079ce7f8455406210" + }, + { + "path": "/Users/vonng/tmp/silo-r5-20260915-77ad/make-verifiers.log", + "bytes": 285, + "sha256": "42f4b147ef4aac45ba91457e7932db30f9b57f285466ee3f10bbaa9a911e5bc7" + }, + { + "path": "/Users/vonng/tmp/silo-r5-20260915-77ad/matrix-overlay.json", + "bytes": 153, + "sha256": "a0fb5eb71ebb2bdb3374813752de5867848454794bcbf302ba0f6d7d4f6c0519" + }, + { + "path": "/Users/vonng/tmp/silo-r5-20260915-77ad/matrix_test.go", + "bytes": 22996, + "sha256": "c9cf527c63800fa045bdf0b8e95d740b81a3d5be3a08c74811fa5c06bed56d4f" + }, + { + "path": "/Users/vonng/tmp/silo-r5-20260915-77ad/opus-implementation-request.json", + "bytes": 909, + "sha256": "9fab15ce1cfb5bad102b1880968e4731a7b5cb02d6d01e6cb2caf8bc9029a150" + }, + { + "path": "/Users/vonng/tmp/silo-r5-20260915-77ad/opus-implementation.jsonl", + "bytes": 1184150, + "sha256": "fef155a7382c8f66f69b7afd5fd94559edcc6f72fc13aeb7ef01319c22c09861" + }, + { + "path": "/Users/vonng/tmp/silo-r5-20260915-77ad/opus-implementation.stderr.log", + "bytes": 0, + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + { + "path": "/Users/vonng/tmp/silo-r5-20260915-77ad/opus-v1-request.json", + "bytes": 216, + "sha256": "175e013154c241805e00368f7841d41faf48ee847ff5251aad96ae57831e244a" + }, + { + "path": "/Users/vonng/tmp/silo-r5-20260915-77ad/opus-v1.jsonl", + "bytes": 1017882, + "sha256": "63c00d8362f236a18293e1a637eff3b7c7e38b0bbd11805f75d91e8774efcdb2" + }, + { + "path": "/Users/vonng/tmp/silo-r5-20260915-77ad/opus-v1.stderr.log", + "bytes": 0, + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + { + "path": "/Users/vonng/tmp/silo-r5-20260915-77ad/opus-v2-request.json", + "bytes": 216, + "sha256": "ed710eff03d3ebabab277c9e453048097a8649582df4b01f99d0ee0ad3a7c831" + }, + { + "path": "/Users/vonng/tmp/silo-r5-20260915-77ad/opus-v2.jsonl", + "bytes": 414265, + "sha256": "e888fbf38ba7fd49891e0757c18006875d02a6bbb325906a31fc8d98e54d0e39" + }, + { + "path": "/Users/vonng/tmp/silo-r5-20260915-77ad/opus-v2.stderr.log", + "bytes": 0, + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + { + "path": "/Users/vonng/tmp/silo-r5-20260915-77ad/overlay.json", + "bytes": 142, + "sha256": "d51934eb99e2b19d149478e090ec327ed2753a5ad2a026c8745b8e2554962a00" + }, + { + "path": "/Users/vonng/tmp/silo-r5-20260915-77ad/related-filter.txt", + "bytes": 5771, + "sha256": "f022bc24ae0fe391ae51a5095db1d2e415a994934327c7508e6c65edc313cc78" + }, + { + "path": "/Users/vonng/tmp/silo-r5-20260915-77ad/related-names.txt", + "bytes": 5767, + "sha256": "369ed4b6742d15e8ab4d790615842304a7178fbdc598e231e9205fc096c0785a" + }, + { + "path": "/Users/vonng/tmp/silo-r5-20260915-77ad/related-suite-capacity-final.log", + "bytes": 137077, + "sha256": "322d4854ba909bd99d5c7740abeeac05eb76cb2d39d2c7541642335ae6a1ffe9" + }, + { + "path": "/Users/vonng/tmp/silo-r5-20260915-77ad/related-suite-capacity.log", + "bytes": 3362, + "sha256": "1e4f1bc6be2b4339a0d9b7a2e951774fc24ec5521be9fcf53b2ce02031f5cdbe" + }, + { + "path": "/Users/vonng/tmp/silo-r5-20260915-77ad/related-suite-rest.log", + "bytes": 172665, + "sha256": "846d10084299a77253153c0eed8546e275c789a0289a4198052773e49a73423f" + }, + { + "path": "/Users/vonng/tmp/silo-r5-20260915-77ad/related-suite.log", + "bytes": 3521, + "sha256": "38e3e8e4b7ae815fce40931009a0d4755601a4f3f7f9f44a569edff024c9239b" + }, + { + "path": "/Users/vonng/tmp/silo-r5-20260915-77ad/sender_test.go", + "bytes": 5140, + "sha256": "a1a58fd6968b41cf6c565d9f63a1d0fa1c907f008f70acfd13c7a6c525376357" + }, + { + "path": "/Users/vonng/tmp/silo-r5-20260915-77ad/silo-version.log", + "bytes": 317, + "sha256": "8ce9c5d15082a78e696aa79f8ec007f72ce969ce6ebd7dab2f7db69b20b51f8b" + }, + { + "path": "/Users/vonng/tmp/silo-r5-20260915-77ad/targeted-race-final.log", + "bytes": 39753, + "sha256": "127cfb73f415bad43e2fd79c05150ab765322dcadb29e687b752b6174d4ad850" + }, + { + "path": "/Users/vonng/tmp/silo-r5-20260915-77ad/verifiers-result.json", + "bytes": 417, + "sha256": "6faea89c420685ccae0642be88ddf86938bb25e24f066fd9e57138d16c9e9856" + } + ], + "binary": { + "path": "/Users/vonng/.codex/worktrees/77ad/silo/silo", + "bytes": 93070802, + "sha256": "dd789126966d4a42bc0a9bcd8b8eab9714e3eadc7a524505a6224ea6d76c750f" + }, + "scope": "Local development build and exact raw verification/review records; no publication or production acceptance." +} diff --git a/docs/investigations/r5/final-implementation-manifest.json b/docs/investigations/r5/final-implementation-manifest.json new file mode 100644 index 000000000..d1d167aeb --- /dev/null +++ b/docs/investigations/r5/final-implementation-manifest.json @@ -0,0 +1,20 @@ +{ + "research_base": "9ebe81c1b3611f9cc73e676b5b741c2be62c467a", + "tested_dependency": "dbcf8dec589deb5d91e17d295cb70997635f5b55", + "reviewed_patch_sha256": "8f6f76ee874c43b0827fde272e8a947f118efb1c1af92bf4a02ef88f93554c1b", + "plan_version": "v2", + "plan_sha256": "5a782acf3f285b23d1ae43a73481c4eb772a9a6d917fc5a550ecfc7cbf7446ca", + "files": { + "cmd/bucket-replication.go": "cbab22ffe316fabc076e7f4a1fa5b1417d07b265ae9dc1b27454689355926d35", + "cmd/erasure-object.go": "4bc848685ea714d88cabbd5d1b8585fbcc06f7b19c775e1a811030e783d0e1a4", + "cmd/erasure-server-pool-consistency.go": "d2736ef6bffbb5c5758eba8df38f8d4ecb888a838ab0de8ad3cf015c051f8ad7", + "cmd/erasure-server-pool.go": "87ad0b25dfa3081d0e63d0073b788614a9c88e2498a2ce0956b93f8a0a03ef53", + "cmd/object-handlers-common.go": "101bd7d7447072d13fed50983b69b562e4725632645e623d7fdd490f388ecdec", + "cmd/object-handlers.go": "61897a260f3f5f660f41edcb50956c60e914ef98f9a987da824f16d78171fde2", + "cmd/object-multipart-handlers.go": "d9622c69c540ab32dd23916e3f534b6886473a98370c9dd17673e69a423b2a7e", + "cmd/replication-tagging-order_test.go": "c8260b4ccf82fa615e1e24b35a07f2d1aacbcf776e5c6f9dadffea4a09ad6ea8", + "cmd/replication-tagging-sender_test.go": "3770a1a48a6efe58fe8127e1e4fdf6bd7cf171e17db20f15222ea2f7b85db1af" + }, + "production_unchanged_after_review": true, + "delivery_dependency": "af2b1794d38d9e70e1d2c3ee692426e4b6cab4bd" +} diff --git a/docs/investigations/r5/implementation-manifest.json b/docs/investigations/r5/implementation-manifest.json new file mode 100644 index 000000000..af39a0b5e --- /dev/null +++ b/docs/investigations/r5/implementation-manifest.json @@ -0,0 +1,17 @@ +{ + "base_commit": "dbcf8dec589deb5d91e17d295cb70997635f5b55", + "plan_version": "v2", + "plan_sha256": "5a782acf3f285b23d1ae43a73481c4eb772a9a6d917fc5a550ecfc7cbf7446ca", + "patch_sha256": "8f6f76ee874c43b0827fde272e8a947f118efb1c1af92bf4a02ef88f93554c1b", + "files": { + "cmd/bucket-replication.go": "cbab22ffe316fabc076e7f4a1fa5b1417d07b265ae9dc1b27454689355926d35", + "cmd/erasure-object.go": "4bc848685ea714d88cabbd5d1b8585fbcc06f7b19c775e1a811030e783d0e1a4", + "cmd/erasure-server-pool-consistency.go": "d2736ef6bffbb5c5758eba8df38f8d4ecb888a838ab0de8ad3cf015c051f8ad7", + "cmd/erasure-server-pool.go": "87ad0b25dfa3081d0e63d0073b788614a9c88e2498a2ce0956b93f8a0a03ef53", + "cmd/object-handlers-common.go": "101bd7d7447072d13fed50983b69b562e4725632645e623d7fdd490f388ecdec", + "cmd/object-handlers.go": "61897a260f3f5f660f41edcb50956c60e914ef98f9a987da824f16d78171fde2", + "cmd/object-multipart-handlers.go": "d9622c69c540ab32dd23916e3f534b6886473a98370c9dd17673e69a423b2a7e", + "cmd/replication-tagging-order_test.go": "64d6dfe3436970caeafcb914157bdedac5982a2105fe72c1753a8d68cf7ed6ef", + "cmd/replication-tagging-sender_test.go": "3770a1a48a6efe58fe8127e1e4fdf6bd7cf171e17db20f15222ea2f7b85db1af" + } +} diff --git a/docs/investigations/r5/implementation-review-response.md b/docs/investigations/r5/implementation-review-response.md new file mode 100644 index 000000000..40f47d5af --- /dev/null +++ b/docs/investigations/r5/implementation-review-response.md @@ -0,0 +1,26 @@ +# R5 implementation review disposition + +Real reviewer: `claude-opus-5`, explicit `--effort max`, session `599b4759-add2-4a41-b5b2-865af7a2c096`. +Verdict: **GO_WITH_NONBLOCKING_NOTES; 0 blockers**. Raw review is preserved verbatim in `opus-implementation-review.md`; model usage, original plan/patch hashes and raw log location are in `opus-implementation-metadata.json`. + +The accepted v2 plan remains immutable. The following implementation notes supplement it; they do not retroactively change the hash on which plan consensus was reached. + +## Nonblocking notes + +- **N1 accepted:** a scheduled metadata COPY can rewrite object data when the receiver applies bucket-default/automatic KMS encryption. Its cost can therefore exceed metadata I/O. The existing completed-object/scanner and incoming-replica scheduling gates still prevent a feedback loop. No new transfer optimization or HEAD protocol is introduced. +- **N2 accepted:** a malformed recorded source tag timestamp fails sender construction and remains a retry failure until an explicit correct tag mutation/repair supplies a valid revision. A missing revision is different from a present invalid/empty value. No historical time is fabricated, and no automatic production rewrite is performed. +- **N3 retained scope:** existing marker/trust/REPLICA/version predicates are preserved. Production sender requests satisfy the relevant predicates; R5 does not broaden replication trust. +- **N4 accepted compatibility change:** a trusted metadata COPY without a source tag revision preserves stored tags, including the metadata-REPLACE shape. This is the deliberate missing-revision rule in plan C, and is tested under UUID/null versions and unqualified COPY. +- **N5 accepted:** ordinary COPY records its chosen tag state, including an empty REPLACE and unchanged tags during key rotation, as a fresh local event. This is consistent with the accepted last-writer-wins scheme. +- **N6 no change:** all production writers use the lowercase reserved timestamp key. Case-insensitive sender lookup is compatible with those writers and existing lock timestamp handling. + +## Coverage notes + +- **L1:** the review was supplied a passing run with **13**, not 12, top-level R5 tests. Its verdict explicitly did not claim execution of the wider tests. The wider selection reproduced the same `TestReplicationResync` order-dependent initialization panic on the unmodified production baseline; that test passes in isolation on both baseline and R5. Host-capacity and actual ENOSPC failures are retained, not reported as passes. Final related, race and static/build results are recorded separately in `verification.md`. An unfiltered full `cmd` package run remains an integration check before any later merge; this task delivers a local patch and does not claim that full-package or multi-site production gate passed. +- **L2 addressed:** after every incoming multi-pool replay, the R5 test now rereads the addressed version through normal pool routing and checks its empty value and deletion revision. The per-pool checks still inspect every retained copy. This prevents a vacuous pass if all copies disappear. The test deliberately allows existing duplicate suppression to retain both identical copies; existing pool cleanup/retry tests separately exercise retirement. +- **L3 accepted boundary:** the combined KMS cases exercise destination encryption and plaintext readback; source fixtures are populated through storage APIs. They do not establish encrypted-source-to-encrypted-destination replication across two running sites. SSE-C key rotation has its own signed HTTP and decrypted GET test. +- **L4 confirmed:** both the actual SDK default metadata directive and peer metadata-REPLACE shapes are exercised. + +## Changes after review + +Production code is unchanged from reviewed patch SHA256 `8f6f76ee874c43b0827fde272e8a947f118efb1c1af92bf4a02ef88f93554c1b`. Test-only follow-up adds the L2 normal-routing read and applies the repository's gofumpt formatting. `implementation-manifest.json` records the exact reviewed files; the final verification manifest records the final files, so the two versions are distinguishable. diff --git a/docs/investigations/r5/opus-implementation-metadata.json b/docs/investigations/r5/opus-implementation-metadata.json new file mode 100644 index 000000000..760cb1128 --- /dev/null +++ b/docs/investigations/r5/opus-implementation-metadata.json @@ -0,0 +1,46 @@ +{ + "model": "claude-opus-5", + "effort": "max", + "baseline": "dbcf8dec589deb5d91e17d295cb70997635f5b55", + "plan_version": "v2", + "plan_sha256": "5a782acf3f285b23d1ae43a73481c4eb772a9a6d917fc5a550ecfc7cbf7446ca", + "patch_sha256": "8f6f76ee874c43b0827fde272e8a947f118efb1c1af92bf4a02ef88f93554c1b", + "actual_assistant_models": [ + "claude-opus-5" + ], + "session_id": "599b4759-add2-4a41-b5b2-865af7a2c096", + "is_error": false, + "modelUsage": { + "claude-haiku-4-5-20251001": { + "inputTokens": 2125, + "outputTokens": 15, + "cacheReadInputTokens": 0, + "cacheCreationInputTokens": 0, + "webSearchRequests": 0, + "costUSD": 0.0022, + "contextWindow": 200000, + "maxOutputTokens": 32000, + "thinkingTokens": 0, + "canonicalModel": "claude-haiku-4-5", + "provider": "firstParty", + "costBasis": "list" + }, + "claude-opus-5": { + "inputTokens": 106, + "outputTokens": 64414, + "cacheReadInputTokens": 7275193, + "cacheCreationInputTokens": 236959, + "webSearchRequests": 0, + "costUSD": 7.618066499999999, + "contextWindow": 1000000, + "maxOutputTokens": 64000, + "thinkingTokens": 45128, + "canonicalModel": "claude-opus-5", + "provider": "firstParty", + "costBasis": "list" + } + }, + "result": "GO_WITH_NONBLOCKING_NOTES", + "blocking_items": 0, + "raw_output": "/Users/vonng/tmp/silo-r5-20260915-77ad/opus-implementation.jsonl" +} diff --git a/docs/investigations/r5/opus-implementation-prompt.md b/docs/investigations/r5/opus-implementation-prompt.md new file mode 100644 index 000000000..140acfa7d --- /dev/null +++ b/docs/investigations/r5/opus-implementation-prompt.md @@ -0,0 +1,27 @@ +Review the actual R5 implementation independently for correctness and regressions, using Claude Opus 5 at max effort. This is a read-only final code review after an already recorded two-round plan consensus. Do not edit files. Do not simulate tests or claim you executed them. Read the relevant source and evidence yourself; focus on material blockers and minimal compatible fixes. + +Working tree: /Users/vonng/.codex/worktrees/77ad/silo +Base dependency commit: dbcf8dec589deb5d91e17d295cb70997635f5b55 (R4 KMS timestamp field, one production addition) +R5 plan v2 SHA256: 5a782acf3f285b23d1ae43a73481c4eb772a9a6d917fc5a550ecfc7cbf7446ca +R5 implementation patch SHA256: 8f6f76ee874c43b0827fde272e8a947f118efb1c1af92bf4a02ef88f93554c1b +Manifest: /Users/vonng/.codex/worktrees/77ad/silo/docs/investigations/r5/implementation-manifest.json +Frozen review patch (7 production files + 2 new tests): /Users/vonng/tmp/silo-r5-20260915-77ad/implementation-v1.patch +Plan: /Users/vonng/.codex/worktrees/77ad/silo/docs/investigations/r5/plan-v2.md +Prior actual review: /Users/vonng/.codex/worktrees/77ad/silo/docs/investigations/r5/opus-v2-review.md +Consensus and disagreements: /Users/vonng/.codex/worktrees/77ad/silo/docs/investigations/r5/consensus.md, /Users/vonng/.codex/worktrees/77ad/silo/docs/investigations/r5/opus-v1-response.md +Baseline reproduction: /Users/vonng/.codex/worktrees/77ad/silo/docs/investigations/r5/reproduction.md +Latest new regression run: /Users/vonng/tmp/silo-r5-20260915-77ad/fixed-targeted-latest.log (PASS, 9.161s; signed HTTP and actual storage on single/16 disks, null/UUID, COPY default and REPLACE, PUT/multipart, KMS plaintext GET, SSE-C rotation, multi-pool, retry and stale source ACK; test names and details in source.) +Additional related suite and race/static validation are ongoing and are not yet accepted. An expanded suite hit existing TestReplicationResync initialization panic before R5 tests; baseline isolation is ongoing. Do not treat that as a proven R5 regression or a passing test. + +Research and implementation points to scrutinize: +1. Empty tag values are ordered states only with recorded timestamp; no fabricated tombstone for empty legacy object. Nonempty legacy sender falls back to ModTime. Malformed stored timestamp fails PUT and metadata COPY sender construction. +2. Local PUT/DELETE tagging timestamps are unconditional, advance under existing storage locks; pooled mutation computes a single revision > every copy; do not mutate caller map. Replicas keep source ordering and equal timestamp stored-wins. +3. Actual sender getCopyObjMetadata + minio Core.CopyObject sends tagging REPLACE with no metadata directive; peers may also send metadata REPLACE. Capture stored timestamp before reconstruction, accept incoming empty value, prevent stale SSE-C encMetadata snapshot overwrite, keep existing storage rechecks. +4. PUT/multipart init persist parsed trusted timestamp. Matching ETag/version no-op is relaxed only for trusted newer tag revision, retaining explicit client preconditions and SSE-C behavior. Multipart completion rechecks revision under lock. +5. Equal visible tag values can hide a newer deletion/re-addition revision, so scheduled metadata/heal work with recorded timestamp sends metadata. Preserve existing Completed/scanner and incoming no-reschedule gates. +6. Replication status ACK no longer writes old queued ri.UserTags over the current tags. Preserve current value/timestamp under metadata lock including multi-pool. +7. Scope explicitly excludes tag-filter target eligibility changes, historical tombstone recovery, arbitrary independent unversioned content replacements, clock skew consensus, and production multi-site acceptance. R4 owns object-api-options.go; it is not in this R5 diff. + +There was an earlier KMS test-only fixture error: it assigned a single-PUT ETag to a multipart-layout retransmission; corrected by seeding a real multipart source and using its actual ETag/layout (full latest run passes plaintext read). Inspect the fixture rather than inferring production encryption correctness solely from prior failed test output. + +Return a clear GO / GO_WITH_NONBLOCKING_NOTES / REQUEST_CHANGES verdict; blocking count; concrete source locations with cause, trigger and effect for any blockers; tests/coverage limitations if material. State which baseline, plan hash and patch hash you reviewed (you may quote supplied hash, but cannot independently compute with Read/Grep/Glob). Do not demand unrelated broad redesign. Distinguish actual source defect from nonblocking improvement or scope limitation. diff --git a/docs/investigations/r5/opus-implementation-review.md b/docs/investigations/r5/opus-implementation-review.md new file mode 100644 index 000000000..a15e68b61 --- /dev/null +++ b/docs/investigations/r5/opus-implementation-review.md @@ -0,0 +1,58 @@ +Write is disabled in this session, so I'll deliver the review directly rather than to the plan file. + +# R5 implementation review — tag deletion and ordered replication + +## Verdict: **GO_WITH_NONBLOCKING_NOTES** — blocking count: **0** + +Read-only review. No files edited, no tests run, no results simulated. Every pass I cite is quoted from the supplied log. + +## What I reviewed + +| Item | Value | +|---|---| +| Working tree | `/Users/vonng/.codex/worktrees/77ad/silo`, branch `codex/r5-tag-deletion-ordering` | +| Baseline dependency | `dbcf8dec589deb5d91e17d295cb70997635f5b55` (R4 SSE-KMS field) | +| Plan | `plan-v2.md`, SHA256 `5a782acf…7fc7446ca` *(quoted)* | +| Patch | `implementation-v1.patch`, SHA256 `8f6f76ee…f88f93554c1b` *(quoted)* | + +**Hash caveat:** with Read/Grep/Glob I **cannot compute** either SHA256 — both are quoted from the manifest/task. What I did verify by reading is that every hunk in the frozen patch is present verbatim in the working tree across all seven production files. That is source identity by inspection, not by digest. I also confirmed R5 does not touch `cmd/object-api-options.go`; the R4 branch carrying `ReplicationSourceTaggingTimestamp` (`object-api-options.go:449-460`) is unmodified. + +## Per-claim findings + +**1. Empty values are ordered states; no fabricated legacy tombstone.** Confirmed. `replicationTaggingTimestamp` (`bucket-replication.go:786-794`) returns the recorded stamp even with empty tags, falls back to `ModTime` only for non-empty tags, zero otherwise. Used by both `putReplicationOpts` (`:861-870`) and the metadata sender (`:1702-1707`). The SDK omits the header for a zero time (`minio-go@…60bd07042d49/api-put-object.go:236-238`, `api-compose-object.go:286-288`), so "no revision" really travels as absence. Malformed stamps fail both constructions. + +**2. Local revisions unconditional and monotonic.** Confirmed. Both handlers mint one `UTCNow()` outside the `dsc.ReplicateAny()` branch (`object-handlers.go:3773-3778`, `:3876-3881`); `getOpts` leaves `opts.UserDefined` nil (`object-api-options.go:110`,`:39`), so the unconditional map replacement drops nothing. `er.PutObjectTags` applies the guard under the existing NS lock (`erasure-object.go:2273-2282`, `:2330-2334`); an absent stamp yields `""` and preserves legacy direct-storage semantics. `z.PutObjectTags` folds one candidate strictly beyond every copy and **clones** first (`erasure-server-pool.go:3054-3062`) — `opts` is a value parameter and `er.PutObjectTags` never writes `opts.UserDefined`, so no caller map is mutated. No replica path reaches `PutObjectTags` (the only two production callers are the tagging handlers), so replicas keep strict source ordering via `reconcileStoredObjectTags`, stored-wins on ties (`erasure-server-pool-consistency.go:238-242`). + +**3. COPY receiver.** Confirmed. Stored pair captured before reconstruction (`object-handlers.go:1800`); `srcInfo.UserTags` is never reassigned between the source read and the decision, so it genuinely is stored state. The decision block (`:1818-1837`) accepts an incoming empty value with a stamp and rechecks the captured state; all existing in-lock rechecks still run (`erasure-object.go:136-138`, `:1312-1315`; `erasure-multipart.go:1161-1190`; `erasure-server-pool.go:1443-1450`). The `encMetadata` fix (`:1840`) is safe and correctly placed — `encMetadata` receives reserved keys only on the SSE-C rotation path (`:1655-1659`), and the delete lands after `rotateKey`/`newEncryptReader` and before the merge at `:1910`. + +**4. PUT/multipart persistence and the duplicate exception.** Confirmed. `putOptsFromHeaders` aliases `opts.UserDefined = metadata` in both branches (`object-api-options.go:451`,`:464`), so post-build writes reach storage (`object-handlers.go:2323-2325`; `object-multipart-handlers.go:315-318`, correctly *after* `maps.Copy(metadata, encMetadata)` at `:300`). The relaxation (`object-handlers-common.go:243-246`) sits below the explicit `If-Match`/`If-None-Match` checks, is gated on `isReplicaTrusted` + `olderThan` (zero source never wins, `bucket-object-lock.go:370-376`), and leaves the SSE-C exemption intact. It cannot loop: once the write lands the stamps are equal and the next attempt 412s. `completeMultipartOpts` sets neither `PreserveETag` nor a tagging timestamp (`object-api-options.go:501-550`), so completion needs no new exception and reconciles under the lock (`object-multipart-handlers.go:1201`). + +**5. Equal values can hide a newer revision.** Confirmed and correctly scoped. The gate (`bucket-replication.go:1013-1018`) sits after the null-version resync exclusion and after **every** branch that can return `replicateAll`; from there only `replicateMetadata`/`replicateNone` are reachable, so it can never downgrade a needed full transfer. It is reached only from `replicationActionForTarget` → `replicateAll` (`:1608`), not from the object-replication fast path (`:1328-1343`). The Completed gate (`:3775`) and failures-only requeue (`:1316`) bound the work, and an incoming replica COPY schedules no outgoing event. Existing fixtures carry no tagging stamp (`bucket-replication_test.go:716-739`), so they are unaffected. + +**6. ACK no longer overwrites current tags.** Confirmed removed (`bucket-replication.go:1276-1286`). Preservation holds on both write-backs: `er.PutObjectMetadata` copies from `ObjectInfo.UserDefined`, which `cleanMetadata` strips of `x-amz-tagging` (`object-api-utils.go:403-407`; `erasure-object.go:2254-2260`); `updatePoolMetadata` falls back to merged `UserTags` and rewrites the merged newest stamp (`erasure-server-pool-consistency.go:194-214`). Both under the object lock (`erasure-object.go:2196-2205`; `erasure-server-pool.go:3020-3029`). The sender also re-reads current state first (`bucket-replication.go:1527-1550`). + +**7. Scope.** Respected — no tag-filter eligibility change, no historical tombstone invention, no clock-skew consensus, no `object-api-options.go` change. + +**Trust boundary re-checked:** the reserved key cannot be injected from the wire — `containsReservedMetadata` rejects the whole `X-Minio-Internal-` class outside the SSE allowlist (`generic-handlers.go:75-85`), and `extractMetadataFromMimeWithReplication` maps only `replicationToInternalHeaders` (`handler-utils.go:258-298`). + +**KMS fixture inspected directly**, not inferred from prior output: the multipart case now seeds a real multipart source and reuses its actual ETag/part layout (`replication-tagging-order_test.go:472-489`). The earlier single-PUT-ETag mismatch is gone. See L3 for what it still does not cover. + +## Non-blocking notes (no change required) + +- **N1 — on encrypted destinations the forced metadata COPY is not metadata-only.** The gate at `bucket-replication.go:1013-1018` yields a replica COPY; with bucket-default/auto KMS the destination applies SSE before `copyDstOpts` (`object-handlers.go:1428-1433`) and then clears `srcInfo.metadataOnly` (`:1669-1677`) — so it **rewrites object data**. Bounded to one COPY per object entering heal and one per object per explicit resync (not a loop), but the plan's "extra metadata I/O" understates this case. Worth a sentence in the cost note. +- **N2 — fail-closed on a malformed stored revision is terminal for that object** (`:786-794` → `:1702-1707`/`:867-870`, requeued by MRF at `:1316-1322`). No production writer can produce such a value, so this is a defensive tail. Note the asymmetry: storage self-heals the same corruption (invalid *stored* ⇒ incoming wins, `erasure-server-pool-consistency.go:238-242`) while the sender refuses to proceed. The minimal hardening, if ever wanted, is to treat a present-but-**empty** value as absent — I traced no reachable path producing one, and the current behavior is what plan and consensus chose, so I am not asking for it. +- **N3 — trusted-marker vs REPLICA asymmetry (pre-existing).** `object-handlers.go:2323` / `object-multipart-handlers.go:316` persist on `opts.ReplicationRequest`, while the in-lock recheck needs `isReplicaTrusted` **and** a version ID (`:2442`). Production sets both; the new precondition exception uses the stricter predicate. Accepted in plan §D. +- **N4 — a trusted metadata COPY with no source revision now ignores the request's tag value** (`object-handlers.go:1826-1833`). For a peer sending `x-amz-metadata-directive: REPLACE` without a revision, the value used to land (`X-Amz-Tagging` is in `supportedHeaders`, `handler-utils.go:271-283`). No MinIO sender produces that shape, and the R4 case `object-copy-replication-tagging_test.go:89` already expects stored-wins there, reaching it via the storage reconcile. Deliberate per plan §C. +- **N5 — ordinary COPY always writes an explicit tag value plus a fresh revision** (`:1834-1837`). (a) `x-amz-tagging-directive: REPLACE` with no tags now genuinely clears the destination, where the default-metadata path used to carry source tags forward — an S3 conformance improvement, covered by `TestAPILocalTaggingAlwaysAdvancesRevision`. (b) An in-place key-rotation COPY advances the revision without changing any value, re-asserting current tags against an older in-flight remote deletion. Both follow from last-writer-wins as specified. +- **N6 — cosmetic.** The key is read case-insensitively at `bucket-replication.go:787`/`:1016`, exactly elsewhere. `TaggingTimestamp` is lowercase (`:74`) and storage writes only lowercase, so they agree; the same mix already exists for lock timestamps in that file (`:896` vs `:1708`). + +## Tests and coverage limitations (material) + +- **L1 — the only established green result is the 12 R5 tests** (`fixed-targeted-latest.log`, `ok … 9.161s`): signed HTTP through real single-disk and 16-disk storage, null/UUID, COPY default and REPLACE, PUT/multipart, KMS, SSE-C rotation, multi-pool, sender retry and stale ACK. The wider `cmd` package, `-race`, `gofmt` and `git diff --check` are ongoing, and the `TestReplicationResync` panic is unattributed. I treat that as an **open verification item**, not a regression and not a pass. My static read found no existing test whose expectations R5 flips — I checked the `getReplicationAction` fixtures, the R4 KMS COPY table including its `missing-timestamp` case (R5 satisfies it via the handler instead of the storage reconcile), and confirmed no pre-existing test calls `PutObjectTags` with a supplied revision. +- **L2 — multi-pool convergence after an *incoming* replica write is asserted weakly.** `replication-tagging-order_test.go:688-698` skips any pool whose version is absent and only asserts a retained copy is correct; it pins neither which pool is retained nor that retirement ran. The case that matters for §A — one revision strictly greater than every copy, in the response *and* every stored copy — is pinned by `TestLocalTaggingCommitCannotRegressRevision`. +- **L3 — the KMS variant's source objects are plaintext.** Seeds use `obj.PutObject` (`:468`, `:477-488`), bypassing handler encryption, so encryption enters only via the incoming request and the destination bucket default. Real coverage of the R4 field on the receive side, but not encrypted-source-to-encrypted-destination end to end. The trailing plaintext GET (`:520-523`) does establish the final object is readable. +- **L4 — wire shape is well pinned.** `TestTaggingProductionCopyWireShape` asserts the real SDK request (`metadata-directive=""`, `tagging-directive=REPLACE`) against a live peer, and `r5Receive`'s `"copy"` operation covers peer metadata-REPLACE independently. Both required shapes are present. + +## Recommendation + +The production diff is merge-eligible as written; I found no actual source defect. The single gating action before merge is closing **L1** — a clean full-package run (plus `-race`, `gofmt`, `git diff --check`) with the `TestReplicationResync` panic isolated against the unpatched baseline. N1 and N2 deserve a sentence each in the plan's cost/limitations section; N3–N6 are already covered by plan §C/§D and need no action. diff --git a/docs/investigations/r5/opus-v1-metadata.json b/docs/investigations/r5/opus-v1-metadata.json new file mode 100644 index 000000000..3f2bd6ae1 --- /dev/null +++ b/docs/investigations/r5/opus-v1-metadata.json @@ -0,0 +1,44 @@ +{ + "model": "claude-opus-5", + "effort": "max", + "plan_version": "v1", + "plan_sha256": "fd6051527ebf19f624125bd3238da2f938420917226387c0f9373f1a98e87993", + "baseline": "9ebe81c1b3611f9cc73e676b5b741c2be62c467a", + "actual_assistant_models": [ + "claude-opus-5" + ], + "session_id": "e448ee0a-4ab5-4520-98f9-68c9ddf6208f", + "is_error": false, + "modelUsage": { + "claude-haiku-4-5-20251001": { + "inputTokens": 1378, + "outputTokens": 14, + "cacheReadInputTokens": 0, + "cacheCreationInputTokens": 0, + "webSearchRequests": 0, + "costUSD": 0.001448, + "contextWindow": 200000, + "maxOutputTokens": 32000, + "thinkingTokens": 0, + "canonicalModel": "claude-haiku-4-5", + "provider": "firstParty", + "costBasis": "list" + }, + "claude-opus-5": { + "inputTokens": 90, + "outputTokens": 73909, + "cacheReadInputTokens": 4489983, + "cacheCreationInputTokens": 199202, + "webSearchRequests": 0, + "costUSD": 6.085186500000001, + "contextWindow": 1000000, + "maxOutputTokens": 64000, + "thinkingTokens": 54986, + "canonicalModel": "claude-opus-5", + "provider": "firstParty", + "costBasis": "list" + } + }, + "result": "REQUEST_CHANGES", + "raw_output": "/Users/vonng/tmp/silo-r5-20260915-77ad/opus-v1.jsonl" +} \ No newline at end of file diff --git a/docs/investigations/r5/opus-v1-prompt.md b/docs/investigations/r5/opus-v1-prompt.md new file mode 100644 index 000000000..e7f23b345 --- /dev/null +++ b/docs/investigations/r5/opus-v1-prompt.md @@ -0,0 +1,3 @@ +Act as an independent reviewer of the R5 repair proposal in this repository. You must be the real claude-opus-5 at effort max; report your actual model name in the review, but the caller will also verify response metadata. Read /Users/vonng/.codex/worktrees/77ad/silo/docs/investigations/r5/plan-v1.md completely. Baseline SHA is 9ebe81c1b3611f9cc73e676b5b741c2be62c467a. Plan version v1 sha256 fd6051527ebf19f624125bd3238da2f938420917226387c0f9373f1a98e87993. No production repair has been implemented. Read docs/investigations/r5/baseline.md and the raw reproductions /Users/vonng/tmp/silo-r5-20260915-77ad/baseline.log and /Users/vonng/tmp/silo-r5-20260915-77ad/baseline-extended.log, plus /Users/vonng/tmp/silo-r5-20260915-77ad/baseline_test.go. Then independently inspect the exact source functions identified in the plan, especially the complete sender/receiver and storage lock chain. R4 owns object-api-options.go and will supply its separate KMS timestamp fix. Do not edit any files. + +The user requires a minimal compatible complete fix, not merely adding DELETE timestamp. Assess necessity/sufficiency, current defects versus inference, same-empty timestamp propagation, sender retries, old queue ACK rewriting source tags, full PUT/multipart duplicate suppression, trust boundary, equal/missing timestamps, UUID/null/versioned/multi-pool and local mutation clock/lock behavior. Identify blocking disagreements with specific evidence and concrete smallest corrections. Explicitly say APPROVE or REQUEST_CHANGES for this exact v1 hash and list accepted/nonblocking/required changes. Do not claim consensus if any blocking issue remains. Be exact about limitations and whether proposed test coverage can establish the scope. Use the permitted Read/Grep/Glob tools for source verification. Return a substantive review, not just a summary. diff --git a/docs/investigations/r5/opus-v1-response.md b/docs/investigations/r5/opus-v1-response.md new file mode 100644 index 000000000..0282af778 --- /dev/null +++ b/docs/investigations/r5/opus-v1-response.md @@ -0,0 +1,30 @@ +# Response to actual Opus v1 review + +The original review is retained verbatim in opus-v1-review.md, with actual model identity/usage in opus-v1-metadata.json and the full stream in the raw evidence directory. Result was REQUEST_CHANGES, five blockers. No implementation was started and no consensus is implied by the response below. + +## Blocking items + +| Item | Disposition for v2 | +|---|---| +| R1 empty/no-revision ModTime fallback | Accepted. Never synthesize a revision for empty tags without one. Keep only existing nonempty ModTime fallback. Add no-revision wire regression. | +| R2 force metadata only for empty values | Disagree with the proposed restriction; accept the I/O cost warning. Same nonempty values can carry different revisions: X@T1, delete@T2, re-add X@T3. Skipping T3 lets delayed delete T2 incorrectly win. A recorded revision requires delivery for either value. v2 explicitly accepts extra COPY per scheduled/resync invocation. queueReplicationHeal already skips Completed unless resync requested; replicateObject only requeues Failed. Thus the predicate is permanently conservative, but it does not create perpetual background work. Avoiding a new HEAD protocol is the smaller implementation. Re-review required. | +| R3 actual metadata COPY request shape | Accepted after inspecting the pinned minio-go Core.CopyObject/copyObjectDo. getCopyObjMetadata supplies only tagging REPLACE; the SDK adds no metadata directive. Update provenance and test both actual SDK shape and peers using metadata REPLACE. | +| R4 local revision inversion | Accepted and strengthened for uniform multi-pool persistence. er.PutObjectTags advances a valid supplied revision beyond stored time under lock. z.PutObjectTags computes one value beyond every addressed copy before writing, so the response, ordinary source read and all copies agree. Per-pool-only guards can produce different times; mergedPoolObjectInfo is not every ordinary read path, so relying on later merge is insufficient for a precise source revision. Direct calls without valid supplied revisions keep old semantics. | +| R5 duplicate suppression wording | Accepted. Explicitly use strictly-newer-than-stored, with existing olderThan semantics. Preserve client preconditions; only trusted REPLICA source timestamps can relax version/ETag duplicate suppression. Document possible data re-upload cost. | + +## Nonblocking items + +- Equal times: document stored-wins consistency across COPY and storage, including null/unqualified requests. +- Invalid COPY sender timestamp: fail the metadata send with Failed, as PUT option construction does; no silent fallback. +- Tag trust versus replica trust: preserve existing predicates, document production supplies both. No permission relaxation. +- KMS nuance: agree PUT/multipart depend on R4, but disagree that metadata COPY never depends on R4. Destination bucket defaults and globalAutoEncryption inject KMS before copyDstOpts at object-handlers.go 1425–1435. R4 independently reproduced all three explicit/default/auto entrypoints. Do not adopt the inaccurate broader exclusion. Combined tests required. +- Ordinary whole-object replacement/no-version clocks: document unchanged semantics. This plan addresses local tagging mutation and selected per-hop replicated version updates; it does not create a new conflict model for independent unversioned content overwrites. +- Tag-filtered target eligibility: document the pre-existing scope limitation; no selection/rule protocol redesign in R5. Final result must not claim arbitrary configuration convergence. +- ACK: additionally reproduced on both real storage backends in baseline-ack.log. The source revives `key=queued` with the deletion's timestamp after old queue event completion. Remove the stale assignment; storage preserves current state. +- Storage lock recheck: reuse and keep existing error behavior. The tests establish per-hop behavior, not a production multi-site deployment or physical clock-skew experiment. + +## Added evidence + +`/Users/vonng/tmp/silo-r5-20260915-77ad/matrix_test.go` contains temporary signed HTTP UUID/null tests, exact SDK COPY wire capture, local timestamp inversion, multi-pool deletes and SSE-C rotation. The original matrix fails baseline as expected. `discussion-baseline.log` isolates R2's equal nonempty case, R3's real SDK shape, and R4's commit inversion. A short first compile missed a test import and was corrected; only the subsequent compile/run is behavioral evidence. + +The v2 plan, not this commentary, is the next consensus target. Production diff remains empty. diff --git a/docs/investigations/r5/opus-v1-review.md b/docs/investigations/r5/opus-v1-review.md new file mode 100644 index 000000000..f2157c242 --- /dev/null +++ b/docs/investigations/r5/opus-v1-review.md @@ -0,0 +1,111 @@ +Write is disabled in this session, so the review is delivered here in full. + +# Independent review — R5 repair plan v1 + +**Reviewer model: `claude-opus-5` (Opus 5), effort max.** I read `docs/investigations/r5/plan-v1.md` completely, `docs/investigations/r5/baseline.md`, both raw logs, `baseline_test.go`, and then independently inspected the sender/receiver/storage chain at `9ebe81c1b`. + +**VERDICT: REQUEST_CHANGES** for plan v1 sha256 `fd6051527ebf19f624125bd3238da2f938420917226387c0f9373f1a98e87993`. Five blocking corrections (R1–R5). **No consensus is claimed.** + +Binding caveat, stated exactly: this session has read-only tools, so I did not execute `sha256sum`. I reviewed the file content at that path at the baseline SHA and cite the plan's own line numbers so you can bind findings to the hash. + +--- + +## 1. Claims I confirmed from source (necessity, not inference) + +| Plan | Claim | Evidence | Verdict | +|---|---|---|---| +| L14 | PUT tagging stamps only when replication is selected; DELETE never stamps | `cmd/object-handlers.go:3762-3768`, `:3865-3870` | **correct** (and PUT uses two separate `UTCNow()` calls, 3765/3767) | +| L14 | both write under the existing object lock; multi-pool updates the addressed version in each copy | `cmd/erasure-object.go:2272-2337`; `cmd/erasure-server-pool.go:3033-3075` | **correct** | +| L15 | `putReplicationOpts` stamps only inside nonempty `UserTags`; multipart clears SourceMTime | `cmd/bucket-replication.go:849-863`, `:1776` | **correct** | +| L16 | metadata COPY carries an explicit empty tag; ModTime default only for nonempty | `:747`, `:1692-1700`; SDK `copyObjectDo:262-264` writes the map verbatim | **correct** | +| L18 | PUT / multipart initiation parse but never persist the source stamp | `cmd/object-api-options.go:473`; no writer anywhere in `cmd/` | **correct** (matches `baseline-extended.log:19-26`) | +| L18 | multipart completion already rechecks the persisted upload under the object lock | `cmd/erasure-multipart.go:1161-1190` | **correct** | +| L19 | `getReplicationAction` compares values/counts, not ordering time | `cmd/bucket-replication.go:1000-1005` | **correct** | +| L20 | `checkPreconditionsPUT` skips matching version/ETag for non-SSE-C replicas | `cmd/object-handlers-common.go:233-247` | **correct**; and senders treat 412 as delivered (`:1466`; multipart `:1786-1788` returns `nil`) | +| L21 | ACK callback copies stale `ri.UserTags` | `cmd/bucket-replication.go:1272-1274` | **correct, and worse than stated** | +| L23 | `reconcileStoredObjectTags` gates as described | `cmd/erasure-server-pool-consistency.go:232-243` | **correct** | + +Two amplifiers the plan does not name, both strengthening it: + +- The ACK callback writes stale tags **without** a timestamp. The revived tag set therefore inherits the *deletion's newer* revision and propagates downstream as authoritative. Removal is the right fix and is sufficient: `er.PutObjectMetadata` preserves `fi.Metadata`'s tag key (`cmd/erasure-object.go:2260`) and `updatePoolMetadata` falls back to merged `UserTags` (`cmd/erasure-server-pool-consistency.go:194-214`). +- The `encMetadata` merge at `cmd/object-handlers.go:1903` restores every reserved key snapshotted at `:1655-1659`; the guard at `:1855-1864` covers only the two Object Lock stamps. Tag revision is genuinely exposed, so L47 is justified. + +--- + +## 2. Blocking disagreements + +### R1 — Do not synthesize a ModTime revision for objects with no tags and no revision +**Where:** L35 ("otherwise object ModTime (also for empty legacy objects)") composed with L49 ("persist a nonzero parsed trusted source timestamp"). + +**Evidence:** `PutObjectOptions.Header()` emits the header whenever `TaggingTimestamp` is non-zero (SDK `api-put-object.go:236-238`). If L35 moves selection outside the nonempty branch *and* defaults to ModTime, every replicated object — including every object that has never carried a tag — ships a non-zero stamp, and L49 persists it. Every object on the destination then owns a tag revision. Composed with L39 (recorded revision ⇒ force metadata replication), **every object at the next hop always selects metadata replication.** It also contradicts L10 ("not a reason to change the storage format") and L57 ("we do not invent historical deletion times"). + +**Smallest correction:** send a stamp only when `objInfo.UserTags != ""` **or** a recorded revision exists. That keeps the tombstone case (empty + revision — the entire point), keeps the existing nonempty ModTime fallback, and drops only empty + no-revision, which L57 already declares unrecoverable. This makes §B consistent with §D. + +### R2 — Bound the forced metadata replication in `getReplicationAction` +**Where:** L39. + +**Evidence:** the destination's revision is invisible to HEAD, so the condition never becomes false. Any object carrying a revision never returns `replicateNone` again: every heal, MRF retry and `ExistingObjectReplicationType` resync re-COPIES its metadata, rewriting `xl.meta` on the destination (and, multi-pool, running `retireReplicaCopies`) each pass. L39's "extra COPY only for already-scheduled work" understates a permanent non-convergence. Existing tests won't catch it — `newMatchingReplicationPair` (`cmd/bucket-replication_test.go:716-739`) carries no revision. + +**Smallest correction:** fire only when `oi1.UserTags == ""` and a revision is recorded — exactly the empty-to-empty tombstone L19 names and `TestReviewR5SameEmptyTagsMustTransferTimestamp` asserts. Nonempty states are already caught by the existing value/count comparison at `:1003`. Then state the residual: tag-deleted objects still never converge to `replicateNone`. + +### R3 — The production metadata COPY does not send `x-amz-metadata-directive: REPLACE` +**Where:** L17. + +**Evidence:** `getCopyObjMetadata` sets `x-amz-tagging-directive: REPLACE` (`:748`) but never the metadata directive, so `getCpObjMetadataFromHeader` takes the `defaultMeta` branch (`cmd/object-handlers.go:1143,1165-1170`). Therefore: +1. "its REPLACE metadata map also loses the previous timestamp before comparison" is **false on the production path** — `defaultMeta` preserves the stored revision. It is true only for a peer that does send REPLACE. +2. The empty tombstone is dropped for a *different* reason than the plan gives: `defaultMeta` carries the stored `X-Amz-Tagging` forward and the `objTags != ""` gate at `:1817` skips the overwrite. (Note `X-Amz-Tagging` is in `supportedHeaders`, `cmd/handler-utils.go:84`, so on the REPLACE path the empty value *does* arrive in the map — only the stamp is missing.) +3. The reproduction sends `x-amz-metadata-directive: REPLACE` (`baseline_test.go:134`), so it **does not pin the production request shape.** The conclusion still holds (with a stale stored stamp the delayed COPY wins either way), but the evidence chain as written is not the one production executes. + +**Smallest correction:** fix L17, and add a sender-shaped COPY case asserting against `getCopyObjMetadata` output rather than a hand-built header map. + +### R4 — Missing monotonic guard on the local revision at commit +**Where:** L31 explicitly asks the reviewer to decide. My answer: commit-time *generation* is not required; a commit-time monotonic *guard* is. + +**Evidence:** `er.PutObjectTags` writes `fi.Metadata[x-amz-tagging]` and copies `opts.UserDefined` with **no ordering check** (`cmd/erasure-object.go:2328-2330`), and the handler mints the stamp *before* the namespace lock. R5 newly makes DELETE mint a revision, so a DELETE→PUT pair can invert — via lock queueing (`globalOperationTimeout` waits) or clock skew between the two nodes serving the two requests. Result: source holds `tags=X @ t_old`, replica holds the tombstone `@ t_new`. Every retransmit is then rejected by `reconcileStoredObjectTags` (`stamp.Before(incoming)` false), the sender still records **Completed**, and — with R2's rule — re-sends forever. Permanent, silent divergence: precisely the failure class R5 exists to remove, newly broadened by change A. + +**Smallest correction:** in `er.PutObjectTags`, under the lock, if the incoming revision is not strictly after the stored one, advance it to stored + 1ns. Multi-pool is safe without a second site of change: `z.PutObjectTags` writes identical tags to all copies, so any per-pool stamp differences still merge to a consistent `(tags, newest stamp)` pair through `mergedPoolObjectInfo` (`cmd/erasure-server-pool-consistency.go:124-131`). + +### R5 — Under-specified duplicate-suppression comparison +**Where:** L51, "a valid newer source tag timestamp makes matching version/ETag insufficient". + +**Evidence:** read naively as "non-zero source stamp ⇒ bypass", this disables the duplicate guard for *every* tagged replica write; for multipart it re-uploads all parts, since 412 at initiation is currently the cheap exit (`:1786-1788`). `TestReviewR5NewerTagsMustBypassContentDuplicate` already encodes the correct comparison (source stamp vs. `oi`'s stored stamp), but the prose does not. + +**Smallest correction:** one sentence — "strictly newer than the destination's stored tag revision" — plus the cost note that even correctly scoped, this re-PUTs object data to deliver a tag-only change. + +--- + +## 3. Accepted / non-blocking (state them; do not necessarily fix) + +- **§A local generation semantics** (L29): accepted as sufficient, subject to R4. Use one `UTCNow()` for both stamps as proposed. +- **§B ACK removal** (L41): necessary and sufficient; preservation verified on both the single-pool and pooled write-back paths. +- **§C `encMetadata` reconciliation** (L47): accepted. Today's observable effect is fail-closed (update dropped) when `ReplicaLockReconcile` is on, and a mismatched `(new tags, old stamp)` pair when `VersionID == ""` — worth one sentence. +- **Equal timestamps:** adopting `reconcileStoredObjectTags` in the handler silently flips the non-versioned COPY path from "incoming wins on equal" (`cmd/object-handlers.go:1824`, `!ondiskTimestamp.After(srcTimestamp)`) to "stored wins on equal". This is the right direction and removes a real handler/storage inconsistency, but it is a compat-visible change and belongs in §D. +- **Missing/invalid timestamps:** the gate asymmetry is correct as the plan describes — invalid *stored* ⇒ incoming wins; invalid *incoming* with valid stored ⇒ stored wins. Note the metadata COPY sender swallows parse errors (`:1696-1699`, `if err == nil`); L35's fail-loud rule should cover that call site too. +- **Trust boundary: sound.** Stamp honored only under `trustedReplication` (`cmd/object-api-options.go:390-396`); headers stripped otherwise (`cmd/replication-trust.go:96-112,139-143`); client-supplied reserved headers rejected wholesale (`cmd/generic-handlers.go:75-85`). One asymmetry to resolve deliberately: the tag decision keys on `dstOpts.ReplicationRequest` (trusted marker) while Object Lock keys on `replicaTrusted` (marker + REPLICA). Production sets both. +- **SSE-KMS / R4 boundary — more precise than the plan:** the KMS early return (`cmd/object-api-options.go:431-461`) drops `ReplicationSourceTaggingTimestamp`, so R5's PUT/multipart persistence is a **silent no-op for KMS-header requests** until R4 lands. The metadata-COPY leg is **not** affected: the public SSE header is synthesized only in responses (`cmd/api-response.go:525-533`), so `getCopyObjMetadata` never forwards it and tag deletions still order correctly for SSE-KMS objects via COPY. Say this instead of only "KMS combined tests after R4". +- **Ordinary PUT/multipart with tags writes no revision,** so §A's invariant has a hole. Mostly masked by versioning; exposed on null/suspended versions, where `ReplicaLockReconcile` is off anyway (`:1847`, `:2432`). Either stamp there too (~3 lines) or document. +- **Tag-filtered replication rules never see a deletion:** `FilterTargetArns` runs on the *post*-deletion `UserTags` (`cmd/bucket-replication.go:1189-1193`; `scheduleReplication` is called with the post-write `oi` at `cmd/object-handlers.go:3879`), so a rule with a tag filter yields no target. Pre-existing, but it bounds any completeness claim. +- **Sender retries are otherwise clean:** `replicateAll` re-reads current state (`:1517`), so retries carry the current revision; staleness was confined to the ACK callback. No new HEAD protocol — agreed. +- **UUID / null / versioned / multi-pool:** `metadataPoolInfos`, `mergedPoolObjectInfo`, `retireReplicaCopies` and the `nullVersionID` normalization (`:136-138`, `:1162-1168`) do carry tag state correctly; I found no additional loss path there beyond R4's clock inversion. + +--- + +## 4. Can the proposed coverage establish the scope? + +**Can:** per-hop handler + storage behavior on real erasure disks (ErasureSD and 16-drive), multi-pool merge/retire through `consistencyPools` and the existing tag suite (`cmd/erasure-server-pool-tags_test.go`), sender option construction, and the ACK race with a deterministic interleave. + +**Cannot:** cross-site convergence, real clock skew, real lock contention. Both `ExecObjectLayerAPITest` instances run in one process against one clock. L73's "Not production multi-site acceptance" should be sharpened to say the suite establishes **per-hop** correctness only. + +**Must add:** (a) an untagged-object regression asserting **no** revision is synthesized (R1); (b) the sender-shaped metadata COPY (R3); (c) a DELETE→PUT inversion, by passing an older stamp directly at the storage call (R4); (d) equal-timestamp parity across the versioned and null-version COPY paths; (e) an SSE-KMS metadata COPY case marked blocked-on-R4. Also keep the three assertions inside `TestReviewR5DeleteThenDelayedTagUpdate` separable — once change A lands, the first passes and the later two silently depend on it. + +--- + +## 5. Summary + +- **Accepted:** §12.1/.2/.3/.5/.6/.7/.8 defect claims; §A generation model; §B ACK removal; §C `encMetadata` reconciliation, storage-recheck reuse, multipart persistence; §D R4 ownership and the no-migration position. +- **Non-blocking:** equal-timestamp behavior change, trust asymmetry, ordinary-PUT revision hole, tag-filtered rules, swallowed parse error in the COPY sender. +- **Required (blocking):** R1 ModTime fallback scope; R2 forced-replication scope; R3 metadata-directive correction + sender-shaped test; R4 monotonic guard at commit; R5 duplicate-suppression comparison wording. + +Answering L75 directly: §A's existing local timestamp semantics are **not** sufficient without R4. §B's forced metadata synchronization is necessary but **wrongly scoped** (R1, R2); the stale ACK removal is necessary and correctly scoped. §C's duplicate exception is defensible but **under-specified** (R5). And yes — a path can still lose or revive the deletion revision: the DELETE→PUT local inversion (R4), plus the documented SSE-KMS PUT leg and tag-filtered-rule gaps. + +I recommend a v2 addressing R1–R5, then re-review against the new hash. I made no edits. \ No newline at end of file diff --git a/docs/investigations/r5/opus-v2-metadata.json b/docs/investigations/r5/opus-v2-metadata.json new file mode 100644 index 000000000..4597e0717 --- /dev/null +++ b/docs/investigations/r5/opus-v2-metadata.json @@ -0,0 +1,45 @@ +{ + "model": "claude-opus-5", + "effort": "max", + "plan_version": "v2", + "plan_sha256": "5a782acf3f285b23d1ae43a73481c4eb772a9a6d917fc5a550ecfc7cbf7446ca", + "baseline": "9ebe81c1b3611f9cc73e676b5b741c2be62c467a", + "actual_assistant_models": [ + "claude-opus-5" + ], + "session_id": "1723246d-35c6-48de-9693-69f061427fcb", + "is_error": false, + "modelUsage": { + "claude-haiku-4-5-20251001": { + "inputTokens": 1578, + "outputTokens": 19, + "cacheReadInputTokens": 0, + "cacheCreationInputTokens": 0, + "webSearchRequests": 0, + "costUSD": 0.001673, + "contextWindow": 200000, + "maxOutputTokens": 32000, + "thinkingTokens": 0, + "canonicalModel": "claude-haiku-4-5", + "provider": "firstParty", + "costBasis": "list" + }, + "claude-opus-5": { + "inputTokens": 28, + "outputTokens": 30031, + "cacheReadInputTokens": 709817, + "cacheCreationInputTokens": 79637, + "webSearchRequests": 0, + "costUSD": 1.9021934999999999, + "contextWindow": 1000000, + "maxOutputTokens": 64000, + "thinkingTokens": 23251, + "canonicalModel": "claude-opus-5", + "provider": "firstParty", + "costBasis": "list" + } + }, + "result": "APPROVE_WITH_NONBLOCKING_NOTES", + "blocking_items": 0, + "raw_output": "/Users/vonng/tmp/silo-r5-20260915-77ad/opus-v2.jsonl" +} \ No newline at end of file diff --git a/docs/investigations/r5/opus-v2-prompt.md b/docs/investigations/r5/opus-v2-prompt.md new file mode 100644 index 000000000..14ad77d01 --- /dev/null +++ b/docs/investigations/r5/opus-v2-prompt.md @@ -0,0 +1,7 @@ +Independent real Opus 5 at effort max follow-up review. Production sources remain exactly 9ebe81c1b3611f9cc73e676b5b741c2be62c467a; no fix implemented. Consensus target: docs/investigations/r5/plan-v2.md sha256 5a782acf3f285b23d1ae43a73481c4eb772a9a6d917fc5a550ecfc7cbf7446ca. Read that complete v2 plan, docs/investigations/r5/opus-v1-response.md, and your preserved docs/investigations/r5/opus-v1-review.md. Focus on the five prior blocking items and disposition; do not repeat a broad repository audit. Read precise source as needed to decide. + +R1 accepted (no empty/no-revision fallback). R3 confirmed against pinned SDK; actual SDK wire test now in /Users/vonng/tmp/silo-r5-20260915-77ad/matrix_test.go and /Users/vonng/tmp/silo-r5-20260915-77ad/discussion-baseline.log. R4 accepted plus a uniform multi-pool guard before per-set write, since normal GetObjectInfo/GetObjectNInfo can return primary pool and not merged latest revision. R5 explicit strictly-newer-than-stored. R2 challenged: same nonempty value at T1 and T3 can hide an intervening delete at T2; empty-only forcing would drop re-addition T3 and allow delayed delete. We accept one metadata COPY per explicitly scheduled retry/heal/resync for explicit revisions. Completed queueReplicationHeal returns early unless resync (cmd/bucket-replication.go ~3758); replicateObject requeues only Failed (~1305). No endless background loop is introduced. A new HEAD revision protocol seems more complexity than justified; evaluate the concrete correctness/cost tradeoff. Both equal-empty and equal-nonempty baseline action tests in discussion-baseline.log expose loss. + +Correct one nonblocking claim too: although COPY sender does not forward a public KMS header, destination bucket defaults/globalAutoEncryption apply BEFORE copyDstOpts (object-handlers.go 1425-1435), so COPY can still depend on R4. R4 has independently reproduced this. Plan documents tag-filter eligibility and unversioned content overwrite as pre-existing scope limits; per-hop guarantees only. + +Return APPROVE / APPROVE_WITH_NONBLOCKING_NOTES / REQUEST_CHANGES for this EXACT v2 and hash, with explicit blocker count and per-item resolution. If R2 is still blocking, provide the smallest concrete alternative that passes the X@T1/delete@T2/X@T3/reordered-delete case, not an empty-only suggestion. Do not claim consensus with remaining blockers. Do not edit or attempt Write; only return text. Model identity will be verified from raw assistant metadata. diff --git a/docs/investigations/r5/opus-v2-review.md b/docs/investigations/r5/opus-v2-review.md new file mode 100644 index 000000000..b32433042 --- /dev/null +++ b/docs/investigations/r5/opus-v2-review.md @@ -0,0 +1,43 @@ +Write is disabled for this task by your instruction, so the review is returned in full as text. No files were created or edited. + +# Independent follow-up review — R5 repair plan v2 + +**Reviewer model: `claude-opus-5` (Opus 5), effort max.** Read: `docs/investigations/r5/plan-v2.md` (complete), `opus-v1-response.md`, my preserved `opus-v1-review.md`, plus `/Users/vonng/tmp/silo-r5-20260915-77ad/matrix_test.go` (discussion cases) and `discussion-baseline.log`. Source re-read at `9ebe81c1b` only where an item's disposition depended on it. + +**VERDICT: APPROVE_WITH_NONBLOCKING_NOTES** for `docs/investigations/r5/plan-v2.md`, consensus target sha256 `5a782acf3f285b23d1ae43a73481c4eb772a9a6d917fc5a550ecfc7cbf7446ca`. **Blocking items remaining: 0 (zero).** All five v1 blockers are resolved; R2 I withdraw as a blocker on the evidence below. + +Same binding caveat as v1, unchanged: this session has Glob/Grep/Read only, so I did not execute `sha256sum`. I reviewed the file at that path in this worktree at the stated base and cite its line numbers so findings bind to the hash. + +## Per-item resolution + +| Item | Status | Basis | +|---|---|---| +| **R1** empty/no-revision ModTime fallback | **Resolved** | Plan L35 is exactly my v1 correction: revision-when-recorded, existing nonempty ModTime fallback retained, empty + no revision sends nothing. §B and §D (L61) are now consistent. | +| **R2** forced metadata replication scope | **Resolved — my correction was wrong; I withdraw the blocker** | See below. | +| **R3** production COPY request shape | **Resolved, with wire evidence** | `TestTaggingProductionCopyWireShape` (matrix_test.go:436-459) drives the real `minio.Core.CopyObject` against an httptest peer: `metadata-directive="" tagging-directive="REPLACE"` plus the tagging timestamp header (log line 6). L17 now states both shapes and requires covering a peer that sends metadata REPLACE. | +| **R4** local revision inversion | **Resolved and correctly strengthened** | L31's uniform multi-pool requirement is necessary, and **my v1 note was wrong** — see correction 2. `TestLocalTaggingCommitCannotRegressRevision` encodes max-across-pools + 1ns in the response and every copy; it fails baseline (log line 9: stamp stays `01:00:00Z`). | +| **R5** duplicate-suppression wording | **Resolved** | L51 is explicit: valid source revision *strictly newer than the destination's stored tag revision*, `olderThan` semantics, client preconditions preserved, re-upload cost documented. | + +## R2 adjudication — why I withdraw it + +**My proposed restriction was incorrect.** `TestTaggingRepeatedValueNeedsRevisionDelivery` (matrix_test.go:422-435) runs both rows; baseline returns `replicateNone` for equal `""` **and** for equal `"key=same"` with a revision an hour newer (log lines 2-3). An empty-only condition fixes only the first row, so it does not repair X@T1 / delete@T2 / X@T3 with a reordered delete. The scenario is reachable: `er.PutObjectTags` never touches `fi.ModTime` (`cmd/erasure-object.go:2328-2332`), so the full-copy gate `oi1.ModTime.Unix() != oi2.LastModified.Unix()` (`:975-981`) never fires on tagging-only changes and the value comparison at `:1003` is decisive; concurrent workers for the same object are not serialized by revision, and at `:1600-1625` a `replicateNone` result is force-marked Completed, making the loss permanent and silent. + +**My cost rationale is refuted by source, not merely by assertion.** `queueReplicationHeal` returns at `cmd/bucket-replication.go:3768` for `Completed && VersionPurgeStatus.Empty() && !mustResync()`; `replicateObject` requeues only non-Completed at `:1306`. I also checked the feedback path I would have raised in its place: `mustReplicate` returns an empty decision for an incoming replication request (`:270-272`), so a forced COPY cannot schedule a new event at the destination — no active-active ping-pong. And the blast radius is **narrower than the plan claims**: `ObjectReplicationType` dispatches to `ri.replicateObject` (`:1233-1237`), which never calls `getReplicationAction`, so the extra COPY applies only to Metadata/Heal/ExistingObject types. + +**Concrete tradeoff against a HEAD revision protocol.** The sender already extends the HEAD (`sOpts.Set(xhttp.AmzTagDirective, "ACCESS")`, `:1595`), so the idea is not absurd — but the pinned SDK's `extractObjMetadata` (`minio-go@v7.3.1-0.20260910142817.../utils.go:232-277`) preserves only the whitelist plus `x-amz-meta-`/`X-Minio-Meta-`; any `x-minio-internal-*` response header is discarded. Exposing the revision therefore needs (a) a new target-side response header, in a client-visible namespace or behind an SDK whitelist change, (b) a sender-side read path, and (c) a fallback for peers that do not answer — and that fallback is the forced COPY anyway. Strictly more code, a new cross-version wire contract, and the same worst case. The plan's choice is right; L39 already states the residual cost honestly. + +## Corrections to my own v1 non-blocking claims + +1. **KMS / COPY (as you flagged).** My v1 line — "the metadata-COPY leg is **not** affected" — is wrong. The sender indeed forwards no public SSE header, but `CopyObjectHandler` applies the destination bucket's SSE config and `globalAutoEncryption` to `r.Header` at `cmd/object-handlers.go:1428-1433`, *before* `copyDstOpts` → `putOptsFromReq` → `putOpts` → `putOptsFromHeaders`, whose `crypto.S3KMS.IsRequested(hdr)` branch (`cmd/object-api-options.go:431-461`) returns an ObjectOptions carrying the legal-hold and retention timestamps but **not** `ReplicationSourceTaggingTimestamp`. So COPY does depend on R4 whenever the destination bucket has default KMS or auto-encryption is on. Plan L59 states this correctly; do not adopt my broader exclusion. +2. **Multi-pool merge.** My v1 R4 note claimed per-pool stamp differences merge safely via `mergedPoolObjectInfo`. They do not on ordinary reads: `z.GetObjectInfo` → `getLatestObjectInfoWithIdx` (`cmd/erasure-server-pool.go:1121`, `:1032-1072`) returns one pool's `ObjectInfo`, sorted by ModTime with a lowest-index tiebreak — and ModTime is identical across copies for tagging changes. `mergedPoolObjectInfo` is reached only from `replicaObjectInfo` and `updatePoolMetadata` (`cmd/erasure-server-pool-consistency.go:135-147`, `:169-174`). Since the replication sender reads through `GetObjectNInfo`, it can emit a stale primary-pool revision. L31's uniform value is required. + +## Non-blocking notes for v2 + +- **Tighten the cost statement** in L39 to Metadata/Heal/ExistingObject types only (`ObjectReplicationType` bypasses the predicate). It makes the accepted cost smaller and the test targets sharper. +- **Add a termination regression** asserting `mustReplicate` yields no decision for the incoming forced COPY (`:270-272`). That property, not the scanner gates alone, is what makes the rule terminating under bidirectional configurations. +- **Assert ModTime invariance across tagging** (`:2328-2332`). If a future change ever bumped ModTime on tagging, the equal-value skip disappears and the whole cost calculus shifts; a one-line assertion pins the premise. +- **Multi-pool guard mechanics.** `z.PutObjectTags` returns `copies[0]`'s result (`cmd/erasure-server-pool.go:3059-3067`) and is bypassed entirely for `SinglePool()` (`:3035-3036`). Keep the guard in `er.PutObjectTags` as the invariant holder, have it advance only when the passed value is not strictly newer than that set's stored value, and have the response report what was actually written. Transient per-set divergence from a direct-to-set write outside the pool lock re-converges on the next z-level write; say so rather than implying it cannot happen. +- **`replicateNone` branch side effect.** Revisioned objects now reach `applyAction` instead of the force-Completed path at `:1614-1624`, so a failed COPY becomes Failed + MRF rather than a phantom Completed, and `rinfo.ReplicationAction` flips `none`→`metadata`. Right direction; cover the metrics path once. +- **Sender parse asymmetry.** The receiver fails closed on a malformed tag timestamp (`cmd/object-api-options.go:421-424`) while the COPY sender swallows it (`cmd/bucket-replication.go:1695-1700`). L35/L37 covers this; just confirm the COPY call site is in scope, since it is a different function from `putReplicationOpts`. + +I made no edits, launched no agents, and executed nothing. No implementation is proposed or requested here, so I am not calling ExitPlanMode. \ No newline at end of file diff --git a/docs/investigations/r5/plan-v1.md b/docs/investigations/r5/plan-v1.md new file mode 100644 index 000000000..e4549729c --- /dev/null +++ b/docs/investigations/r5/plan-v1.md @@ -0,0 +1,75 @@ +# R5 repair plan v1 — tag deletion and ordered replication + +Status: proposed, no production implementation before real Opus consensus. +Base: `9ebe81c1b3611f9cc73e676b5b741c2be62c467a` (local and GitHub main checked). + +## Problem and necessity + +A tag value and its `x-minio-internal-tagging-timestamp` form one state, including an empty value. Successful DeleteObjectTagging currently leaves the old timestamp. A delayed, authenticated metadata COPY newer than that old timestamp restores deleted tags. The parent and this worktree reproduce this through signed HTTP with real single-drive and 16-drive storage. An empty incoming COPY is also ignored. + +The consequence is persistent incorrect user metadata on the source or replicas, affecting tag-based access/lifecycle behavior. Repair is justified before claiming replication correctness. It is not a reason to change the supported PGSTY dependency graph, storage format, encryption protocol, or Object Lock behavior. + +## Current chain and additional gaps + +1. `PutObjectTaggingHandler` stamps only when replication is selected. `DeleteObjectTaggingHandler` never stamps. Both storage methods write the value under the existing object lock; multi-pool PutObjectTags updates the addressed version in each copy. +2. `putReplicationOpts` sets the timestamp only inside nonempty UserTags. Multipart uses this builder and clears SourceMTime at initiation, so using initiation time as a tag fallback would be wrong. +3. Metadata COPY carries an explicit empty tag via `getCopyObjMetadata`, but only defaults its timestamp to object ModTime for nonempty tags. +4. `CopyObjectHandler` branches on nonempty tags; its REPLACE metadata map also loses the previous timestamp before comparison. +5. `PutObjectHandler` and `NewMultipartUploadHandler` parse the source timestamp but never persist it in metadata. Multipart completion already rechecks the upload's persisted metadata against the addressed destination version under the object lock. +6. `getReplicationAction` compares tag values/counts, not their ordering time. An empty-to-empty deletion revision can be declared complete without transmitting the revision. +7. `checkPreconditionsPUT` skips matching version/ETag for non-SSE-C replicas even when their tag revision is newer. This affects PUT and multipart initiation. Explicit client If-Match/If-None-Match checks must still apply. +8. `replicateObject`'s completion metadata callback copies nonempty `ri.UserTags` from the old queue snapshot. A deletion committed before this worker's current-object read can be overwritten at acknowledgment. A status update must retain the tags read under its own metadata lock. + +The existing storage fix (`3ce831925`) already supplies `reconcileStoredObjectTags` in PUT, COPY, multipart completion and all-pool reconciliation. Its strict ordering keeps stored state on equal timestamps and keeps a valid stored revision against missing/invalid incoming timestamps. Reuse these gates, do not replace them. + +## Proposed minimal changes + +### A. Produce local revisions + +In both PUT tagging and DELETE tagging handlers, allocate opts.UserDefined if needed and assign a single UTCNow RFC3339Nano tag revision for each authorized mutation, independent of current replication selection. Use the same time for ReplicationTimestamp when replication is selected. This also covers empty PUT tagging and mutations while replication is disabled. Persist through existing PutObjectTags/DeleteObjectTags locks. Ordinary COPY must write an empty REPLACE tag and a fresh tag timestamp too. + +This preserves the existing wall-clock conflict model, not a new distributed causal clock. The timestamp is generated before the storage lock as in existing PUT tagging. Reviewer should explicitly assess whether a commit-time generation change is necessary for this scoped repair; if necessary, revise before implementing. Clock skew and simultaneous conflicting equal revisions cannot be completely ordered by this protocol. + +### B. Transport complete state + +Move tag timestamp selection outside the nonempty-value branch in putReplicationOpts. Use recorded RFC3339Nano time when present, otherwise object ModTime (also for empty legacy objects). Malformed recorded timestamps fail option construction rather than being silently treated as fresh. + +Use the same selection for metadata COPY; a small shared timestamp helper is acceptable to prevent inconsistent error/fallback rules. Preserve explicit empty tag REPLACE metadata. Multipart initiation retains this timestamp even though SourceMTime is cleared. + +When getReplicationAction sees a recorded tag revision after the existing identity/full-copy checks, select metadata replication even if visible values match: HEAD does not expose that revision. Do not change the existing null-version resync exclusion. This is an extra COPY only for already-scheduled work with an explicit revision, including retry/heal; it does not add scans or network calls on ordinary object reads. Avoid a new HEAD protocol merely to save that COPY. + +Remove the stale ri.UserTags assignment from replication completion metadata write-back. The callback changes replication status only; existing metadata write-back preserves the current tags and timestamp. + +### C. Accept, order, and persist + +COPY captures the stored UserTags and timestamp before metadata reconstruction. For a trusted replication request with a nonzero source timestamp, install the incoming tag value (including empty) with that timestamp, then reuse reconcileStoredObjectTags against the captured state. A missing source timestamp preserves stored state for metadata COPY. Storage rechecks under its lock, including all pools. Equal timestamps keep stored state. Ordinary COPY generates a fresh revision for its chosen tags, including empty. + +Ensure the final encMetadata merge cannot restore a stale tag timestamp over the accepted pair (SSE-C rotation snapshots reserved keys). Reconcile the timestamp entry in encMetadata with the tag decision before merging, using the existing lock-timestamp pattern. + +PUT and multipart initiation persist a nonzero parsed trusted source timestamp into the existing metadata map before entering storage. Their existing lock/reconcile flags retain the newest state. Completion takes tag state from the persisted upload, not client-supplied completion headers, and orders it again against current state. + +For trusted REPLICA PUT/multipart initiation, a valid newer source tag timestamp makes matching version/ETag insufficient to skip the request. Preserve explicit If-Match/If-None-Match and existing SSE-C behavior. Duplicate/equal/older non-SSE-C writes may keep their existing no-op/412 behavior; the sender treats these as already delivered. Completion does not set PreserveETag and does not need a new duplicate exception. + +### D. Boundaries and compatibility + +R4 owns object-api-options.go SSE-KMS common-field preservation. R5 will not implement it. R4 will provide a reviewed patch for isolated combined KMS verification. R5 owns the handlers, sender and tag-related duplicate exception. + +No migration: historical deletions with missing/wrong timestamps have irrecoverably lost ordering information. We do not invent historical deletion times or rewrite production state. A fresh authenticated tagging mutation after upgrade produces an ordered state. Upgrade both sender and receiver for complete guarantees; older peers may continue to drop empty revisions. No main merge, push, release or deployment is authorized here. + +## Verification matrix + +- Re-run parent overlay on exact HEAD; preserve raw failures. Extend temporary reproduction for PUT/multipart lost persistence, empty-to-empty sender decision, and matching-content newer revision skip. +- Signed HTTP tag PUT/delete, active replication and no selected replication, empty PUT, repeated DELETE; check response and persisted tag/time, worker scheduling when active. +- COPY old/new/equal/missing/invalid source time, empty/nonempty, metadata COPY/REPLACE, explicit UUID/null version; protect unrelated/latest versions and local empty COPY. +- Production putReplicationOpts/SDK headers and actual metadata sender requests: explicit empty tombstone, legacy ModTime fallback, nanoseconds, malformed timestamp. Equal empty values must still send ordered deletion; exercise retry after failed send. +- PUT and multipart through signed requests/SDK: first receipt, newer removal, older replay after removal, duplicate receipt, missing timestamp. For multipart, mutate tags between initiation and completion and check final disk state. +- Multi-pool real-storage fixture with duplicate UUID/null versions, newer tag state in secondary pool, all-pool persistence/retirement; reuse current tag storage suite and failure-closed coverage. Include a deterministic update after handler snapshot to show storage lock recheck. +- Old queued replication event followed by deletion: process event and confirm source completion callback cannot restore tags; check tag/time after reread. +- Run related replication trust, Object Lock/SSE-C retransmit, tag storage and API precondition tests; targeted race tests, gofmt, git diff --check. No whole-repository tests in parallel with other R tasks without need. +- KMS combined dependent tests only after R4 reviewed change is available. Report R5-only and combined results separately. + +## Work and acceptance + +Estimated 2–4 engineer days including replication boundary tests and review. The patch should remain localized; added regression code is expected to exceed production LOC. Completion requires a reviewable diff, actual Opus model/effort record, same-plan consensus, meaningful persisted-state tests, and explicit local versus merge/release state. Not production multi-site acceptance. + +Review questions: Are A's existing local timestamp generation semantics sufficient here? Are B's forced metadata synchronization, stale ACK removal and C's narrow duplicate exception necessary and correctly scoped? Is any incoming/forwarding/multi-pool path still able to lose or revive the deletion revision? diff --git a/docs/investigations/r5/plan-v2.md b/docs/investigations/r5/plan-v2.md new file mode 100644 index 000000000..c403c6a90 --- /dev/null +++ b/docs/investigations/r5/plan-v2.md @@ -0,0 +1,81 @@ +# R5 repair plan v2 — tag deletion and ordered replication + +Status: proposed v2, responding to real Opus v1 REQUEST_CHANGES; no production implementation before consensus. +Base: `9ebe81c1b3611f9cc73e676b5b741c2be62c467a` (local and GitHub main checked). + +## Problem and necessity + +A tag value and its `x-minio-internal-tagging-timestamp` form one state, including an empty value. Successful DeleteObjectTagging currently leaves the old timestamp. A delayed, authenticated metadata COPY newer than that old timestamp restores deleted tags. The parent and this worktree reproduce this through signed HTTP with real single-drive and 16-drive storage. An empty incoming COPY is also ignored. + +The consequence is persistent incorrect user metadata on the source or replicas, affecting tag-based access/lifecycle behavior. Repair is justified before claiming replication correctness. It is not a reason to change the supported PGSTY dependency graph, storage format, encryption protocol, or Object Lock behavior. + +## Current chain and additional gaps + +1. `PutObjectTaggingHandler` stamps only when replication is selected. `DeleteObjectTaggingHandler` never stamps. Both storage methods write the value under the existing object lock; multi-pool PutObjectTags updates the addressed version in each copy. +2. `putReplicationOpts` sets the timestamp only inside nonempty UserTags. Multipart uses this builder and clears SourceMTime at initiation, so using initiation time as a tag fallback would be wrong. +3. Metadata COPY carries an explicit empty tag via `getCopyObjMetadata`, but only defaults its timestamp to object ModTime for nonempty tags. +4. `CopyObjectHandler` branches on nonempty tags. Production getCopyObjMetadata + SDK Core.CopyObject sends tagging REPLACE but no metadata directive, so its default metadata map retains the old timestamp. A peer using metadata REPLACE additionally loses that timestamp before the handler comparison. Cover both shapes; the empty-value skip affects both. +5. `PutObjectHandler` and `NewMultipartUploadHandler` parse the source timestamp but never persist it in metadata. Multipart completion already rechecks the upload's persisted metadata against the addressed destination version under the object lock. +6. `getReplicationAction` compares tag values/counts, not their ordering time. An empty-to-empty deletion revision can be declared complete without transmitting the revision. +7. `checkPreconditionsPUT` skips matching version/ETag for non-SSE-C replicas even when their tag revision is newer. This affects PUT and multipart initiation. Explicit client If-Match/If-None-Match checks must still apply. +8. `replicateObject`'s completion metadata callback copies nonempty `ri.UserTags` from the old queue snapshot. A deletion committed before this worker's current-object read can be overwritten at acknowledgment. A status update must retain the tags read under its own metadata lock. + +The existing storage fix (`3ce831925`) already supplies `reconcileStoredObjectTags` in PUT, COPY, multipart completion and all-pool reconciliation. Its strict ordering keeps stored state on equal timestamps and keeps a valid stored revision against missing/invalid incoming timestamps. Reuse these gates, do not replace them. + +## Proposed minimal changes + +### A. Produce local revisions + +In both PUT tagging and DELETE tagging handlers, allocate opts.UserDefined if needed and assign a single UTCNow RFC3339Nano tag revision for each authorized mutation, independent of current replication selection. Use the same time for ReplicationTimestamp when replication is selected. This also covers empty PUT tagging and mutations while replication is disabled. Persist through existing PutObjectTags/DeleteObjectTags locks. Ordinary COPY must write an empty REPLACE tag and a fresh tag timestamp too. + +Under the existing storage write lock, guard local tagging revisions against regression: a valid supplied tag revision not strictly after the valid stored revision is advanced to stored + 1ns. Do this in er.PutObjectTags; for multi-pool writes, z.PutObjectTags first computes one revision strictly beyond every addressed copy and passes that identical value to all sets. This is necessary because ordinary replication source reads/returned primary ObjectInfo can observe one physical pool; merely allowing different pool revisions with equal values can send a revision older than an already-replicated tombstone. Only explicit valid local tag revisions are advanced; replicated PUT/COPY/multipart retain strict source ordering, and direct storage calls without a supplied revision retain current legacy semantics. Generate before locking as today; the guard runs under the lock. Preserve the requested map from unintended shared mutation. This is a per-object monotonic guard in the existing RFC3339Nano domain, not a new wire clock. Equal independent remote conflicting revisions still keep stored state; arbitrary distributed clock skew is not totally ordered by this protocol. + +### B. Transport complete state + +Move tag timestamp selection outside the nonempty-value branch in putReplicationOpts. Use recorded RFC3339Nano time when present even for an empty value. Without a recorded revision, retain the existing ModTime fallback only for nonempty tags; empty + no revision sends no timestamp. This avoids inventing a tombstone for never-tagged/historically unordered objects. Malformed recorded timestamps fail option construction rather than being silently treated as fresh. + +Use the same selection for metadata COPY; a small shared timestamp helper is acceptable to prevent inconsistent error/fallback rules. Preserve explicit empty tag REPLACE metadata. Multipart initiation retains this timestamp even though SourceMTime is cleared. + +When getReplicationAction sees a recorded tag revision after the existing identity/full-copy checks, select metadata replication even if visible values match: HEAD does not expose that revision. Keep this for empty AND nonempty states. Example: destination key=X@T1, source deleted at T2 and re-added key=X@T3; if the equal nonempty state skips T3, delayed delete T2 incorrectly removes X. An empty-only condition does not fix ordered deletion/re-addition. Preserve existing null-version resync exclusion. Cost: every explicitly scheduled retry/heal/resync for an object with a recorded revision may require a metadata COPY, even if already converged. It does not create a background retry loop: queueReplicationHeal returns early for Completed objects without requested resync (bucket-replication.go around 3758), and replicateObject requeues only failed results (around 1305). Successful copies remain Completed. Never-tagged objects retain the old skip optimization under the preceding rule. Accept the extra metadata I/O during explicit resync as the smallest correctness-complete option; avoid introducing an authenticated HEAD revision protocol solely as an optimization. + +Remove the stale ri.UserTags assignment from replication completion metadata write-back. The callback changes replication status only; existing metadata write-back preserves the current tags and timestamp. + +### C. Accept, order, and persist + +COPY captures the stored UserTags and timestamp before metadata reconstruction. For a trusted replication request with a nonzero source timestamp, install the incoming tag value (including empty) with that timestamp, then reuse reconcileStoredObjectTags against the captured state. A missing source timestamp preserves stored state for metadata COPY. Storage rechecks under its lock, including all pools. Equal timestamps keep stored state. Ordinary COPY generates a fresh revision for its chosen tags, including empty. + +Ensure the final encMetadata merge cannot restore a stale tag timestamp over the accepted pair (SSE-C rotation snapshots reserved keys). Reconcile the timestamp entry in encMetadata with the tag decision before merging, using the existing lock-timestamp pattern. + +PUT and multipart initiation persist a nonzero parsed trusted source timestamp into the existing metadata map before entering storage. Their existing lock/reconcile flags retain the newest state. Completion takes tag state from the persisted upload, not client-supplied completion headers, and orders it again against current state. + +For trusted REPLICA PUT/multipart initiation, a valid source tag timestamp strictly newer than the destination's stored tag revision makes matching version/ETag insufficient to skip the request. Use the existing olderThan predicate (zero never wins, valid source beats missing/invalid stored). Preserve explicit If-Match/If-None-Match and existing SSE-C behavior. Duplicate/equal/older non-SSE-C writes may keep their existing no-op/412 behavior; the sender treats these as already delivered. Completion does not set PreserveETag and does not need a new duplicate exception. This narrow exception can re-upload data to carry a tag-only revision; normal metadata work uses COPY, while full retransmission must not silently acknowledge a newer revision it did not persist. + +### D. Boundaries and compatibility + +R4 owns object-api-options.go SSE-KMS common-field preservation. R5 will not implement it. R4 will provide a reviewed patch for isolated combined KMS verification. R5 owns the handlers, sender and tag-related duplicate exception. + +Compatibility: equal-timestamp COPY consistently keeps stored state, including unqualified and explicit null requests; this aligns with existing storage tie behavior and changes the old unqualified handler incoming-wins tie. Preserve the existing tag trust predicate (trusted replication marker) and stronger ReplicaLockReconcile predicate (trusted marker + REPLICA and addressed version), without expanding trust. Production replication supplies both. + +Scope limitations: ordinary full object PUT/multipart creation retain their existing nonempty ModTime fallback; this change does not order independent unversioned content replacements against one another. Existing tag-filter target eligibility can exclude post-deletion empty tags; target-selection semantics are not changed here. This work guarantees correct tag ordering along selected per-hop replication requests, not every replication-rule configuration. Destination bucket-default/global-auto KMS is applied before copyDstOpts (object-handlers.go 1425-1435), so COPY can depend on R4 even when the sender does not forward an explicit encryption header. PUT/multipart KMS also require R4. + +No migration: historical deletions with missing/wrong timestamps have irrecoverably lost ordering information. We do not invent historical deletion times or rewrite production state. A fresh authenticated tagging mutation after upgrade produces an ordered state. Upgrade both sender and receiver for complete guarantees; older peers may continue to drop empty revisions. No main merge, push, release or deployment is authorized here. + +## Verification matrix + +- Re-run parent overlay on exact HEAD; preserve raw failures. Extend temporary reproduction for PUT/multipart lost persistence, empty-to-empty sender decision, and matching-content newer revision skip. +- Signed HTTP tag PUT/delete, active replication and no selected replication, empty PUT, repeated DELETE; check response and persisted tag/time, worker scheduling when active. +- COPY old/new/equal/missing/invalid source time, empty/nonempty, metadata COPY/REPLACE, explicit UUID/null version; protect unrelated/latest versions and local empty COPY. +- Production putReplicationOpts/SDK headers and actual metadata sender requests: explicit empty tombstone, nonempty legacy ModTime fallback, no fabricated empty legacy revision, nanoseconds, malformed timestamp. Equal empty values must still send ordered deletion; equal nonempty values must send re-addition revisions to defeat intervening delayed deletions. Pin actual SDK metadata COPY headers, and cover peer metadata REPLACE independently. Exercise retry after failed send. +- PUT and multipart through signed requests/SDK: first receipt, newer removal, older replay after removal, duplicate receipt, missing timestamp. For multipart, mutate tags between initiation and completion and check final disk state. +- Multi-pool real-storage fixture with duplicate UUID/null versions, newer tag state in secondary pool, all-pool persistence/retirement; reuse current tag storage suite and failure-closed coverage. Include a deterministic update after handler snapshot to show storage lock recheck. A local DELETE-to-PUT inversion with supplied older timestamp must produce one revision greater than the maximum across pools, in both response and every stored copy. +- Old queued replication event followed by deletion: process event and confirm source completion callback cannot restore tags; check tag/time after reread. +- Run related replication trust, Object Lock/SSE-C retransmit, tag storage and API precondition tests; targeted race tests, gofmt, git diff --check. No whole-repository tests in parallel with other R tasks without need. +- KMS combined dependent tests only after R4 reviewed change is available. Report R5-only and combined results separately. + +## Work and acceptance + +Estimated 2–4 engineer days including replication boundary tests and review. The patch should remain localized; added regression code is expected to exceed production LOC. Completion requires a reviewable diff, actual Opus model/effort record, same-plan consensus, meaningful persisted-state tests, and explicit local versus merge/release state. Establishes per-hop behavior with deterministic clock/commit interleaves; not production multi-site or real host-clock-skew acceptance. + +## v2 review focus + +See `opus-v1-response.md` for every blocking/nonblocking disposition and exact counterarguments. R1/R3/R4/R5 accepted with concrete changes. R2 is disputed as proposed: empty-only forced transfer is insufficient for same-value re-addition after deletion, and Completed scanner gates bound work. Please adjudicate on this v2 hash, not on general preference for avoiding metadata I/O. Do not treat unresolved disagreement as consensus. diff --git a/docs/investigations/r5/related-test-names.txt b/docs/investigations/r5/related-test-names.txt new file mode 100644 index 000000000..c0a30ed69 --- /dev/null +++ b/docs/investigations/r5/related-test-names.txt @@ -0,0 +1,135 @@ +TestPeerBucketCorsReplicationOrdering +TestSiteReplicationMetaInfoPreservesCorsTombstone +TestSiteReplicationStatusDetectsCorsTimestampMismatch +TestSiteReplicationStatusCountsCorsPerSite +TestCORSReplicationStateOrdering +TestCORSReplicationStatusStateEquality +TestNewBucketCORSReplicationEvent +TestCorsReplicationDispatchStatusHealReload +TestMarshalUnmarshalReplicationMRFStats +TestEncodeDecodeReplicationMRFStats +TestMarshalUnmarshalBucketReplicationResyncStatus +TestEncodeDecodeBucketReplicationResyncStatus +TestMarshalUnmarshalReplicationState +TestEncodeDecodeReplicationState +TestMarshalUnmarshalTargetReplicationResyncStatus +TestEncodeDecodeTargetReplicationResyncStatus +TestCompositeReplicationStatus +TestReplicationResyncwrapper +TestReplicationValidationObjectUsesRulePrefix +TestGetReplicationActionEmptyObjectLockValues +TestReplicationActionForTargetRetentionRemoval +TestReplicationActionForTargetNullVersionResync +TestReplicationActionForTargetTimestampOnlyRemoval +TestMarshalUnmarshalBucketReplicationStat +TestEncodeDecodeBucketReplicationStat +TestMarshalUnmarshalBucketReplicationStats +TestEncodeDecodeBucketReplicationStats +TestMarshalUnmarshalReplicationLastHour +TestEncodeDecodeReplicationLastHour +TestMarshalUnmarshalReplicationLastMinute +TestEncodeDecodeReplicationLastMinute +TestMarshalUnmarshalReplicationLatency +TestEncodeDecodeReplicationLatency +TestMarshalUnmarshalReplicationQueueStats +TestEncodeDecodeReplicationQueueStats +TestAPISSECCompressionReplicaStaysReadable +TestSSECBatchReplicationCannotRead +TestAPIDeleteObjectVersionDenyAndReplicationCompatibility +TestAPISSECReplicaPartNumberReads +TestAPISSECReplicaMalformedPartIsRejected +TestPoolsDeleteVersionAPI +TestPoolsDeleteVersionUnreadablePool +TestPoolsDeleteVersionSingleCopy +TestPoolsDeleteVersionReplicationPurge +TestPoolsDeleteVersionCleanupFailure +TestPoolsDeleteVersionCallbacks +TestPoolsDeleteVersionSpecialCalls +TestPoolsDeleteUnversionedFanout +TestPoolsConditionalDeleteVersionSelection +TestPoolsConditionalDeleteDuplicateVersion +TestPoolsConditionalDeleteReportsOtherPoolFailure +TestPoolsConditionalDeleteSerializesPut +TestPoolsConditionalDeleteSerializesCompletion +TestPoolsReplicaSerializesMetadataAndHealing +TestPoolsConditionalDeletePreservesVersionHistory +TestPoolsReplicaIndependentLockWinners +TestPoolsReplicaSoleDrainingOwner +TestPoolsMetadataUpdateUsesMergedVersion +TestPoolsReplicaMetadataCopyReconcilesLockAndTags +TestPoolsReplicaCleanupFailureCanRetry +TestPoolsRetiringCopyPreservesSharedTierObject +TestPoolsDeleteVersionAfterInterruptedRebalance +TestPoolsDeleteDirectoryMarker +TestPoolsMultipartConditionalUsesLogicalLatest +TestPoolsMultipartConditionalHTTPMatrix +TestPoolsMultipartConditionalHTTPAbsentObject +TestPoolsMultipartConditionalHTTPNormalRouting +TestPoolsMultipartConditionalUnreadablePool +TestPoolsMultipartConditionalLatestVersionAndCallbackOnce +TestPoolsMultipartConditionalConcurrentCompletes +TestPoolsMultipartConditionMatrix +TestPoolsMultipartConditionBoundaries +TestPoolsMetadataUpdatePreservesTags +TestReplicaWritesPreserveTagOrdering +TestMergedPoolObjectInfoTagOrdering +TestPoolsMetadataCallbackReplacesTags +TestReconcileStoredObjectTagOrdering +TestPoolsMetadataUpdatePreservesAbsentTags +TestExtractReplicationMetadataHeaders +TestGetCopyObjectMetadataFromHeaderReplication +TestCloneRequestWithoutReplicationHeaders +TestIAMServiceAccountReplicationRejectsOtherCredentialKinds +TestIAMServiceAccountReplicationPreservesExpiration +TestPutOptsFromHeadersReplicationTimestamps +TestAPIGetObjectAttributesSSECReplicationAuthz +TestAPICopyObjectSSECKeyRotationReplicaKeepsFastPath +TestAPIFederatedCopyObjectRejectsRawSSECReplica +TestAPICopyObjectReplicaTaggingTimestampUnderKMS +TestCheckPreconditions +TestAPIPutObjectReplicationHeaderPoisoning +TestAPICopyObjectReplicationHeaderPoisoning +TestPostPolicyCannotForgeReplicationStatus +TestReplicationMRFDropsVisible +TestReplicationObjectDeleteWorkerAffinity +TestAPISSECReplicationTargetHead +TestAPISSECReplicaRetransmitOverExistingVersion +TestPutReplicationOptsRetentionRemoval +TestAPISSECReplicaWriteExemptionIsKeyedOnTheIncomingWrite +TestAPISSECReplicaRetransmitObjectLockOrdering +TestAPISSECReplicaRetransmitMultipartObjectLockOrdering +TestPutReplicationOptsRetentionRemovalTimestampOnly +TestAPIReplicaMarkerOnlyAppliesObjectLock +TestAPIReplicaMultipartNewerHoldSurvivesCompletion +TestAPITaggingReplicationOrdering +TestAPITaggingReplicationOrderingKMS +TestAPITaggingMultipartCommitRechecksRevision +TestAPILocalTaggingAlwaysAdvancesRevision +TestTaggingTimestampWire +TestAPIPoolsTaggingReplicaDeletion +TestAPITaggingSSECRotationPreservesDeletionRevision +TestTaggingRepeatedValueNeedsRevisionDelivery +TestTaggingProductionCopyWireShape +TestLocalTaggingCommitCannotRegressRevision +TestTaggingReplicaContentDuplicateGuard +TestAPITaggingUnqualifiedCopyOrdering +TestTaggingReplicationSenderRetryAndAcknowledgment +TestAPISSECReplicaSkipsDestinationTransforms +TestAPISSECMultipartReplicaRoundTripWithCompression +TestPutReplicationOptsRejectsCompressedSSEC +TestAPIReplicationTrustProtectsSSECReads +TestReplicationTrustControlsInternalOptionsAndEvents +TestAPIPutObjectReplicationTrust +TestAPISnowballReplicationTrustIsPerEntry +TestAPIDeleteObjectReplicationTrust +TestAPISSECMultipartReplicationTrust +TestAPIStreamingTrailerWithUntrustedReplicationHeaders +TestAPICopyObjectReplicaLegalHoldTimestamp +TestAPICopyObjectReplicaAbsentLockFieldsPreserveNewerState +TestAPICopyObjectReplicaRetentionRemovalKeepsOrderingTimestamp +TestAPICopyObjectReplicaObjectLockOrdering +TestAPICopyObjectReplicaRetentionRemovalUnderBucketKMS +TestAPICopyObjectReplicaLockTimestampSurvivesSSECKeyRotation +TestBucketPolicyReplicationKey +TestBucketPolicyReplicationStatusLegacyOrder +TestSiteReplicationStatusAccountsPerSiteAndSurvivesMalformedConfig diff --git a/docs/investigations/r5/reproduction.md b/docs/investigations/r5/reproduction.md new file mode 100644 index 000000000..2807406bb --- /dev/null +++ b/docs/investigations/r5/reproduction.md @@ -0,0 +1,38 @@ +# Current-baseline reproduction + +Base: `9ebe81c1b3611f9cc73e676b5b741c2be62c467a`. Go 1.27.1, macOS arm64. Production sources unchanged. Temporary overlay injects regression tests; real erasure disks persist object metadata. Capacity adapter changes only reported capacity to avoid the developer machine's unrelated disk-usage threshold. + +## Commands and raw evidence + +Raw directory: `/Users/vonng/tmp/silo-r5-20260915-77ad/`. + +```sh +GOMAXPROCS=2 go test -p 1 -overlay /Users/vonng/tmp/silo-r5-20260915-77ad/overlay.json ./cmd -run '^TestReviewR5' -count=1 -v +GOMAXPROCS=2 go test -p 1 -overlay /Users/vonng/tmp/silo-r5-20260915-77ad/overlay.json ./cmd -run '^TestReviewR5Queued' -count=1 -v +``` + +Both exit 1, as expected before repair. Files: `baseline.log`, `baseline-extended.log`, `baseline-ack.log`; the full injected source is `baseline_test.go`. + +## Observations + +| Regression | Observed result | +|---|---| +| Empty source tags with explicit revision | putReplicationOpts returns zero TaggingTimestamp | +| Signed HTTP DELETE on a versioned object with replication selected | 204, empty tags, one queued event, unchanged old timestamp | +| Delayed signed trusted COPY after DELETE | 200 and deleted tags restored | +| Newer empty signed COPY | 200, old nonempty tags/time remain | +| First signed replica PUT carrying tag timestamp | 200, timestamp absent in stored object | +| First signed replica multipart initiation carrying timestamp | 200, timestamp absent in persisted upload metadata | +| Equal empty source/target values, source has newer deletion revision | getReplicationAction returns none | +| Same ETag/version with newer trusted source tag revision | checkPreconditionsPUT skips request | +| Process an old queued tagging event after a stored deletion | replication completes; source ACK restores `key=queued` with the deletion timestamp | + +All HTTP/storage cases above ran on both ErasureSD (one real disk) and Erasure (16 real disks). The last case uses a local HTTP protocol peer for replication responses and the real source object layer. It manually persists the deletion revision before processing the old queue snapshot to isolate the ACK defect from the separate DELETE-handler defect. The worker reads current deleted tags, yet its completion callback restores stale queue tags. + +These are component/in-process HTTP integration results, not multi-site production acceptance. + +## Provenance + +Current git history attributes introduction of ReplicationSourceTaggingTimestamp in COPY to upstream `c4373ef29` (2021-09-18, multi-site replication). COPY sender timestamps were added in `3781a0f9a` (2023-12-13), with default tag timestamps in `64a8f2e55` (2025-02-04). Queue-snapshot tag reassignment traces to `fa6d082bf` (2023-09-16). The storage tag reconciliation fix `3ce831925` is already present in this baseline and does not cover the HTTP/transport or queue-ACK omissions. + +No historical state can prove a missing deletion time. The planned repair records future revisions; a production backfill would need separate authoritative evidence and authorization. diff --git a/docs/investigations/r5/validation-results.json b/docs/investigations/r5/validation-results.json new file mode 100644 index 000000000..868e69194 --- /dev/null +++ b/docs/investigations/r5/validation-results.json @@ -0,0 +1,88 @@ +{ + "environment": { + "go": "go1.27.1 darwin/arm64", + "GOMAXPROCS": "2", + "go_test_p": "1" + }, + "new_r5_tests": { + "count": 13, + "exit_code": 0, + "log": "/Users/vonng/tmp/silo-r5-20260915-77ad/fixed-targeted-latest.log", + "runtime_seconds": 9.161 + }, + "related_selection": { + "selected_top_level_tests": 135, + "first_capacity_adapted_run": { + "passed": 134, + "failed": 1, + "failure": "POST fixture still used host capacity; XMinioStorageFull", + "exit_code": 1, + "log": "/Users/vonng/tmp/silo-r5-20260915-77ad/related-suite-capacity-final.log" + }, + "remaining_post_and_strengthened_pool_test": { + "passed": 2, + "failed": 0, + "exit_code": 0, + "log": "/Users/vonng/tmp/silo-r5-20260915-77ad/final-post-and-pools.log" + }, + "all_135_selected_tests_passed_across_batches": true, + "unfiltered_full_cmd_package_pass": false + }, + "race": { + "command": [ + "go", + "test", + "-race", + "-p", + "1", + "./cmd", + "-run", + "^(TestAPITagging.*|TestAPIPoolsTaggingReplicaDeletion|TestAPILocalTaggingAlwaysAdvancesRevision|TestTagging.*|TestLocalTaggingCommitCannotRegressRevision|TestReplicaWritesPreserveTagOrdering|TestMergedPoolObjectInfoTagOrdering|TestReconcileStoredObjectTagOrdering)$", + "-count=1", + "-v" + ], + "environment": { + "GOMAXPROCS": "2" + }, + "log": "/Users/vonng/tmp/silo-r5-20260915-77ad/targeted-race-final.log", + "exit_code": 0, + "duration_seconds": 121.658, + "passed_top_level_tests": 16, + "race_diagnostics": 0 + }, + "verifiers": { + "command": [ + "make", + "verifiers", + "GOLANGCI=/Users/vonng/tmp/silo-r5-20260915-77ad/golangci-lint-serial" + ], + "environment": { + "GOMAXPROCS": "2" + }, + "log": "/Users/vonng/tmp/silo-r5-20260915-77ad/make-verifiers-serial.log", + "exit_code": 0, + "duration_seconds": 203.553, + "wrapper": "Same repository-pinned lint binary with --allow-serial-runners to wait for the shared host lint lock." + }, + "build": { + "command": [ + "make", + "build" + ], + "environment": { + "GOMAXPROCS": "2" + }, + "log": "/Users/vonng/tmp/silo-r5-20260915-77ad/make-build.log", + "exit_code": 0, + "duration_seconds": 35.043, + "built_worktree_base": "dbcf8dec589deb5d91e17d295cb70997635f5b55", + "source_manifest": "docs/investigations/r5/final-implementation-manifest.json", + "version_exit_code": 0, + "version_output": "silo version DEVELOPMENT.2026-09-15T15-56-31Z (commit-id=dbcf8dec589deb5d91e17d295cb70997635f5b55)\nRuntime: go1.27.1 darwin/arm64\nLicense: GNU AGPLv3 - https://www.gnu.org/licenses/agpl-3.0.html\nCopyright: 2015-2025 MinIO, Inc.\nModifications: Copyright 2025-2026 PGSTY\nSource compatibility: based on MinIO technology" + }, + "limits": [ + "Existing TestReplicationResync order-dependent panic reproduced on the unpatched production baseline; passes in isolation on both versions.", + "Existing test capacity uses recorded test-only overlays, with real I/O and errors preserved.", + "Unfiltered full cmd package and production multi-site validation remain unperformed." + ] +} diff --git a/docs/investigations/r5/verification.md b/docs/investigations/r5/verification.md new file mode 100644 index 000000000..b157b10f8 --- /dev/null +++ b/docs/investigations/r5/verification.md @@ -0,0 +1,57 @@ +# R5 local verification + +## Scope and source identity + +This is a local repair of ordered tag deletion along selected replication requests. It does not authorize or establish a main merge, push, release, deployment, historical-state migration, or production multi-site acceptance. + +- Research baseline: `9ebe81c1b3611f9cc73e676b5b741c2be62c467a`. +- Combined verification dependency: R4 `dbcf8dec589deb5d91e17d295cb70997635f5b55`; R5 does not modify `cmd/object-api-options.go`. +- Accepted plan: v2, SHA256 `5a782acf3f285b23d1ae43a73481c4eb772a9a6d917fc5a550ecfc7cbf7446ca`. +- Actual plan reviewer: `claude-opus-5`, explicit max effort; v1 requested changes, v2 approved with nonblocking notes and zero blockers. See `consensus.md`. +- Actual implementation reviewer: the same requested/observed model and effort, `GO_WITH_NONBLOCKING_NOTES`, zero blockers. Original review identity and hashes are in `opus-implementation-metadata.json`. +- `implementation-manifest.json` identifies the reviewed patch. `final-implementation-manifest.json` identifies the final source after a stronger multi-pool test assertion and gofumpt formatting. All seven production file hashes still match the review. + +## Executed regression checks + +All commands run from this worktree with `GOMAXPROCS=2` and `go test -p 1`, Go `go1.27.1 darwin/arm64`. Raw logs are in `/Users/vonng/tmp/silo-r5-20260915-77ad/`. + +| Check | Observed result | Evidence | +|---|---|---| +| New R5 tests before implementation | Reproduced real signed-HTTP deletion resurrection, empty COPY loss, full-write persistence/skip, equal-value sender skip, local revision inversion and stale source ACK | `baseline*.log`, `discussion-baseline.log`; `reproduction.md` | +| Latest complete new R5 selection | 13 top-level tests passed, 9.161s | `fixed-targeted-latest.log` | +| Related selection, host capacity adapted | 134 passed; one POST fixture still failed the host minimum-free threshold, 56.007s | `related-suite-capacity-final.log`; exact 135 names in `related-test-names.txt` | +| Remaining POST test plus strengthened multi-pool replay test | Both passed, 3.232s; completes the 135-name selection across the two batches | `final-post-and-pools.log` | +| Existing `TestReplicationResync` in isolation | Passed on baseline (2.191s) and R5 (1.776s) | `baseline-resync.log`, `fixed-resync-isolated.log` | +| Final R5 plus tag-storage race selection | 16 top-level tests passed, 25.584s runtime, no race diagnostics | `targeted-race-final.log`; exact command/exit in `final-check-results.json` | +| Repository verifiers | Passed: lint 0 issues, generated files unchanged, branding/compatibility and entrypoint checks passed | `make-verifiers-serial.log`, `verifiers-result.json` | +| Repository build and binary invocation | `make build` passed; the resulting `silo --version` exited 0 | `make-build.log`, `build-result.json`, `silo-version.log` | + +The selected regressions include existing replication trust/header poisoning, API preconditions, Object Lock, SSE-C retransmission, R4 KMS option/COPY tests, and pool metadata/cleanup/retry checks. The new R5 suite covers: + +- Local PUT tags, repeated DELETE, empty PUT and ordinary empty COPY; tag revisions advance even without selected replication, while local tagging preserves object ModTime. +- Empty/nonempty and newer/stale/equal/missing revisions through signed COPY with both metadata directives, PUT and multipart; UUID/null and unqualified COPY; unrelated newer versions survive. +- Multipart deletion committed between initiation and completion, with the upload's saved revision checked at initiation and ordered again at completion. +- Exact SDK sender headers, nanosecond precision, legacy fallback only for nonempty tags, and rejection of malformed recorded times. +- Equal-value metadata resend, failed COPY reporting/retry, no incoming-replica requeue, and a stale queued source ACK preserving the current deletion. +- Uniform local tag revisions beyond every physical pool, deterministic inverted request/commit timestamps, normal-routing readback after replay and inspection of every retained pool copy. +- Destination KMS encryption/readback and signed SSE-C key rotation with decrypted GET. These are local handler/storage fixtures, not an encrypted-source-to-encrypted-destination two-site deployment. + +## Baseline and environment failures retained + +The first broad selection panics at `TestReplicationResync` before any R5 test executes. Replacing all seven R5 production files with the R4 baseline, and hiding the two new tests in a Go overlay, reproduces the same panic after the same preceding tests (`baseline-related-suite.log`). The test passes alone on both versions. The remaining 135-name selection therefore runs separately; this is not reported as an unfiltered full-package pass. + +This host's used-space percentage makes existing allocation tests return `XMinioStorageFull`. The test-only overlays add the existing `tagTestCapacityDisk` via `r5Capacity` at the API/pool fixture boundaries and the final POST fixture. The adapter changes reported capacity only, delegates real I/O and propagates disk errors. The exact overlays, original/modified fixture hashes and diffs are retained as `capacity-fixture*` and `capacity-post*`. No fixture overlay or capacity-policy change enters production code. + +The first adapted link and first verifier run also failed actual `ENOSPC` when the volume had about 200–500 MiB available (`related-suite-capacity.log`, `make-verifiers.log`). Regenerable Go cache data untouched for three days was reclaimed with an exact manifest (`cache-reclaim.json`); subsequent successful checks are distinguished from those failures. A subsequent verifier caught gofumpt formatting in the new helper; that formatting was corrected before final validation. + +An earlier KMS multipart fixture used a single-PUT ETag with multipart data layout and failed decryption. Seeding a real multipart source fixed the fixture; the subsequent complete run passed plaintext readback. The failed log remains `fixed-targeted.log`, and this is not attributed to a production encryption change. + +## Local delivery and remaining integration gates + +Required scoped local checks are complete. `validation-results.json` records command results, and `evidence-manifest.json` identifies the raw files and binary by SHA256. The build compiled the working source identified by `final-implementation-manifest.json`; the Makefile stamped its pre-commit dependency ID `dbcf8dec5` into this local development binary. Final source identity is established by the file hashes, not by that pre-commit version label. + +An unfiltered full `cmd` run and real multi-site deployment remain future integration gates before any separately authorized merge/release. Known scope limits are retained in plan v2 and `implementation-review-response.md`: tag-filter target eligibility, historical missing revisions, malformed stored source times, legacy peers dropping empty revisions, and arbitrary distributed clock skew. + +The verifier uses the repository-pinned golangci-lint v2.13.1 through a local wrapper adding only `--allow-serial-runners`. This waits for the shared host lint lock instead of running another lint process concurrently. The first unscheduled attempt was rejected by that lock (`make-verifiers-success.log`; despite that filename, its recorded exit is 2). The final serialized run passed. The optional `typos` binary is unavailable and was skipped by the Makefile. + +R4 has since merged as `af2b1794d38d9e70e1d2c3ee692426e4b6cab4bd` (PR #193). `dependency-handoff.json` verifies that its production options file and both test bodies match the dependency used above. The other differences are test license headers and R4 review/validation documents. The R5 delivery base is this exact merged dependency, with the old unsigned `dbcf8dec5` ancestor removed. The original recorded plan/review baseline remains intact as historical evidence. The final local commit, clean-worktree check and post-rebase file-hash comparison are recorded outside the commit in `/Users/vonng/tmp/silo-r5-20260915-77ad/final-delivery.json`. diff --git a/docs/investigations/r6/README.md b/docs/investigations/r6/README.md new file mode 100644 index 000000000..fa03e3aa8 --- /dev/null +++ b/docs/investigations/r6/README.md @@ -0,0 +1,71 @@ +# R6:delete-marker purge 与 MRF 修复 + +## 当前交付状态 + +本地实现已完成,v3 已与真实 Opus 5.0 达成共识。原研究基线和同步到主干快照后的定向回归、race、完整构建、vet、lint 全部通过;此前受宿主机容量限制的六项 DELETE 测试,在空间恢复后也全部通过。 + +- 研究基线:`9ebe81c1b3611f9cc73e676b5b741c2be62c467a`。 +- 集成基线:`af2b1794d38d9e70e1d2c3ee692426e4b6cab4bd`;通过最终验证的源码提交:`cf381a7151ef25fc95ace5fedcd767fa19410de2`。后续提交仅整理本目录的验证文档。 +- 分支:`codex/r6-delete-marker-mrf`。 +- [PR #184](https://github.com/pgsty/silo/pull/184) 在最终核对时仍为 OPEN,head `6addf9eb916b5a4b837480cf534cd1efa5407d3c`。复用其按 purge 状态识别操作、接纳 marker 405 的方向,补齐实测遗漏;没有直接合并该 PR。 +- 从研究基线到集成基线,仅新增 R4 的 SSE-KMS PUT 选项与对应材料,R6 涉及的生产文件、测试文件和依赖未发生交叉修改。五个 R6 文件在同步前后的 SHA256 一致。 +- 本任务没有执行主干合并、远端推送、发布、部署或现网存量改写。 + +## 修复内容 + +| 操作 | 远端行为 | 结果与源端写回 | +|---|---|---| +| marker 创建 | 保留 HEAD 已存在/就绪检查及创建语义 | 更新创建状态;405 可以表示已创建 | +| 规范版本 purge | 发送指定 versionId 的永久删除 | 只更新 purge 状态,保留磁盘创建/replica 字段 | +| 旧 marker 形态 purge | 从任务级 purge 状态识别,沿用规范永久删除请求 | 不再被创建 COMPLETED 跳过;失败进入 MRF;已完成 purge 不重发 | + +- 离线、DELETE 拒绝、成功与 resync 出口使用操作自己的状态;失败 purge 不写成功 reset 标记。源端 purge 写回显式清空三个“创建更新”字段,避免多目标空状态被旧正则误解析后覆盖磁盘创建记录。 +- MRF 接受携带正确 marker、版本、对象、桶和非零时间的 405;其它错误或无效信息不调度删除。 +- 删除任务携带重试计数;锁失败、复制失败、工作队列满三个入 MRF 出口都递增。耗尽现有预算后继续保留 scanner 恢复路径。 +- purge 的内部 COMPLETE 保持不变;操作审计使用规范 COMPLETED,失败为 FAILED。按目标状态变化更新统计,成功 heal purge 的统计为次数增加、字节数为零。 +- 不改 wire 格式、MRF 磁盘格式、共享正则、复制状态合并框架或依赖版本。 + +## 真实 Opus 共识 + +使用本机 Claude Code 2.1.270,每轮显式指定 `claude-opus-5 --effort max`。全部记录到的 assistant 模型均为 `claude-opus-5`;实际调用成功,未用模拟评审或限流失败代替同意。 + +| 方案 | 结论 | 处置 | +|---|---|---| +| v1 | REVISE,1 个阻断 | 接受意见:不能用本次目标子集重写完整创建状态 | +| v2 | GO_WITH_NONBLOCKING_NOTES | 共识后实现;随后用真实存储发现多目标空状态正则反例 | +| v3 | GO_WITH_NONBLOCKING_NOTES,0 阻断 | reviewer 撤回过强的旧证明,确认三字段清空方案;共识后应用并验证 | + +最终不可变方案:[plan-v3.md](plan-v3.md),SHA256 `dc9a67fc91b3113fa35218a2f903455807430cc4daf9c78a88d0be6b8fc27058`。 +详见 [完整共识及逐项处置](consensus.md)、[研究与 #184 审查](research.md)、[v3 原始评审正文](opus-v3-review.md)。模型只读权限不允许计算哈希;它核对了具体文件内容,本任务在评审前后计算并确认哈希不变。评审不替代执行验证。 + +## 验证范围 + +原基线证据:[机器记录](verification/baseline-verification.json)。最终集成证据:[五项检查记录](verification/rebased-verification.json)、[六项 DELETE 复验](verification/rebased-delete-verification.json)、[源码与方案哈希清单](final-source-manifest.json)。对应日志在同目录,均与原始日志逐字节一致。运行环境:Go 1.27.1,darwin/arm64,GOMAXPROCS=4。 + +| 检查 | 最终结果 | +|---|---| +| `go test -p 2 ./cmd ./internal/bucket/replication -run 'TestReplication\|TestReplicate\|TestMRF\|TestResync\|TestSiteResync' -count=1 -v` | 通过,28 个顶层测试 / 198 个通过条目 | +| `go test -race -p 2 ./cmd -run 'TestReplicateDelete\|TestReplicationMRF\|TestReplicationDeleteQueueFull' -count=1 -v` | 通过,无数据竞争报告 | +| `go build -p 2 ./...` | 通过 | +| `go vet -p 2 ./cmd ./internal/bucket/replication` | 通过 | +| golangci-lint 2.13.1,仓库配置,`--build-tags kqueue` | 通过,0 issues | +| 原容量失败的六项 DELETE 测试,按完整测试名精确复跑 | 六项全部通过 | + +- 28 个顶层定向测试通过,包含 198 个通过条目(含子测试)。 +- 单盘、16 盘真实 erasure 存储;真实源端签名 DELETE、minio-go HTTP、源/目标 marker 元数据。 +- 失败 → MRF 文件持久化 → 新 ReplicationPool 读取 → 真实 marker+405 lookup → 工作队列 → 生产 replicateDelete → 恢复。 +- 覆盖创建/旧新 purge、部分目标重试、两目标一成一败/离线、远端已删除但响应丢失、重试预算耗尽和 scanner 接管;恢复后核对源和目标 marker 最终状态。 +- 两个目标空状态的实际存储反例已转绿;所有 purge 写回均通过断言确认创建字段为空,完整创建/replica 元数据和时间戳保留。 +- 验证实际审计 webhook 的 FAILED/COMPLETED,以及失败/成功统计变化;race 未报告数据竞争。 + +**边界:** 目标为受控 HTTP 适配器,调用真实 ObjectLayer;测试显式消费队列并执行生产复制函数,直接驱动 MRF 保存,没有启动后台定时器和完整 worker 循环。这是三端点 fan-out 与磁盘恢复验证,不是三台独立 SILO 进程的站点复制集群、接收端认证或进程崩溃验收。 + +首次扩大测试中,六项无关 DELETE 测试在种子数据写入时触发宿主机容量阈值,该次测试未通过。空间恢复后,保持代码不变精确复跑六项,全部通过;这不等于运行了整个 cmd 测试集。R6 存储夹具使用仓库现有容量适配器,数据仍真实落盘。一次测试链接遇到磁盘空间耗尽,清理可确认属于本任务的旧 Go 缓存后复验。详情和中间失败记录:[verification-notes.md](verification-notes.md)。 + +## 仍然独立的事项 + +当前 DELETE/scanner/heal/resync 已产生规范 purge;旧任务形态不会序列化跨重启,不能宣称所有失败 purge 永久卡住。本修复主要恢复活跃的 marker MRF 路径,并完善旧形态兼容。 + +未覆盖或未修改:缺失客户端导致的目标状态遗漏、目标级 resync 的既有 purge 子集替换、复制跟踪已丢失、replica relay、purge 后延迟创建且无 tombstone、源端元数据写失败依赖 scanner、共享解析器的通用健壮性、额外 backoff/指标设计。完整多进程站点验收应另行安排。 + +原始大日志与临时复现:`/Users/vonng/tmp/silo-r6-20260915-aa3f/`。每轮 metadata 记录模型、命令、基线、方案及原始输出哈希。 diff --git a/docs/investigations/r6/consensus.md b/docs/investigations/r6/consensus.md new file mode 100644 index 000000000..cb7193ff1 --- /dev/null +++ b/docs/investigations/r6/consensus.md @@ -0,0 +1,53 @@ +# R6 plan consensus — final v3 + +2026-09-15. Source baseline: `9ebe81c1b3611f9cc73e676b5b741c2be62c467a`. + +Earlier v2 agreed immutable plan: [plan-v2.md](plan-v2.md), SHA256 `dae51753a3ab4b2ea98b85e720144338fdf52541ca6daf968c7dac5ce564d8c3`. + +Codex accepts this plan. Real Claude Code 2.1.270, explicitly `--model claude-opus-5 --effort max`, read the same file and returned **GO_WITH_NONBLOCKING_NOTES, no blockers**, with explicit consensus. Every recorded assistant model in both rounds is `claude-opus-5`. CLI auxiliary usage is listed separately in the metadata files. The reviewer had read-only tools, so verified content but did not independently compute the hash; Codex computed the hash before and after review and confirmed it unchanged. The plan is 59 lines as described by the reviewer. A `rate_limit_event` telemetry record is not a failure verdict: the actual final result is `subtype=success, is_error=false`, and includes the explicit review and consensus. + +| Round | Verdict | Outcome | +|---|---|---| +| v1 | REVISE, one blocker | Accepted B1: preserve the disk creation block using the existing empty-update signal. All seven notes resolved/scoped in decisions-v2.md. No production code changed. | +| v2 | GO_WITH_NONBLOCKING_NOTES | Same immutable plan accepted by both parties; implementation and tests now proceed. | + +Raw logs: `/Users/vonng/tmp/silo-r6-20260915-aa3f/opus-v1.jsonl`, `opus-v2.jsonl`, and matching stderr logs. Extracted reviews and SHA/model/command metadata are alongside this document. No simulated reviewer or fallback model was substituted. + +## v2 nonblocking dispositions + +1. Keep canonical success audit normalization COMPLETE → COMPLETED, using replication.CompletedLegacy for conversion; assert the actual audit outcome and document the visible string correction. +2. Use operation status for per-target change comparisons. Assert count-only successful heal purge deltas, zero bytes, and no pending operation outcome for failed purges. No new metrics policy. +3. Initialize ResetStatusesMap before an existing assignment when nil, unconditionally safe; no general merge change. +4. Already-COMPLETE purge also short-circuits under ExistingObjectReplicationType, matching canonical behavior; add a matrix row. +5. Offline errors populate Err for both creation and purge. This is explicit error reporting with the same failed outcome. +6. Preserve getReplicationState's existing unused third parameter and shape-agnostic behavior. +7. Validate real marker/nonempty version/nonzero ModTime plus bucket and decoded object identity. Use decodeDirObject for the name comparison so the stricter gate does not reject the internal directory-object encoding. +8. Use int for delete RetryCount, matching the persisted MRF field and QueueReplicationHeal input. No on-disk format changes. +9. Purge-status subset replacement is pre-existing and remains outside R6; creation-block preservation under partial fan-out is newly tested. +10. Tests drive saveMRFEntries directly, verify the real stored record, and create a fresh pool for each load/queue replay. Timer waiting and process-crash durability are not claimed. + +These are implementation refinements within the accepted plan; Opus explicitly stated they require no new review round/hash. Consensus is not test acceptance, merge, release or deployment. Production implementation begins only after this record was written. + + +## Final v3 consensus, 2026-09-16 CST + +Final immutable plan: [plan-v3.md](plan-v3.md), SHA256 `dc9a67fc91b3113fa35218a2f903455807430cc4daf9c78a88d0be6b8fc27058` (70 lines; locally recomputed unchanged after review). + +Codex accepts v3. Real `claude-opus-5 --effort max` returned GO_WITH_NONBLOCKING_NOTES, no blockers, and explicit consensus on the exact read content. As before, read-only reviewer tools could not compute the digest; the model verified the named content and Codex verified its hash. Original review/metadata are opus-v3-review.md and opus-v3.metadata.json. The review candidly withdraws the earlier multi-target regex proof. This is actual additional review, not a simulated amendment to the earlier output. + +The v2 implementation was completed only after v2 agreement. Codex then found and reproduced the two-empty-status parser counterexample on real storage; the incremental three-field v3 source change remained unapplied until this new consensus was recorded. + +| v3 note | Disposition | +|---|---| +| N1 payload invariant | Accepted as mandatory: wrap the real ObjectLayer update in purge tests and assert all three creation-update fields and their composite are empty. | +| N2 masking scope | Ordinary purges are protected through FileInfo.Deleted=false; ordinary object versions have another such guard. The unrecorded marker case corrupts creation metadata only on its first failed write. | +| N3 ReplicaStatus | Clearing it is defensive, not a repair of an observed producer population. | +| N4 shared parser | Remains unchanged; generic parser robustness is separate. No future arbitrary caller guarantee is claimed. | +| N5 timestamp assignment | Retained; empty creation-update payload makes it irrelevant to purge disk creation metadata. | +| N6 prior proof | v2's regex proof is correct only for a single target. The immutable raw reviews and executable counterexample are both retained. | + +Only v3's three field assignments and the required invariant remain to be applied after this record. All earlier compatible refinements and acceptance limits still stand. + +## Implementation completion, 2026-09-16 CST + +The paragraph above records the state at approval time. The agreed v3 assignments and mandatory payload invariant have since been implemented and verified. After rebasing onto `af2b1794d38d9e70e1d2c3ee692426e4b6cab4bd`, all five R6 source/test hashes remained identical. Source commit `cf381a7151ef25fc95ace5fedcd767fa19410de2` passed the scoped regression, race, build, vet and lint checks, plus the six formerly capacity-blocked DELETE tests. See [README.md](README.md) and its linked machine verification records. Subsequent changes only document this evidence; no new production-plan deviation was introduced. diff --git a/docs/investigations/r6/decisions-v2.md b/docs/investigations/r6/decisions-v2.md new file mode 100644 index 000000000..acccc6760 --- /dev/null +++ b/docs/investigations/r6/decisions-v2.md @@ -0,0 +1,18 @@ +# R6 v1 review disposition + +Actual reviewer: Claude Code 2.1.270, assistant model `claude-opus-5`, explicit effort max. Original verdict REVISE, one blocker; see opus-v1-review.md and opus-v1.metadata.json. No consensus or production implementation at this stage. + +| Item | Disposition | v2 change / evidence | +|---|---|---| +| B1 creation-status pin rewrites a partial target set | Accepted. The disk no-update semantics are preferable and smaller. | Purge results leave ReplicationStatus empty, only VersionPurgeStatus changes; assert full persisted creation state and timestamp across partial fan-out/repeated failure. No generic state merge rewrite. | +| N1 wire version fallback | Accepted. | Existing DeleteMarkerVersionID fallback retained; tests assert query version and false marker flag for every purge shape. | +| N2 queue-full retry budget | Accepted. | All three queueMRFSave sites for deletes increment RetryCount, including queueReplicaDeleteTask. | +| N3 completion statistics change | Accepted. | Check concrete Heal/ExistingObject counter deltas for COMPLETE-to-COMPLETED conversion. | +| N4 null/empty versions | Accepted as current boundary. | 405 recovery requires a real nonempty identity; empty/null special cases remain outside acceptance. | +| N5 live producer/old-shape scope | Accepted. | Old tasks are not serialized; robustness path distinguished from currently active MRF defect. | +| N6 detached MRF execution | Accepted. | Real disk persistence each round, new pool, synchronized queue receives with bounded timeout, no sleeping for presumed completion. | +| N7 fixture threshold | Resolved with actual execution. | Existing capacity adapter; baseline canonical and old scanner recovery pass. PR MRF canonical recovery completes on single/16-drive fixtures; old-shape failure still queues zero entries. | + +Correction to our research inference: a canonical purge's empty returned creation result causes replicatedInfos.ReplicationStatus() to report PENDING, but that does NOT show loss of the disk creation block. xlMetaV2 skips that block update when the composite creation status is empty. The temporary probe's in-memory assertion was too strong; do not promote it into a disk-state defect. The PR old-shape missing-MRF observation and all target-branch observations remain valid. + +The local-source metadata-write error path and missing-client/purge-subset merge behavior are explicitly documented acceptance limits. They are not new claims of convergence. v2 does not broaden the repair into those independent mechanisms. diff --git a/docs/investigations/r6/final-source-manifest.json b/docs/investigations/r6/final-source-manifest.json new file mode 100644 index 000000000..7528e2f5c --- /dev/null +++ b/docs/investigations/r6/final-source-manifest.json @@ -0,0 +1,39 @@ +{ + "recorded_at_utc": "2026-09-15T16:31:10.707976+00:00", + "research_base": "9ebe81c1b3611f9cc73e676b5b741c2be62c467a", + "integration_base": "af2b1794d38d9e70e1d2c3ee692426e4b6cab4bd", + "verified_source_commit": "cf381a7151ef25fc95ace5fedcd767fa19410de2", + "branch": "codex/r6-delete-marker-mrf", + "source_sha256": { + "cmd/bucket-replication.go": "999c2818a8980cbeb55cfcbc244840069fb44e042b4b4d6ef7668c2660ce31e0", + "cmd/bucket-replication-utils.go": "365641c760901641e8320cc5123697ab92a46f1add612048b991ed2d1cfad43b", + "cmd/replication-delete-marker_test.go": "d967787804d558ac6266b113228fdf4a4f9fcb7cab39138a4fb07558814ccca4", + "cmd/replication-delete-operation_test.go": "2888a04a543324776041316de2821f388d28c3c1a6f5d0031e5ac9d34f58d504", + "cmd/replication-delete-mrf_test.go": "5e160f7e19cbbb8cd5fa4e7ffd9cff9e09361b3fc4c5ee5ae61458f99c777781" + }, + "source_patch_sha256": "f374335371c9c6bed00fe453a6e10270bedcc1f1f391bfc09338664b3f50d886", + "source_patch_raw_path": "/Users/vonng/tmp/silo-r6-20260915-aa3f/final-integrated-source.diff", + "dependency_and_lint_config_sha256": { + "go.mod": "8351bb86377a8deed95fd0bd67c1e363e11d33f8da7631cea8f68cea11259976", + "go.sum": "2287ce975cab91f92f59a3b6e164d49325f141f35d480b8c3d23f796df3772b2", + ".golangci.yml": "b18a20eb81da3e8714ba3d9cdf404913b7c8e011fe6958ff7f10e6410a4573ba" + }, + "final_plan": { + "path": "plan-v3.md", + "sha256": "dc9a67fc91b3113fa35218a2f903455807430cc4daf9c78a88d0be6b8fc27058" + }, + "opus_metadata_sha256": { + "opus-v1.metadata.json": "22b8d6b27a1112441b070bae096641eb5f0d2f954ddfe3fd88e17e0377b577f4", + "opus-v2.metadata.json": "9b6d7a44578fa6be9cc341a045db0c73fc7439ef7e0ee5a8aa99e66250493cc8", + "opus-v3.metadata.json": "678ed800aae399d627a19e9802aeb7109bf51ee539163263c2fec06aba4efbc3" + }, + "verification_record_sha256": { + "verification/baseline-verification.json": "700dddb0a9efab01a0524a51736fecd457ed81e689926c842fa2de6838f1e1d6", + "verification/rebased-delete-verification.json": "f370ba2c9c0b9d6695cf2701f97dbd774a336f833d474992f2c390e67d9cbf58", + "verification/rebased-verification.json": "4d9ad5688d45cba15fe9632590854a48e6fca048f5f3556d5edf9a4b1997d9ca" + }, + "go_version": "go version go1.27.1 darwin/arm64", + "GOMAXPROCS": "4", + "verification_source_unchanged": true, + "delivery_note": "The follow-up commit records verification documents only; all production and test files match verified_source_commit." +} diff --git a/docs/investigations/r6/opus-prompt-v1.md b/docs/investigations/r6/opus-prompt-v1.md new file mode 100644 index 000000000..dfb54450d --- /dev/null +++ b/docs/investigations/r6/opus-prompt-v1.md @@ -0,0 +1,5 @@ +You are the independent Opus reviewer for SILO R6. Read AGENTS.md first. Review only; do not edit files. We need real independent technical scrutiny, not a ceremonial approval. + +Source commit: 9ebe81c1b3611f9cc73e676b5b741c2be62c467a. Plan file: docs/investigations/r6/plan-v1.md. Plan SHA256: bcd023e2b00ad3dc709baa738bb02551d47aeebb49879b0f76f2789f4c4dff8b. Read that exact file in full and inspect the referenced current source and tests. PR184 raw patch and fresh baseline probes are in /Users/vonng/tmp/silo-r6-20260915-aa3f/ (read pr184.diff, review_probe_test.go, baseline.log if available). Its code direction is useful but its claims are not accepted evidence. Focus on every exit of replicateDeleteToTarget and replicateDelete, persisted queueMRFHeal with MethodNotAllowed ObjectInfo, preserving statuses, resync accounting and actual wire behavior on retries. + +Critically verify whether the minimal proposed code could restore an already removed marker, whether retry budgets and multi-target status remain safe within the explicit bounded scope. Explain any disagreement with precise code evidence and a concrete minimal correction. Return a verdict GO, GO_WITH_NONBLOCKING_NOTES, or REVISE, explicitly for plan v1 hash bcd023e2b00ad3dc709baa738bb02551d47aeebb49879b0f76f2789f4c4dff8b. List blocking findings separately from nonblocking suggestions. If there are no blockers, explicitly state that consensus on this plan is technically acceptable, while implementation still requires tests. Do not claim to have run anything. diff --git a/docs/investigations/r6/opus-prompt-v2.md b/docs/investigations/r6/opus-prompt-v2.md new file mode 100644 index 000000000..2d95f2c7c --- /dev/null +++ b/docs/investigations/r6/opus-prompt-v2.md @@ -0,0 +1,3 @@ +Review the revised R6 plan, read-only. The exact source remains 9ebe81c1b3611f9cc73e676b5b741c2be62c467a. Read AGENTS.md, docs/investigations/r6/opus-v1-review.md, docs/investigations/r6/decisions-v2.md, and docs/investigations/r6/plan-v2.md (SHA256 dae51753a3ab4b2ea98b85e720144338fdf52541ca6daf968c7dac5ce564d8c3). Focus on resolving your B1 and coupled exits, and verify the amended retry queue-full site. We accepted your disk-preservation correction; the previous inference from an empty per-target return was too strong. No production implementation has started. Source and temporary probes remain available. Real storage fixtures now execute with the repository capacity adapter (only DiskInfo capacity changes); baseline-mrf.log and pr184-mrf.log have the evidence. + +Perform a targeted delta review of v2, inspect source as needed; do not re-review unrelated systems or attempt to write a file. Say GO, GO_WITH_NONBLOCKING_NOTES, or REVISE explicitly for plan-v2.md hash dae51753a3ab4b2ea98b85e720144338fdf52541ca6daf968c7dac5ce564d8c3, list any blockers, and if acceptable explicitly state consensus on the exact plan. Tests are future obligations, not completed acceptance. diff --git a/docs/investigations/r6/opus-prompt-v3.md b/docs/investigations/r6/opus-prompt-v3.md new file mode 100644 index 000000000..ddc20b023 --- /dev/null +++ b/docs/investigations/r6/opus-prompt-v3.md @@ -0,0 +1,5 @@ +Targeted R6 incremental review, read only, no file writes. Real v2 consensus was GO; v2 implementation was then authorized and implemented. We found an executable counterexample to part of both our empty-composite proofs. Read docs/investigations/r6/plan-v3.md (SHA256 dc9a67fc91b3113fa35218a2f903455807430cc4daf9c78a88d0be6b8fc27058) especially the final incremental section, and /Users/vonng/tmp/silo-r6-20260915-aa3f/v2-unrecorded-purge.log. Inspect the current implementation and the new TestReplicationMRFMarkerRecovery/unrecorded-purge test. Source identity: baseline 9ebe81c1 plus implementation-v2.diff in that evidence directory; v3's three-field correction is NOT applied yet. + +The regex requires a character but permits a semicolon: two empty entries parse into a nonempty fake status. Usual purge metadata sets FileInfo.Deleted=false and masks it, but a task ahead of source purge metadata reproduces real creation-block overwrite with two targets. Proposed minimal delta is ONLY in outer replicateDelete after getReplicationState: if isPurge then drs.ReplicationStatusInternal="", drs.Targets=nil, drs.ReplicaStatus="". This makes CompositeReplicationStatus truly empty, preserves disk creation/replica blocks, retains purge/reset metadata, and avoids changing shared parsing or merging. + +Please independently verify the counterexample, field selection, whether the proposal introduces any loss or leak, and whether it suffices. Focus this review on the delta and its interactions rather than restarting unrelated exploration. Return explicit GO/GO_WITH_NONBLOCKING_NOTES/REVISE for plan-v3.md hash dc9a67fc91b3113fa35218a2f903455807430cc4daf9c78a88d0be6b8fc27058; list blockers and state exact-plan consensus if acceptable. Do not claim tests ran or that the normal producer necessarily emits the special setup. The reviewers' earlier multi-target regex proof was too strong; revise the conclusion candidly. Other scope limits remain explicit. diff --git a/docs/investigations/r6/opus-v1-review.md b/docs/investigations/r6/opus-v1-review.md new file mode 100644 index 000000000..d7db521b4 --- /dev/null +++ b/docs/investigations/r6/opus-v1-review.md @@ -0,0 +1,78 @@ +I'll start by reading AGENTS.md and the plan file, then dig into the actual source. + +Let me locate the core source files and the evidence directory. + +I've verified the plan against the actual source. Writing up the review now. + +The `Write` tool is disabled in this session, so I can't create the plan file — the review is below in full. + +--- + +# Independent review — SILO R6 `plan-v1.md` + +**Plan hash reviewed:** `bcd023e2b00ad3dc709baa738bb02551d47aeebb49879b0f76f2789f4c4dff8b` +**Source:** `9ebe81c1b3611f9cc73e676b5b741c2be62c467a` +**I did not run anything.** All findings come from reading the source, the existing tests, PR #184's diff/prose, and the supplied `baseline.log` (read, not produced by me). + +## Verdict: **REVISE** + +One blocking finding. The classification rule, the canonical-wire choice, the MRF 405 work, and the retry-budget work are technically correct and better grounded than PR #184. One explicitly prescribed detail in §A causes a silent regression on the *canonical* purge shape — the shape every live producer emits — and §C would not catch it. The correction is small and stays inside the plan's bounded scope. + +--- + +## Blocking + +### B1 — Pinning `rinfo.ReplicationStatus = rinfo.PrevReplicationStatus` for purges makes the persisted marker creation-status block get rewritten from the fan-out subset + +§A prescribes this "on every exit" while also forbidding changes to `getReplicationState` merging. Together that is a regression, not preservation. + +1. `bucket-replication-utils.go:392-399` — `targetState()` never sets `ReplicationStatus`, so a canonical purge returns `""` today. +2. `bucket-replication-utils.go:92-101` — `ReplicationStatusInternal()` rebuilds `"arn=STATUS;"` **only from `rinfos.Targets`**, i.e. only fanned-out targets. +3. `bucket-replication.go:508-543` — fan-out skips `!Replicate`, non-matching `dobj.TargetArn`, and nil clients. `TargetArn` is really set by `queueReplicateDeletesWrapper` (`:2400-2409`). +4. `bucket-replication-utils.go:410-412` → `drs`, passed as `DeleteReplication` at `bucket-replication.go:572-582`. +5. `erasure-object.go:2163-2177` → `xl-storage-format-v2.go:1438-1446` — for a `DeleteType` version, **if `fi.DeleteMarkerReplicationStatus()` is non-empty**, `MetaSys[ReplicationStatus]` and `MetaSys[ReplicationTimestamp]` are overwritten. + +Today that guard never fires for a canonical purge: the string is `"arn1=;"`, `replicationStatusesMap` doesn't match it (`:428-439`), composite over the empty map is `""` (`:455-459`) — so the on-disk creation block is left **untouched**, which preserves it perfectly including non-attempted targets. Under the plan it becomes non-empty and is rewritten: + +- **Status loss:** `"arn1=COMPLETED;arn2=COMPLETED;"` with a fan-out covering only `arn2` persists `"arn2=COMPLETED;"` — `arn1` silently dropped. That is the exact failure the plan exists to fix, newly introduced on the main shape. +- **Zero timestamp:** `rs.ReplicationTimeStamp = rinfos.ReplicationTimeStamp` (`:413`) is never assigned in `replicateDelete`; it's only rescued by `bucket-replication.go:568-570` when the composite *changes*. A repeated FAILED→FAILED purge writes `0001-01-01T00:00:00Z`. +- **Empty ReplicaStatus:** under a legacy `RoleArn` config the composite can be `REPLICA` (`bucket-replication-utils.go:497-503`), taking `xl-storage-format-v2.go:1398-1400`, which writes `ReplicaStatus` — never populated by `ObjectInfo.ReplicationState()` (`:569-587`). + +Only delete-**marker** versions are affected; `ObjectType` versions only touch `VersionPurgeStatusKey` (`xl-storage-format-v2.go:1465-1476`). + +**Minimal correction (strictly smaller than the plan):** keep the classification and every exit fix, but for purges **leave `rinfo.ReplicationStatus` at its zero value** rather than pinning it. That is what the canonical path already does, and it preserves the on-disk block byte-for-byte including non-attempted targets, with no change to `getReplicationState`. Consequences that then become mandatory, not optional: + +- purge exits write only `VersionPurgeStatus` — never `Failed`, `Completed`, or `PrevReplicationStatus`; +- the resync defer (`:618-622`) must be gated on the operation's own success. Under the plan as written, pinning `Completed` would stamp the reset marker for a **failed** purge on any marker whose creation was COMPLETED — the two changes are coupled and cannot land separately; +- the stats gate at `:556` must be replaced by the per-target operation-status comparison §A already calls for, otherwise purges stop being reported at all. + +Add a partial-fan-out test: two ARNs persisted, one excluded from fan-out, failed purge → marker metadata unchanged. §C item 5 fans out to *both* targets and cannot detect this. + +--- + +## Nonblocking + +1. **The wire version ID is load-bearing.** For the legacy shape `dobj.VersionID` is empty, so the implementation must keep the existing `versionID` fallback (`bucket-replication.go:609-612`). A `RemoveObject` with empty `VersionID` against a versioned target **creates a new marker** (`erasure-object.go:2126-2149`) — the exact resurrection being fixed. Make §C1 assert the outgoing `versionId` and `x-minio-source-deletemarker` explicitly. +2. **Retry increment must cover all three delete-path MRF sites:** `:487` (lock), `:565` (aggregate failure), `:2427` (queue full). §B names only the first two; missing `:2427` leaves an unbounded loop once marker MRF is live. Mirror `ri.RetryCount++` at `:1310`. +3. **Stats will move.** `COMPLETE`→`COMPLETED` makes `ReplicationStats.Update` reach its `Completed` case for Heal/ExistingObject deletes (`bucket-replication-stats.go:184-201`, `replication.go:139-144`). Today `"COMPLETE" != "COMPLETED"` so nothing is recorded. Assert expected counter deltas rather than discovering them. +4. **§B's identity gate excludes null-version markers by construction** — `GetObjectInfo` returns `ObjectNotFound`, not 405, when `VersionID == ""` (`erasure-object.go:996-999`), and `ToObjectInfo` leaves `VersionID` empty when `versioned` is false (`erasure-metadata.go:120-123`). No regression, but document it instead of implying MRF healing is complete. +5. **Legacy-shape scope.** I found no producer of that shape on this baseline: `object-handlers.go:3225-3228`, `bucket-handlers.go:565-570`, `bucket-replication.go:3323-3327`/`:3775-3779` are mutually exclusive, and `erasure-object.go:1752-1766` only sets `DeleteMarkerVersionID` when `VersionID == ""`. `DeletedObjectReplicationInfo` isn't serialized, so it can't survive a restart. §A's legacy handling is upgrade/robustness work; the live value of R6 is mostly §B. Say so in the PR text so the fork doesn't inherit #184's overclaiming. +6. **§C item 3 is testable but fiddly.** `globalLocalDrivesMap` is populated by `newErasureServerPools` (`erasure-server-pool.go:174-181`), so save/load works — but `loadMRF` **deletes the file after reading** (`:4013-4015`) and `queueMRFHeal` dispatches a detached goroutine with a 1s per-entry context (`:4067-4081`). Re-persist between rounds and synchronize, don't sleep. +7. **Confirm the fixtures run.** The supplied `baseline.log` shows both `TestReplicateDeleteMarkerPurge` subtests aborting at `replication-delete-marker_test.go:121` with "Storage reached its minimum free drive threshold" — an environment failure. §C items 2, 3, 5 all depend on those fixtures. + +--- + +## Independently confirmed as correct in the plan + +- **Observation 1 holds at every exit:** `:624` (early-return without sending), `:645-649`/`:702-706`/`:712-716` (field selected on `dobj.VersionID`), `:684-688` (HEAD-not-ready overwrites creation status unconditionally), `:628` (completed-purge early-out only for non-empty `VersionID`), `:618-622` (resync stamp keyed on `ReplicationStatus`). The supplied probe log agrees. +- **Observation 2 is a real regression in PR #184.** `replicateDelete` selects on `dobj.VersionID != ""` (`:549-552`), so with only the target-side fix a failed legacy purge aggregates to `COMPLETED`, emits `ObjectReplicationComplete`, and **skips `queueMRFSave`** (`:563-566`) — worse than baseline. +- **Task-level classification is required; #184's per-target `isDMPurge` is wrong.** `VersionPurgeStatus()` needs `completed == len(ri.Targets)` (`bucket-replication-utils.go:122-139`); a target classified as a creation never sets the purge field, so the composite can never reach COMPLETE. The task-level rule is also safe here — no current producer emits a creation with a non-empty composite purge status. +- **The canonical-wire choice really does fix resurrection; #184 does not.** With `ReplicationDeleteMarker=true` and an absent version the receiver re-creates the marker (`xl-storage.go:1346-1349`; `xl-storage-format-v2.go:1517-1520`). With `false`, the receiver returns VersionNotFound (`erasure-object.go:2013-2027`) and the handler answers 204 (`object-handlers.go:3171-3192`). Scope it honestly: this only changes *legacy-shaped* purges (canonical already sends `false`), and does nothing for a delayed creation after a purge — correctly filed as separate. +- **§B's 405 premise is exact:** `erasure-object.go:996-1002` returns a populated `ObjectInfo` with `toObjectErr(errMethodNotAllowed,…)` → `MethodNotAllowed{}` (`object-api-errors.go:96-102`), preserved multi-pool too (`erasure-server-pool.go:1063-1077`). +- **§B's retry gap is real:** `ToMRFEntry()` (`:1927-1937`) never sets `RetryCount`, so the `> mrfRetryLimit` drop (`:3883-3887`) can never fire for deletes. No msgp code exists for the type, so the field is genuinely schema-free. +- **Resync accounting already handles both shapes** — `resyncTargetSucceeded` (`:3154-3162`) keys on `roi.VersionPurgeStatus`. The plan correctly leaves it alone. +- **The pushback on #184's permanence/topology prose is correct.** Canonical purge retries are not short-circuited on this baseline (`:624` requires `VersionID == ""`; `:628` exits only on `VersionPurgeComplete`). #184's Bugs 1 and 2 as narrated are legacy-shape-only. + +--- + +Because B1 is a blocker, I am not stating consensus on this exact hash. Resolve B1 (and the coupled resync-defer/stats-gate items it forces) and the plan becomes technically acceptable in my judgement — implementation would still require the tests in §C plus the partial-fan-out regression above. diff --git a/docs/investigations/r6/opus-v1.metadata.json b/docs/investigations/r6/opus-v1.metadata.json new file mode 100644 index 000000000..f08cd80f1 --- /dev/null +++ b/docs/investigations/r6/opus-v1.metadata.json @@ -0,0 +1,23 @@ +{ + "source_commit": "9ebe81c1b3611f9cc73e676b5b741c2be62c467a", + "requested_model": "claude-opus-5", + "requested_effort": "max", + "cli_version": "2.1.270", + "plan": "plan-v1.md", + "plan_sha256": "bcd023e2b00ad3dc709baa738bb02551d47aeebb49879b0f76f2789f4c4dff8b", + "assistant_models": [ + "claude-opus-5" + ], + "raw_path": "/Users/vonng/tmp/silo-r6-20260915-aa3f/opus-v1.jsonl", + "raw_sha256": "16f1954b213b5813a72a8db6d960d7933e1065c63e9ae2d6f139d0cff881011f", + "command": "claude --print --model claude-opus-5 --effort max --safe-mode --permission-mode plan --tools Read,Grep,Glob --strict-mcp-config --no-session-persistence --add-dir /Users/vonng/tmp/silo-r6-20260915-aa3f --output-format stream-json --verbose", + "auxiliary_usage_models": [ + "claude-haiku-4-5-20251001", + "claude-opus-5" + ], + "subtype": "success", + "is_error": false, + "session_id": "927a163e-c14f-4366-b1bc-dbb363fbe793", + "duration_ms": 357292, + "num_turns": 61 +} diff --git a/docs/investigations/r6/opus-v2-review.md b/docs/investigations/r6/opus-v2-review.md new file mode 100644 index 000000000..0b9453cf1 --- /dev/null +++ b/docs/investigations/r6/opus-v2-review.md @@ -0,0 +1,86 @@ +I'll start by reading the required documents and verifying the plan hash. + +Now let me verify the plan hash and inspect the source claims. + +I read AGENTS.md, opus-v1-review.md, decisions-v2.md, and plan-v2.md, then re-checked every claim v2 changed against the source at `9ebe81c1b`. I ran nothing; findings are source reading plus the supplied logs. + +One honesty note up front: **no shell tool is available in this session, so I could not compute the SHA256 myself.** My verdict is on the exact content I read at `docs/investigations/r6/plan-v2.md` — 59 lines, opening `# R6 plan v2 — marker purge operation and MRF recovery`, dated 2026-09-15, closing with the "v2 changes from Opus v1" section that lists B1/N1–N7 dispositions. If that is `dae51753a3ab4b2ea98b85e720144338fdf52541ca6daf968c7dac5ce564d8c3`, the verdict below attaches to that hash. + +# Verdict: GO_WITH_NONBLOCKING_NOTES + +**Blockers: none.** B1 is resolved, and resolved by the correct mechanism rather than by wording. + +--- + +## B1 and the coupled exits — resolved, mechanism verified end-to-end + +§A now says purges leave `rinfo.ReplicationStatus` empty on every exit. I traced the preservation chain to make sure that is actually load-bearing and not just an absence: + +1. `replicatedInfos.ReplicationStatusInternal()` (`cmd/bucket-replication-utils.go:92-101`) emits `"arn1=;"` — non-empty, so it does reach `ReplicationState`. +2. `replicationStatusesMap` (`:428-439`) uses `replStatusRegex` (`:168`), whose second group `([^,].*?)` requires ≥1 char before `;`. `"arn1=;"` does not match → **empty `Targets` map**. +3. `CompositeReplicationStatus()` (`:356-372`) therefore takes the `default:` branch → `getCompositeReplicationStatus(empty)` → `""`; the `ReplicaTimeStamp` fall-through at `:366-371` also returns `""` because `replStatus == Completed` is false. So the composite is `""` under every sub-case. +4. `xl-storage-format-v2.go:1438` gates the `DeleteType` rewrite on `!fi.DeleteMarkerReplicationStatus().Empty()` → **guard never fires** → `MetaSys[ReplicationStatus]`, `MetaSys[ReplicationTimestamp]` and the Replica pair at `:1441-1442` are left byte-for-byte intact, including ARNs excluded from fan-out. `updateVersion` is still true via `:1390`, so `MetaSys[VersionPurgeStatusKey]` at `:1448-1449` is written as intended. + +All three consequences I said were mandatory and non-separable are present: + +| Coupled item | v2 | +|---|---| +| Purge exits write only `VersionPurgeStatus` | §A, explicit, "on every exit"; `PrevReplicationStatus` retained for inspection only — harmless, `targetState()` sets it and nothing persists it | +| Resync defer gated on the operation's own success | §A "Do not stamp the current resync reset for a failed purge"; §C1 asserts the reset marker | +| `:556` stats gate replaced by per-target operation-status comparison | §A "Feed per-target old/new operation status into stats rather than selecting changes from the unrelated creation status" | +| Partial-fan-out regression test | §C5, with two creation ARNs, a non-zero timestamp, and fan-out restricted to one ARN | + +The zero-timestamp and `ReplicaStatus` sub-findings from v1 are dissolved rather than patched: with the composite empty, `:1444-1445` is never reached, so `drs.ReplicationTimeStamp` (set at `bucket-replication.go:568-570`) cannot land on disk for a purge at all. + +I also checked the downstream consumer the change could have silently flipped: `resyncTargetSucceeded` (`bucket-replication.go:3154-3162`) keys the purge branch on `roi.VersionPurgeStatus` and reads `t.VersionPurgeStatus`, never `t.ReplicationStatus`. Leaving `ReplicationStatus` empty makes resync accounting strictly more correct for the old shape (today a resynced old-shape purge sets `ReplicationStatus = Completed` at `:713`). §A's "leave it alone" is right. + +## Amended retry queue-full site — correct + +`cmd/bucket-replication.go:2411-2448`, `queueReplicaDeleteTask`, `default:` branch of the select at `:2426-2427` → `p.queueMRFSave(doi.ToMRFEntry())`. §B names it exactly ("queueReplicaDeleteTask queue-full fallback"). I grepped every `queueMRFSave(` call: the delete-path sites are precisely `:487` (lock), `:565` (aggregate failure), `:2427` (queue-full) — three, no more. `:1206`, `:1311`, `:2339`, `:2370` are object-path. + +The supporting claims hold too: `MRFReplicateEntry.RetryCount` already exists with msgp tag `rc` (`bucket-replication-utils.go:792`), so no format change; `DeletedObjectReplicationInfo.ToMRFEntry()` (`:1927-1937`) sets only `Bucket`/`Object`/`versionID`; `versionID` is unexported but survives as the map key (`persistMRF` `:3870`, read back at `:4068`); the `> mrfRetryLimit` drop at `:3883-3887` is therefore currently unreachable for deletes; and `DeletedObjectReplicationInfo` has no generated msgp code, so the new field is genuinely schema-free. The gap's exact location is `queueReplicationHeal:3762` setting `roi.RetryCount` while `dv` at `:3781-3793` drops it — which is what §B closes. + +## N7 evidence — checks out + +`baseline-mrf.log` shows `TestReplicateDeleteMarkerPurge` both subtests **PASS**; the "Storage reached its minimum free drive threshold" abort from v1 is gone, so §C items 2/3/5/6 have a working fixture. The logs also independently corroborate two plan premises on real storage: `err=Method not allowed` on the source marker lookup (§B's 405 premise), and `legacy=true … MRF=0` on both baseline and PR #184 (observation 2, and #184's residual gap). Your retraction is right, and I'd add that the mechanism is visible: `replicatedInfos.ReplicationStatus()` (`bucket-replication-utils.go:103-119`) counts *every* target including empty-status ones, so `creation=PENDING` for a canonical purge is an artifact of that aggregate, not disk state. The retained logs still carry the old "failure stored in incorrect status field" assertion text on the canonical rows — worth a pointer to decisions-v2's retraction beside them so a later reader doesn't re-derive the wrong conclusion. + +--- + +## Non-blocking notes + +1. **Audit `Status` string changes on the live canonical path.** §A folds audit into the COMPLETE→COMPLETED mapping. The audit defer at `bucket-replication.go:434-444` logs `Status: string(replicationStatus)`, so every successful canonical versioned-delete replication goes from `COMPLETE` to `COMPLETED`. That is a user-visible change on the *live* path, not the legacy one. Keep it — `CompletedLegacy` is documented as an error at `internal/bucket/replication/datatypes.go:35-36` — but assert the audit string in §C, reuse `replication.CompletedLegacy` rather than a `"COMPLETE"` literal, and mention it in the PR text. +2. **The stats delta is wider than §C5's Heal/ExistingObject framing.** The `Completed` case gate is right (`bucket-replication-stats.go:189-192` requires `IsDataReplication()`, which excludes the unset OpType on handler-originated deletes — `replication.go:139-145`). But replacing the `:556` gate also changes *which* purges reach `Update`: today `""` vs `COMPLETED` makes that gate fire for nearly every purge of a previously-replicated version, and old-shape purges currently record a spurious `Pending` (aggregate `Pending`, prev `COMPLETED`). Assert that spurious `Pending` disappears too. Note `ri.Size` is never set in `replicateDeleteToTarget`, so `Completed` deltas are count-only, zero bytes. +3. **Make the `ResetStatusesMap` nil-guard unconditional** instead of contingent on tests exposing it (plan line 51). `getReplicationState:419-422` writes into `prevState.ResetStatusesMap` unguarded; `ObjectToDelete.ReplicationState()` (`:590-600`) leaves it nil, unlike `ObjectInfo.ReplicationState()` (`:576`). Today it is unreachable only because the resync defer requires `ReplicationStatus == Completed`, which purges never reach — and §A re-gates exactly that defer. I traced the live producers: the sole `ExistingObjectReplicationType` delete is `:3329-3342`, fed by `getHealReplicateObjectInfo` → `oi.ReplicationState()` → non-nil, so this is robustness and test-fixture safety, not a live panic. Two lines; just do it. +4. **Pin the already-COMPLETE purge under ExistingObject resync in §C1.** The purge early-out at `:628` has no `OpType != ExistingObjectReplicationType` exclusion, unlike the creation early-out at `:624`. Routing old-shape purges through it means a resync of an already-COMPLETE old-shape purge now short-circuits where today it re-sends. That matches canonical behaviour and is probably intended, but §C1's "resync success/failure" row currently leaves the implementer free to pick either. +5. **Scope "Preserve actual target failure in Err, including offline error where appropriate."** The offline exit (`:631-651`) sets no `Err` today, and `Err` flows into `replStat.set(...)` → `srUpdate` → site-replication stats. Say whether creations also start carrying an offline `Err`, or restrict it to purges; otherwise §C6's offline row has no fixed expectation. +6. **Leave `getReplicationState`'s third parameter alone.** `vID` (`bucket-replication-utils.go:402`) is entirely unused in the body. Since §A re-plumbs shape classification, someone will be tempted to wire it up; the empty-composite preservation depends on that function staying shape-agnostic. +7. **§B identity gate: key on `versionID` + `DeleteMarker` + non-zero `ModTime`.** `queueMRFHeal` calls `GetObjectInfo` with `ObjectOptions{VersionID: vID}` and no `Versioned`, so `ToObjectInfo` (`erasure-metadata.go:118-123`) returns `fi.VersionID` verbatim — the gate works. Bucket/object equality is trivially satisfied (they are the request arguments); the one component that can be perturbed is a strict `oi.Name == e.Object` against `decodeDirObject` (`cmd/utils.go:899-904`) for directory objects. `erasure-server-pool.go:1063-1077` does return the populated `oi` with the error upward, so the gate has real data to inspect. +8. **RetryCount type.** `ReplicateObjectInfo.RetryCount` is `uint32` (`:3762`), `MRFReplicateEntry.RetryCount` is `int` (`:792`), `QueueReplicationHeal` takes `int`. Pick one for the new delete-task field and keep conversions in one place. +9. **State the purge-subset loss as pre-existing, not merely excluded.** The asymmetry §C5 carves out is structural: creation status survives partial fan-out *because* the composite is empty (`:1438`), while `VersionPurgeStatusInternal()` is non-empty and unconditionally rewrites `MetaSys[VersionPurgeStatusKey]` from the fan-out subset (`:1448-1449`). Canonical purges already lose excluded-ARN purge status today, so R6 neither causes nor fixes it. Say that, or a reader will assume both were repaired. +10. **§C3 flush mechanics.** `persistMRF` (`:3866-3874`) only writes to disk on the 5-minute timer, ctx cancel, or 1M entries; the re-queued entry lands in an in-memory map first. N6's "persist each failed entry again" covers it, but make explicit that the test drives `saveMRFEntries` directly rather than waiting on the timer — and that `loadMRF` deletes the file after reading (`:4013-4015`), so each round needs a fresh write. + +--- + +## Consensus + +I state consensus on **`docs/investigations/r6/plan-v2.md` exactly as reviewed here** (hash `dae51753a3ab4b2ea98b85e720144338fdf52541ca6daf968c7dac5ce564d8c3` as supplied; content verified, digest not independently computed for lack of a shell tool). The classification rule, the all-exits treatment, the canonical-wire choice, the empty-creation-status preservation mechanism, the valid-405 gate, the three-site retry budget, and the §C matrix are technically correct against `9ebe81c1b`, and the scope limits in the "Existing state, compatibility, and delivery" section are accurate rather than overclaimed. The ten notes above are refinements and assertions to add, not conditions on this agreement — none of them requires another review round or a new hash. + +Tests in §C remain future obligations. Nothing here asserts they pass; the only executed evidence I relied on is the supplied baseline/PR logs, which establish fixture viability and the two defect premises, not acceptance. + +The review above is complete and stands as delivered. Summarizing the required outputs: + +**Verdict for `docs/investigations/r6/plan-v2.md`, hash `dae51753a3ab4b2ea98b85e720144338fdf52541ca6daf968c7dac5ce564d8c3`: GO_WITH_NONBLOCKING_NOTES** + +**Blockers: none.** + +**Consensus:** I state consensus on the exact plan as reviewed. The one caveat is procedural, not substantive: no shell tool is available in this session, so I verified the plan's *content* (59 lines, opening `# R6 plan v2 — marker purge operation and MRF recovery`, closing with the B1/N1–N7 disposition list) but could not independently compute its digest. Consensus attaches to that content. + +What I resolved this round: +- **B1** — accepted resolution, and I confirmed the mechanism is real rather than merely absent: `"arn1=;"` fails `replStatusRegex`, so `Targets` is empty, so `CompositeReplicationStatus()` returns `""` under every sub-case including the `ReplicaTimeStamp` fall-through, so the `xl-storage-format-v2.go:1438` guard never fires and the on-disk creation block survives partial fan-out intact — while `updateVersion` still writes `VersionPurgeStatusKey`. +- **Coupled exits** — all three present (purge-only status writes; resync defer gated on the operation's own success; `:556` gate replaced by per-target operation status), plus the §C5 partial-fan-out test. I also checked the one consumer that could have flipped silently, `resyncTargetSucceeded:3154-3162`, and it keys on `VersionPurgeStatus` — so the change makes resync accounting strictly more correct. +- **Queue-full retry site** — `queueReplicaDeleteTask`, `default:` branch, `bucket-replication.go:2427`; the delete-path `queueMRFSave` sites are exactly `:487`, `:565`, `:2427`, and the `RetryCount` disk field already exists with msgp tag `rc`. +- **N7** — the fixture-threshold failure is gone; `TestReplicateDeleteMarkerPurge` passes on baseline, and the logs corroborate the 405 premise and the `legacy=true … MRF=0` gap on both baseline and #184. + +The ten non-blocking notes are in my response; the three worth acting on before you branch are the audit-string change on the live canonical path (note 1), the wider-than-stated stats delta (note 2), and making the `ResetStatusesMap` nil-guard unconditional (note 3). None requires another review round or a new hash. + +Tests in §C remain future obligations — nothing here asserts they pass. I made no edits and wrote no files. diff --git a/docs/investigations/r6/opus-v2.metadata.json b/docs/investigations/r6/opus-v2.metadata.json new file mode 100644 index 000000000..ee21bdaaf --- /dev/null +++ b/docs/investigations/r6/opus-v2.metadata.json @@ -0,0 +1,23 @@ +{ + "source_commit": "9ebe81c1b3611f9cc73e676b5b741c2be62c467a", + "requested_model": "claude-opus-5", + "requested_effort": "max", + "cli_version": "2.1.270", + "plan": "plan-v2.md", + "plan_sha256": "dae51753a3ab4b2ea98b85e720144338fdf52541ca6daf968c7dac5ce564d8c3", + "assistant_models": [ + "claude-opus-5" + ], + "raw_path": "/Users/vonng/tmp/silo-r6-20260915-aa3f/opus-v2.jsonl", + "raw_sha256": "aa4d04931875e82ae850a8fa2b875c094f36625784c8c090781f9a0273d20871", + "command": "claude --print --model claude-opus-5 --effort max --safe-mode --permission-mode plan --tools Read,Grep,Glob --strict-mcp-config --no-session-persistence --add-dir /Users/vonng/tmp/silo-r6-20260915-aa3f --output-format stream-json --verbose", + "auxiliary_usage_models": [ + "claude-haiku-4-5-20251001", + "claude-opus-5" + ], + "subtype": "success", + "is_error": false, + "session_id": "c6dfdf82-d542-47a1-8e69-a48753d61b12", + "duration_ms": 219946, + "num_turns": 41 +} diff --git a/docs/investigations/r6/opus-v3-review.md b/docs/investigations/r6/opus-v3-review.md new file mode 100644 index 000000000..bfdc66364 --- /dev/null +++ b/docs/investigations/r6/opus-v3-review.md @@ -0,0 +1,77 @@ +I'll start by reading the key evidence documents and the current implementation state. + +I have everything I need. Let me verify one last interaction — whether the delta is pinned by any existing test — and then write the review. + +Write is disabled in this session (consistent with your read-only constraint), so the review is delivered here in full. + +# Verdict: **GO_WITH_NONBLOCKING_NOTES** — `plan-v3.md` + +**Blockers: none.** + +Digest caveat: no shell tool in this session, so I could not compute SHA256 and cannot attest `dc9a67fc91b3113fa35218a2f903455807430cc4daf9c78a88d0be6b8fc27058`. Consensus attaches to the content I read — 70 lines, opening `# R6 plan v3 — marker purge operation and MRF recovery`, closing `...including the proof now corrected by the executable counterexample.` — the same procedural caveat the v2 reviewer recorded. I ran no tests; `v2-unrecorded-purge.log` is treated as reported evidence, not as my own execution. + +Source reviewed: worktree = `9ebe81c1` + `implementation-v2.diff`, v3 delta **not** applied. + +--- + +## 1. Counterexample verified — and I am withdrawing the general form of the earlier proof + +The v2 consensus rested on: empty per-target status ⇒ `"arn=;"` ⇒ no regex match ⇒ empty `Targets` ⇒ empty composite ⇒ guard at `cmd/xl-storage-format-v2.go:1438` never fires. **That holds only for |targets| ≤ 1. It is false for ≥ 2.** The reviewers' multi-target regex proof was too strong; the executable counterexample is correct. + +Trace against `replStatusRegex = ([^=].*?)=([^,].*?);` (`cmd/bucket-replication-utils.go:168`), input `arn1=;arn2=;`: +- group 1 lazily reaches the first `=` → `arn1` +- group 2's `[^,]` **accepts `;`** (the class excludes comma, not semicolon), then `.*?` runs to the next `;` → `;arn2=` +- one match spans the whole string → `{arn1: ";arn2="}` + +Then `CompositeReplicationStatus` (`:356-379`): non-empty internal, not a legacy literal → `default` → `getCompositeReplicationStatus` sees one bogus entry → **`Pending`**. The `ReplicaTimeStamp` fall-through at `:366-371` cannot rescue it (it only returns `ReplicaStatus` when `replStatus == Completed`). Generalizes: N=1 → `""`; every N ≥ 2 → `Pending`, always via a `;`-prefixed value, so it can never accidentally land on a real status. + +**Why the ordinary multi-target tests passed** — plan §v3 is right, with one mechanism refinement: the masking is not in the guard, it is in `FileInfo.DeleteMarkerReplicationStatus()` (`cmd/erasure-metadata.go:670-675`), which returns `""` whenever `!fi.Deleted`, regardless of the composite. `erasureObjects.DeleteObject` sets `deleteMarker=false` when `versionFound && !goi.VersionPurgeStatus.Empty()` (`cmd/erasure-object.go:2100-2101`) — the normal state after the handler's PENDING write. A **second independent masking condition** sits at `:2102` (`else if !goi.DeleteMarker`), so purges of ordinary object versions are never exposed: the hole is confined to delete-marker versions whose on-disk purge status is absent. + +Unrecorded path: disk marker has creation metadata only → `deleteMarker` stays `opts.Versioned=true` → `fi.Deleted=true` → `DeleteMarkerReplicationStatus()` = bogus `Pending` → guard at `:1438` fires → `default` branch rewrites `ReplicationStatus` = `arn1=;arn2=;` and `ReplicationTimestamp` = `UTCNow()` (supplied by `:576`, since FAILED ≠ PENDING). Exactly the recorded log: statuses emptied, stamp jumping `15:06:15Z` → `16:06:15.612037Z`. + +The fixture is sound: the recreate at `replication-delete-mrf_test.go:258` genuinely removes the marker (`updateVersion=false` → removal branch at `:1457`), the reseed at `:261-264` writes a creation-only block, and `deletion` — captured from the real signed handler DELETE at `:249` — still carries PENDING purge state. `before` is sampled at `:304`, after the reseed. + +**Scoping, unweakened:** the normal producer does not demonstrably race. `DeleteObjectHandler` enqueues only after a successful write and derives the task from the returned `objInfo` (`cmd/object-handlers.go:3164, 3222-3242`); `queueReplicationHeal` derives it from current disk state (`cmd/bucket-replication.go:3808-3821`). Reaching the unrecorded shape needs disk/task divergence — e.g. a partial-quorum PENDING write later healed back from a stale shard while the in-memory task survives. Narrow, not impossible, unproven. Two facts bound the blast radius: the damage is **one-shot** (the same failed write records the purge status, masking every later round), and it is erased if the purge ever completes. It remains real metadata corruption while a marker is stuck — garbage per-target creation statuses plus a lost creation timestamp. + +## 2. Field selection — correct, with one candid downgrade + +| Field | Assessment | +|---|---| +| `ReplicationStatusInternal = ""` | **Load-bearing.** Direct cause; forces the composite to case 3. | +| `Targets = nil` | **Consistency hygiene.** The composite switches on the string, so it changes nothing today; it prevents an internally inconsistent state (empty string, populated map) misleading a future reader. Keep. | +| `ReplicaStatus = ""` | **Defensive, not load-bearing today.** The fallback at `:374-375` is real, but no production producer populates it: `ObjectInfo.ReplicationState()` (`:572-590`) and `ObjectToDelete.ReplicationState()` (`:593-602`) both omit the replica fields, and every delete-task construction site (`bucket-replication.go:2678, 3361, 3813`, `object-handlers.go:3237`, `erasure-object.go:1715, 1758, 1764`) routes through one of them. Keep it — free, and it closes the documented fallback — but it does not repair anything observed. | + +`isPurge` is the right gate: one value computed pre-fan-out at `:428`, identical to the one `replicateDeleteToTarget` uses at `:616`. Creations untouched. + +## 3. Loss / leak — none found + +- **Creation + replica blocks preserved.** Both write sites (`:1396` ventry, `:1438` in-place) sit behind the same now-dead guard, and `:1430-1453` mutates the existing `ver.DeleteMarker.MetaSys` by key, never clearing it. A no-update payload, exactly as the plan says. +- **Purge block / reset map still written** (`:1448-1453`); `updateVersion` unchanged (`:1380` diverts on the non-empty purge status, `:1390` sets it for any non-COMPLETE purge). +- **Successful purge:** composite COMPLETE → `updateVersion=false` → version removed at `:1457`; `:1458`/`:1460` both stay false. Unchanged. +- **ventry path (`:1395-1411`, added at `:1506`) unreachable for purges:** reaching `:1506` requires an `ObjectType` version, and for those `fi.Deleted` is always false via `:2100-2103`. +- **Zero fan-out (nil clients):** two of three assignments are already no-ops; I traced the `ReplicaStatus` case through `:2081/:2084` and `:1380` for replica and non-replica sources — identical outcome with and without the delta. Pre-existing behavior, already an explicit exclusion. +- **No re-derivation risk:** `SetDeleteReplicationState` runs only under `opts.EvalMetadataFn != nil` (`erasure-object.go:2030-2038`, `erasure-server-pool-consistency.go:342-351`); `replicateDelete` never sets it, so emptied fields are not refilled with a PENDING decision. +- **`ReplicationState.Equal`** is used only by `FileInfo.ReplicationInfoEquals` comparing two on-disk infos. No interaction. +- **Only observable change:** `dobjInfo` handed to `sendEvent` at `:604-610` carries empty `ReplicationStatusInternal`/`ReplicationStatus` instead of bogus `PENDING` for multi-target purges (`erasure-metadata.go:160-162`). Strict improvement; that payload never reflected disk state. Worth one line in the PR description. +- **Stats / audit / MRF** are computed from `rinfos` before `drs` (`:548-573`). Untouched. + +## 4. Sufficiency — yes, for the class + +- `getReplicationState` has exactly **one** production caller (`bucket-replication.go:574`). No second write path to patch. +- With the composite forced empty, the guard is dead for all purges under every (`fi.Deleted`, `updateVersion`) combination — not just the fixture's. +- `versionPurgeStatusesMap` is not exposed to the same misparse: `VersionPurgeStatusInternal()` skips empty statuses (`:147-149`), so `arn=;` never appears there. +- After the delta no known producer emits an empty entry at all: purges write `""`; every `!isPurge` exit of `replicateDeleteToTarget` assigns a status (`:640, 664, 681, 695, 715, 725`); the object path pre-initializes `ReplicationStatus: replication.Failed` (`:1338`). Shared parser untouched, as required. +- No existing test asserts a purge writes a non-empty creation status, so no expected regression. `partial-creation-block` restricts fan-out to one ARN → single entry → composite already `""` → unchanged. + +## 5. Non-blocking notes + +- **N1 (strongest — promote from optional to required).** The plan's "small invariant over the source update payload **if useful**" is the only thing that can pin `Targets` and `ReplicaStatus`; the disk assertion at `replication-delete-mrf_test.go:353` pins `ReplicationStatusInternal` alone. Make it mandatory: wrap the `ObjectLayer` passed to `replicateDelete` and assert, for every purge subtest, that `opts.DeleteReplication` has all three fields empty and `CompositeReplicationStatus() == ""`. A `replicaSource + unrecordedPurge, targets: 2` case is optional — it may pass either way because the replica timestamp round-trips byte-identically, which is exactly why the payload invariant is the better instrument. +- **N2.** Tighten §v3's mechanism sentence: `fi.Deleted=false` protects via `DeleteMarkerReplicationStatus()` returning `""` (`erasure-metadata.go:670-675`), not via the guard; record the second masking condition (`erasure-object.go:2102`) confining exposure to delete-marker versions, and the one-shot nature of the corruption. +- **N3.** State `ReplicaStatus = ""` as defensive (§2), not as a fix for an observed failure. +- **N4.** Residual, explicitly out of R6: `replStatusRegex` still misparses any future two-empty-entry string. No producer remains after this delta; record as a follow-up issue, do not fix here. +- **N5.** `drs.ReplicationTimeStamp = UTCNow()` at `:576` is now provably never written for purges. Harmless — leave it rather than widen a three-line delta that has been reviewed field by field. +- **N6.** For the record: the v2 "empty composite" argument was sound only for a single target; the earlier reviews in this lineage, including the one I am extending, overstated it. + +## 6. Consensus + +I state consensus on the exact plan content as read, including §A's three-field instruction (line 23) and the §v3 restatement (line 67), subject to the digest caveat. All v2 dispositions and scope limits carry forward unchanged: no merge/publish/deploy, no live storage rewrite, no parser or `getReplicationState` merge change, nil-client and already-erased-tracking states remain excluded, and the 3-endpoint fixture is not a production three-daemon SR mesh. Proceed with the three-field delta plus the N1 payload invariant. diff --git a/docs/investigations/r6/opus-v3.metadata.json b/docs/investigations/r6/opus-v3.metadata.json new file mode 100644 index 000000000..6dce8683d --- /dev/null +++ b/docs/investigations/r6/opus-v3.metadata.json @@ -0,0 +1,25 @@ +{ + "source_commit": "9ebe81c1b3611f9cc73e676b5b741c2be62c467a", + "requested_model": "claude-opus-5", + "requested_effort": "max", + "cli_version": "2.1.270", + "plan": "plan-v3.md", + "plan_sha256": "dc9a67fc91b3113fa35218a2f903455807430cc4daf9c78a88d0be6b8fc27058", + "assistant_models": [ + "claude-opus-5" + ], + "raw_path": "/Users/vonng/tmp/silo-r6-20260915-aa3f/opus-v3.jsonl", + "raw_sha256": "7e63d6d522919206d5833b7d908d028123e3a403e9c34a26a07934af79e870c1", + "command": "claude --print --model claude-opus-5 --effort max --safe-mode --permission-mode plan --tools Read,Grep,Glob --strict-mcp-config --no-session-persistence --add-dir /Users/vonng/tmp/silo-r6-20260915-aa3f --output-format stream-json --verbose", + "auxiliary_usage_models": [ + "claude-haiku-4-5-20251001", + "claude-opus-5" + ], + "subtype": "success", + "is_error": false, + "session_id": "07e73b10-dab0-4992-bcad-a71ddac270a3", + "duration_ms": 282053, + "num_turns": 40, + "source_diff": "/Users/vonng/tmp/silo-r6-20260915-aa3f/implementation-v2.diff", + "source_diff_sha256": "30f154da13626b5b38b48fbd48755b21a2377ff7b1a5534dc64feda2cc208488" +} diff --git a/docs/investigations/r6/plan-v1.md b/docs/investigations/r6/plan-v1.md new file mode 100644 index 000000000..75739bb74 --- /dev/null +++ b/docs/investigations/r6/plan-v1.md @@ -0,0 +1,54 @@ +# R6 plan v1 — marker purge operation and MRF recovery + +Date: 2026-09-15. Implementation has NOT started; this is the review candidate. +Baseline: `9ebe81c1b3611f9cc73e676b5b741c2be62c467a` (clean detached worktree and freshly fetched origin/main agree). +PR #184: OPEN; head `6addf9eb916b5a4b837480cf534cd1efa5407d3c`, code commit `96b21557a85cd4a615ba8a797bfc4c556e413db4`. +Evidence directory: `/Users/vonng/tmp/silo-r6-20260915-aa3f/` (PR JSON/diff, baseline overlays/probes/logs). +Current PGSTY support policy is read from local AGENTS.md, copied verbatim from the main checkout's ignored AGENTS.md. No dependency or supported-stack change. + +## Observed mechanisms and scope + +1. `replicateDeleteToTarget`: an old-shaped task has VersionID empty, DeleteMarkerVersionID set, creation target COMPLETED, purge target PENDING/FAILED. The creation early-return suppresses its DELETE. Offline/RemoveObject error/success select the wrong field for this shape. HEAD-not-ready always overwrites creation status, including purges. The resync defer uses creation COMPLETED even when purge fails; purge COMPLETE early-out only recognizes nonempty VersionID. +2. `replicateDelete`: aggregate audit/event/MRF status and the stats change check still select on VersionID/creation status. Fixing only the target routine leaves a failed old-shaped purge reporting Completed and not entering MRF. +3. `queueMRFHeal`: disk entries are consumed, GetObjectInfo(marker version) returns real metadata plus MethodNotAllowed, and every error is discarded. Direct queueReplicationHeal is NOT an MRF test. +4. Current DELETE producer, scanner/heal, and resync already construct the canonical VersionID purge shape. Existing `TestReplicateDeleteMarkerPurge/recover_legacy_true` proves scanner/heal can recover old state. This is NOT evidence that every failed purge is permanently stuck, nor a defect exclusive to >2 sites. +5. PR #184 has the right state-based classification and 405 recovery direction, but misses the outer status, HEAD failure, resync defer, already-complete guard, bounded delete retry propagation, and real MRF coverage. Its existing-marker test confirms one attempt through a helper HTTP endpoint, not a three-site deployment. Its prose makes stronger permanence/topology claims than the current baseline proves. + +## Proposed minimal implementation + +### A. One operation classification, all exits + +Add a small `DeletedObjectReplicationInfo.isVersionPurge()` helper: true when VersionID is nonempty OR DeleteMarkerVersionID is nonempty and the task's composite VersionPurgeStatus is nonempty. Use the task-level decision in both outer and target functions; do not classify a multi-target task differently merely because one target lacks a map entry. + +For purges preserve `rinfo.ReplicationStatus = rinfo.PrevReplicationStatus` on every exit. Only creation changes this field; only purge changes VersionPurgeStatus. Use this classification for completed early-outs, offline/error/success and resync success. Do not stamp the current resync reset for a failed purge. Existing creation semantics (HEAD 405 means already created, readiness gate, quorum fall-through) remain. + +Route ALL purges through the canonical permanent-delete request already produced by today's handlers: explicit version ID, `ReplicationDeleteMarker=false`. Perform marker HEAD/readiness probes only for creations. Purge authorization/failure is determined by the DELETE itself. This removes the obsolete old-shape HEAD error path, prevents a lost-response retry from re-creating an absent marker, and needs no new wire header or receiver change. Keep current RemoveObject 404/idempotency semantics. Preserve actual target failure in Err, including offline error where appropriate. + +In the outer routine use purge outcomes for audit/event/MRF and for per-target change detection. Map internal purge COMPLETE to operation COMPLETED only when passing an operation status to the existing statistics/event/audit logic; stored purge metadata remains COMPLETE. Feed per-target old/new operation status into stats rather than selecting changes from the unrelated creation status. Keep existing stats policy, no new metrics framework. + +For pre-fan-out exits: config/decision failures remain not-tracked; lock failure retains MRF scheduling; missing configured clients remain logged/skipped and are a documented separate state-preservation limitation. Do not rewrite general `getReplicationState` target merging in this issue. Do not claim global convergence for nil clients or resync narrowed to one of many targets. + +### B. Real MRF healing and bounded retries + +Accept GetObjectInfo MethodNotAllowed only when returned ObjectInfo is a delete marker with nonempty matching bucket/object/version identity. QueueReplicationHeal still rejects zero ModTime. Other errors and invalid/empty ObjectInfo are not queued. Reuse the existing disk persistence/load/queue path; no timer/backoff redesign or format change. + +Carry a RetryCount in the in-memory delete task, pass it from queueReplicationHeal, increment it when a failure/lock error is submitted to MRF, and include it in ToMRFEntry (whose disk format already has RetryCount). Respect the existing mrfRetryLimit and drop accounting; after budget exhaustion the scanner can still start a fresh heal. This closes the retry-count omission exposed by re-enabling marker MRF. No additional persistence schema. + +### C. Regression and acceptance matrix + +1. Target operation table: create (new/HEAD405/completed/readiness failure/quorum), canonical object purge, canonical marker purge, old marker purge. Pending/failed/completed purge, creation pending/completed/replica; success, DELETE403/405/503, offline, absent version, response lost after real removal, resync success/failure. Verify HTTP method/version/header, exact status fields and reset marker. No HEAD for purges. +2. Full outer call: old/new purge shapes, source/target real erasure metadata, first failure yields purge FAILED while creation state survives; MRF queue entry exists; correct operation status accounting. +3. Persist that MRF entry with saveMRFEntries, create a fresh ReplicationPool with no in-memory entry, then queueMRFHeal -> loadMRF -> real GetObjectInfo(405) -> QueueReplicationHeal -> delete queue -> replicateDelete. Initially still failing, entry reappears with increased retry count. Recover target, reload/replay and prove source AND target marker versions removed; repeated successful purge remains absent. Repeat in single-drive and 16-drive fixtures. No direct queueReplicationHeal substitute for MRF proof. +4. Creation MRF: failed marker creation metadata also travels the real disk MRF path and reaches the target. Negative MRF lookups (missing/corrupt/nonmarker 405/empty identity) do not schedule mutations. Verify retry budget still drops with counters, scanner fallback works. +5. Two independent HTTP targets plus source in local erasure fixtures: one target succeeds, the other fails/offline; source remains with per-target COMPLETE/FAILED purge states and unchanged creation states. Restore failed target via persisted MRF; successful target is not resent, both targets and source are absent at completion. This is 3-endpoint fan-out/metadata evidence, not a production three-daemon SR mesh, process-crash/power-loss or cross-region test. Report that distinction. +6. Run focused replication/delete/MRF/resync tests and race tests, then build/vet appropriate to changed code. Do not run other tasks' whole-repository suites concurrently. Tests must fail on baseline for the repaired paths; existing canonical purge and scanner recovery remain covered. + +## Existing state, compatibility, and delivery + +Pending/failed old marker metadata is consumed using the normal heal/MRF machinery; no bulk migration or live object rewrite. Disk entries whose marker lookup was previously dropped can be rediscovered by the scanner. Already missing/erased replication tracking or nil target clients cannot be recovered by this patch alone. A durable receiver tombstone for a delayed *creation* after a purge, replica relay behavior, full arbitrary-mesh convergence, and new backoff/observability are separate issues. + +Reuse PR #184's operation-state separation and valid-405 intent, and adapt its useful target/legacy convergence tests; do not copy its unproven permanence claims or adopt the entire PR blindly. Expected work: 1 production file plus focused tests and investigation records (helper placement may touch bucket-replication-utils.go if clearer). Branch only after exact-version Opus consensus. Normal implementation/tests authorized; merge, publish, deploy, and live storage rewrites excluded. + +## Review request + +Verify the classification and every exit against the exact source; challenge the canonical-wire choice, status conversion, MRF validity/retry budget, test sufficiency and scope. Respond with explicit GO/GO_WITH_NONBLOCKING_NOTES/REVISE for plan-v1.md and list blockers separately. Agreement must be on this exact plan hash. No implementation before resolving blockers. diff --git a/docs/investigations/r6/plan-v2.md b/docs/investigations/r6/plan-v2.md new file mode 100644 index 000000000..cbb73bc5c --- /dev/null +++ b/docs/investigations/r6/plan-v2.md @@ -0,0 +1,59 @@ +# R6 plan v2 — marker purge operation and MRF recovery + +Date: 2026-09-15. Implementation has NOT started; this is the review candidate. +Baseline: `9ebe81c1b3611f9cc73e676b5b741c2be62c467a` (clean detached worktree and freshly fetched origin/main agree). +PR #184: OPEN; head `6addf9eb916b5a4b837480cf534cd1efa5407d3c`, code commit `96b21557a85cd4a615ba8a797bfc4c556e413db4`. +Evidence directory: `/Users/vonng/tmp/silo-r6-20260915-aa3f/` (PR JSON/diff, baseline overlays/probes/logs). +Current PGSTY support policy is read from local AGENTS.md, copied verbatim from the main checkout's ignored AGENTS.md. No dependency or supported-stack change. + +## Observed mechanisms and scope + +1. `replicateDeleteToTarget`: an old-shaped task has VersionID empty, DeleteMarkerVersionID set, creation target COMPLETED, purge target PENDING/FAILED. The creation early-return suppresses its DELETE. Offline/RemoveObject error/success select the wrong field for this shape. HEAD-not-ready always overwrites creation status, including purges. The resync defer uses creation COMPLETED even when purge fails; purge COMPLETE early-out only recognizes nonempty VersionID. +2. `replicateDelete`: aggregate audit/event/MRF status and the stats change check still select on VersionID/creation status. Fixing only the target routine leaves a failed old-shaped purge reporting Completed and not entering MRF. +3. `queueMRFHeal`: disk entries are consumed, GetObjectInfo(marker version) returns real metadata plus MethodNotAllowed, and every error is discarded. Direct queueReplicationHeal is NOT an MRF test. +4. Current DELETE producer, scanner/heal, and resync already construct the canonical VersionID purge shape. Existing `TestReplicateDeleteMarkerPurge/recover_legacy_true` proves scanner/heal can recover old state. This is NOT evidence that every failed purge is permanently stuck, nor a defect exclusive to >2 sites. +5. PR #184 has the right state-based classification and 405 recovery direction, but misses the outer status, HEAD failure, resync defer, already-complete guard, bounded delete retry propagation, and real MRF coverage. Its existing-marker test confirms one attempt through a helper HTTP endpoint, not a three-site deployment. Its prose makes stronger permanence/topology claims than the current baseline proves. + +## Proposed minimal implementation + +### A. One operation classification, all exits + +Add a small `DeletedObjectReplicationInfo.isVersionPurge()` helper: true when VersionID is nonempty OR DeleteMarkerVersionID is nonempty and the task's composite VersionPurgeStatus is nonempty. Use the task-level decision in both outer and target functions; do not classify a multi-target task differently merely because one target lacks a map entry. + +For purges leave `rinfo.ReplicationStatus` EMPTY on every exit; `PrevReplicationStatus` still carries the previous value for inspection. This is the existing canonical-purge no-update signal: the disk marker metadata layer does not overwrite creation status or its timestamp when the composite creation status is empty. Pinning previous status here would instead rebuild creation state from the fan-out subset and is explicitly rejected. Only creation sets ReplicationStatus; only purge sets VersionPurgeStatus. Preserve the full on-disk creation/replica status and timestamp block without rewriting it, including excluded targets. Use this classification for completed early-outs, offline/error/success and resync success. Do not stamp the current resync reset for a failed purge. Existing creation semantics (HEAD 405 means already created, readiness gate, quorum fall-through) remain. + +Route ALL purges through the canonical permanent-delete request already produced by today's handlers: explicit version ID, `ReplicationDeleteMarker=false`. Perform marker HEAD/readiness probes only for creations. Purge authorization/failure is determined by the DELETE itself. This removes the obsolete old-shape HEAD error path, prevents a lost-response retry from re-creating an absent marker, and needs no new wire header or receiver change. Keep current RemoveObject 404/idempotency semantics. Preserve actual target failure in Err, including offline error where appropriate. + +In the outer routine use purge outcomes for audit/event/MRF and for per-target change detection. Map internal purge COMPLETE to operation COMPLETED only when passing an operation status to the existing statistics/event/audit logic; stored purge metadata remains COMPLETE. Feed per-target old/new operation status into stats rather than selecting changes from the unrelated creation status. Keep existing stats policy, no new metrics framework. + +For pre-fan-out exits: config/decision failures remain not-tracked; lock failure retains MRF scheduling; missing configured clients remain logged/skipped and are a documented separate state-preservation limitation. Do not rewrite general `getReplicationState` target merging in this issue. Do not claim global convergence for nil clients or resync narrowed to one of many targets. + +### B. Real MRF healing and bounded retries + +Accept GetObjectInfo MethodNotAllowed only when returned ObjectInfo is a delete marker with nonempty matching bucket/object/version identity. QueueReplicationHeal still rejects zero ModTime. Other errors and invalid/empty ObjectInfo are not queued. Reuse the existing disk persistence/load/queue path; no timer/backoff redesign or format change. + +Carry a RetryCount in the in-memory delete task, pass it from queueReplicationHeal, increment it at ALL THREE submission sites: aggregate failure, lock failure, and queueReplicaDeleteTask queue-full fallback, and include it in ToMRFEntry (whose disk format already has RetryCount). Respect the existing mrfRetryLimit and drop accounting; after budget exhaustion the scanner can still start a fresh heal. This closes the retry-count omission exposed by re-enabling marker MRF. No additional persistence schema. + +### C. Regression and acceptance matrix + +1. Target operation table: create (new/HEAD405/completed/readiness failure/quorum), canonical object purge, canonical marker purge, old marker purge. Pending/failed/completed purge, creation pending/completed/replica; success, DELETE403/405/503, offline, absent version, response lost after real removal, resync success/failure. Verify HTTP method/version/header, exact status fields and reset marker. No HEAD for purges. +2. Full outer call: old/new purge shapes, source/target real erasure metadata, first failure yields purge FAILED while the complete on-disk creation/replica status and timestamp survive (empty creation field in the per-target result is intentional); MRF queue entry exists; correct operation status accounting. +3. Persist that MRF entry with saveMRFEntries, create a fresh ReplicationPool with no in-memory entry, then queueMRFHeal -> loadMRF -> real GetObjectInfo(405) -> QueueReplicationHeal -> delete queue -> replicateDelete. Initially still failing, entry reappears with increased retry count. Recover target, reload/replay and prove source AND target marker versions removed; repeated successful purge remains absent. Repeat in single-drive and 16-drive fixtures. No direct queueReplicationHeal substitute for MRF proof. +4. Creation MRF: failed marker creation metadata also travels the real disk MRF path and reaches the target. Negative MRF lookups (missing/corrupt/nonmarker 405/empty identity) do not schedule mutations. Verify retry budget still drops with counters, including worker-queue saturation, and scanner fallback works. +5. Also persist two creation ARNs and a nonzero creation timestamp, restrict fan-out to one ARN, fail/repeat the purge and verify the full creation block/timestamp is unchanged. This checks preservation only; the separate purge-target subset-merging limitation remains excluded. Verify COMPLETE-to-COMPLETED statistics deltas for Heal/ExistingObject operations. +6. Two independent HTTP targets plus source in local erasure fixtures: one target succeeds, the other fails/offline; source remains with per-target COMPLETE/FAILED purge states and unchanged creation states. Restore failed target via persisted MRF; successful target is not resent, both targets and source are absent at completion. This is 3-endpoint fan-out/metadata evidence, not a production three-daemon SR mesh, process-crash/power-loss or cross-region test. Report that distinction. +7. Run focused replication/delete/MRF/resync tests and race tests, then build/vet appropriate to changed code. Do not run other tasks' whole-repository suites concurrently. Tests must fail on baseline for the repaired paths; existing canonical purge and scanner recovery remain covered. + +## Existing state, compatibility, and delivery + +No current producer emits the old in-memory shape, and DeletedObjectReplicationInfo is not serialized across restart. Old-shape handling is robustness/upgrade compatibility; the directly active repair is marker MRF. Pending/failed marker metadata is consumed using the normal heal/MRF machinery; no bulk migration or live object rewrite. Disk entries whose marker lookup was previously dropped can be rediscovered by the scanner. The valid-405 gate covers nonempty version identities; null/empty-version markers that return ObjectNotFound remain outside it. Local source DeleteObject metadata-write errors retain the existing scanner recovery behavior; this issue does not change that independent failure path. These are explicit acceptance limits, not claims of full failure-mode coverage. Already missing/erased replication tracking or nil target clients cannot be recovered by this patch alone. A durable receiver tombstone for a delayed *creation* after a purge, replica relay behavior, full arbitrary-mesh convergence, and new backoff/observability are separate issues. + +Reuse PR #184's operation-state separation and valid-405 intent, and adapt its useful target/legacy convergence tests; do not copy its unproven permanence claims or adopt the entire PR blindly. Expected work: 1 production file plus focused tests and investigation records (helper placement may touch bucket-replication-utils.go if clearer). No getReplicationState merge change is proposed: the empty creation-update signal is the minimal preservation mechanism. If tests expose a nil ResetStatusesMap on a newly successful purge resync, initialize that map before its existing assignment without changing its merge semantics. Branch only after exact-version Opus consensus. Normal implementation/tests authorized; merge, publish, deploy, and live storage rewrites excluded. + +## Review request + +Verify the classification and every exit against the exact source; challenge the canonical-wire choice, status conversion, MRF validity/retry budget, test sufficiency and scope. Respond with explicit GO/GO_WITH_NONBLOCKING_NOTES/REVISE for plan-v2.md and list blockers separately. Agreement must be on this exact plan hash. No implementation before resolving blockers. + +## v2 changes from Opus v1 + +B1 accepted: remove the proposed creation-status pin, require disk-block preservation and partial-fan-out regression. N1 accepted: outgoing versionId/header assertions remain mandatory. N2 accepted: include queue-full RetryCount increment. N3 accepted: assert status-conversion counter deltas. N4/N5 accepted: scope empty/null identities and unsaved old task shape accurately. N6 accepted: persist each failed entry again and synchronize actual queue delivery between rounds. N7 resolved: capacity-adapted real-storage baseline/PR probes completed; see research.md and baseline-mrf.log/pr184-mrf.log. A prior probe incorrectly treated the empty returned creation result as evidence of a disk-state loss; the reviewer corrected that inference. Raw logs are retained. diff --git a/docs/investigations/r6/plan-v3.md b/docs/investigations/r6/plan-v3.md new file mode 100644 index 000000000..3afcd2216 --- /dev/null +++ b/docs/investigations/r6/plan-v3.md @@ -0,0 +1,69 @@ +# R6 plan v3 — marker purge operation and MRF recovery + +Date: 2026-09-15. The v2 implementation exists locally after v2 consensus; the v3 incremental correction below has NOT been implemented pending review. +Baseline: `9ebe81c1b3611f9cc73e676b5b741c2be62c467a` (clean detached worktree and freshly fetched origin/main agree). +PR #184: OPEN; head `6addf9eb916b5a4b837480cf534cd1efa5407d3c`, code commit `96b21557a85cd4a615ba8a797bfc4c556e413db4`. +Evidence directory: `/Users/vonng/tmp/silo-r6-20260915-aa3f/` (PR JSON/diff, baseline overlays/probes/logs). +Current PGSTY support policy is read from local AGENTS.md, copied verbatim from the main checkout's ignored AGENTS.md. No dependency or supported-stack change. + +## Observed mechanisms and scope + +1. `replicateDeleteToTarget`: an old-shaped task has VersionID empty, DeleteMarkerVersionID set, creation target COMPLETED, purge target PENDING/FAILED. The creation early-return suppresses its DELETE. Offline/RemoveObject error/success select the wrong field for this shape. HEAD-not-ready always overwrites creation status, including purges. The resync defer uses creation COMPLETED even when purge fails; purge COMPLETE early-out only recognizes nonempty VersionID. +2. `replicateDelete`: aggregate audit/event/MRF status and the stats change check still select on VersionID/creation status. Fixing only the target routine leaves a failed old-shaped purge reporting Completed and not entering MRF. +3. `queueMRFHeal`: disk entries are consumed, GetObjectInfo(marker version) returns real metadata plus MethodNotAllowed, and every error is discarded. Direct queueReplicationHeal is NOT an MRF test. +4. Current DELETE producer, scanner/heal, and resync already construct the canonical VersionID purge shape. Existing `TestReplicateDeleteMarkerPurge/recover_legacy_true` proves scanner/heal can recover old state. This is NOT evidence that every failed purge is permanently stuck, nor a defect exclusive to >2 sites. +5. PR #184 has the right state-based classification and 405 recovery direction, but misses the outer status, HEAD failure, resync defer, already-complete guard, bounded delete retry propagation, and real MRF coverage. Its existing-marker test confirms one attempt through a helper HTTP endpoint, not a three-site deployment. Its prose makes stronger permanence/topology claims than the current baseline proves. + +## Proposed minimal implementation + +### A. One operation classification, all exits + +Add a small `DeletedObjectReplicationInfo.isVersionPurge()` helper: true when VersionID is nonempty OR DeleteMarkerVersionID is nonempty and the task's composite VersionPurgeStatus is nonempty. Use the task-level decision in both outer and target functions; do not classify a multi-target task differently merely because one target lacks a map entry. + +For purges leave `rinfo.ReplicationStatus` EMPTY on every exit; `PrevReplicationStatus` still carries the previous value for inspection. This is the per-target no-update intent. Before the outer source DeleteObject write, explicitly enforce the no-update signal for purge: after getReplicationState, set drs.ReplicationStatusInternal="", drs.Targets=nil, drs.ReplicaStatus="". Leave purge fields, reset map, replica timestamp and other fields unchanged. This is necessary because two serialized empty statuses (arn1=;arn2=;) are misparsed by the existing regexp into a nonempty composite. Do not change that shared parser/serializer or getReplicationState merging in R6. Pinning previous status here would instead rebuild creation state from the fan-out subset and is explicitly rejected. Only creation sets ReplicationStatus; only purge sets VersionPurgeStatus. Preserve the full on-disk creation/replica status and timestamp block without rewriting it, including excluded targets. Use this classification for completed early-outs, offline/error/success and resync success. Do not stamp the current resync reset for a failed purge. Existing creation semantics (HEAD 405 means already created, readiness gate, quorum fall-through) remain. + +Route ALL purges through the canonical permanent-delete request already produced by today's handlers: explicit version ID, `ReplicationDeleteMarker=false`. Perform marker HEAD/readiness probes only for creations. Purge authorization/failure is determined by the DELETE itself. This removes the obsolete old-shape HEAD error path, prevents a lost-response retry from re-creating an absent marker, and needs no new wire header or receiver change. Keep current RemoveObject 404/idempotency semantics. Preserve actual target failure in Err, including offline error where appropriate. + +In the outer routine use purge outcomes for audit/event/MRF and for per-target change detection. Map internal purge COMPLETE to operation COMPLETED only when passing an operation status to the existing statistics/event/audit logic; stored purge metadata remains COMPLETE. Feed per-target old/new operation status into stats rather than selecting changes from the unrelated creation status. Keep existing stats policy, no new metrics framework. + +For pre-fan-out exits: config/decision failures remain not-tracked; lock failure retains MRF scheduling; missing configured clients remain logged/skipped and are a documented separate state-preservation limitation. Do not rewrite general `getReplicationState` target merging in this issue. Do not claim global convergence for nil clients or resync narrowed to one of many targets. + +### B. Real MRF healing and bounded retries + +Accept GetObjectInfo MethodNotAllowed only when returned ObjectInfo is a delete marker with nonempty matching bucket/object/version identity. QueueReplicationHeal still rejects zero ModTime. Other errors and invalid/empty ObjectInfo are not queued. Reuse the existing disk persistence/load/queue path; no timer/backoff redesign or format change. + +Carry a RetryCount in the in-memory delete task, pass it from queueReplicationHeal, increment it at ALL THREE submission sites: aggregate failure, lock failure, and queueReplicaDeleteTask queue-full fallback, and include it in ToMRFEntry (whose disk format already has RetryCount). Respect the existing mrfRetryLimit and drop accounting; after budget exhaustion the scanner can still start a fresh heal. This closes the retry-count omission exposed by re-enabling marker MRF. No additional persistence schema. + +### C. Regression and acceptance matrix + +1. Target operation table: create (new/HEAD405/completed/readiness failure/quorum), canonical object purge, canonical marker purge, old marker purge. Pending/failed/completed purge, creation pending/completed/replica; success, DELETE403/405/503, offline, absent version, response lost after real removal, resync success/failure. Verify HTTP method/version/header, exact status fields and reset marker. No HEAD for purges. +2. Full outer call: old/new purge shapes, source/target real erasure metadata, first failure yields purge FAILED while the complete on-disk creation/replica status and timestamp survive (empty creation field in the per-target result is intentional); MRF queue entry exists; correct operation status accounting. +3. Persist that MRF entry with saveMRFEntries, create a fresh ReplicationPool with no in-memory entry, then queueMRFHeal -> loadMRF -> real GetObjectInfo(405) -> QueueReplicationHeal -> delete queue -> replicateDelete. Initially still failing, entry reappears with increased retry count. Recover target, reload/replay and prove source AND target marker versions removed; repeated successful purge remains absent. Repeat in single-drive and 16-drive fixtures. No direct queueReplicationHeal substitute for MRF proof. +4. Creation MRF: failed marker creation metadata also travels the real disk MRF path and reaches the target. Negative MRF lookups (missing/corrupt/nonmarker 405/empty identity) do not schedule mutations. Verify retry budget still drops with counters, including worker-queue saturation, and scanner fallback works. +5. Also persist two creation ARNs and a nonzero creation timestamp, restrict fan-out to one ARN, fail/repeat the purge and verify the full creation block/timestamp is unchanged. This checks preservation only; the separate purge-target subset-merging limitation remains excluded. Verify COMPLETE-to-COMPLETED statistics deltas for Heal/ExistingObject operations. +6. Two independent HTTP targets plus source in local erasure fixtures: one target succeeds, the other fails/offline; source remains with per-target COMPLETE/FAILED purge states and unchanged creation states. Restore failed target via persisted MRF; successful target is not resent, both targets and source are absent at completion. This is 3-endpoint fan-out/metadata evidence, not a production three-daemon SR mesh, process-crash/power-loss or cross-region test. Report that distinction. +7. Run focused replication/delete/MRF/resync tests and race tests, then build/vet appropriate to changed code. Do not run other tasks' whole-repository suites concurrently. Tests must fail on baseline for the repaired paths; existing canonical purge and scanner recovery remain covered. + +## Existing state, compatibility, and delivery + +No current producer emits the old in-memory shape, and DeletedObjectReplicationInfo is not serialized across restart. Old-shape handling is robustness/upgrade compatibility; the directly active repair is marker MRF. Pending/failed marker metadata is consumed using the normal heal/MRF machinery; no bulk migration or live object rewrite. Disk entries whose marker lookup was previously dropped can be rediscovered by the scanner. The valid-405 gate covers nonempty version identities; null/empty-version markers that return ObjectNotFound remain outside it. Local source DeleteObject metadata-write errors retain the existing scanner recovery behavior; this issue does not change that independent failure path. These are explicit acceptance limits, not claims of full failure-mode coverage. Already missing/erased replication tracking or nil target clients cannot be recovered by this patch alone. A durable receiver tombstone for a delayed *creation* after a purge, replica relay behavior, full arbitrary-mesh convergence, and new backoff/observability are separate issues. + +Reuse PR #184's operation-state separation and valid-405 intent, and adapt its useful target/legacy convergence tests; do not copy its unproven permanence claims or adopt the entire PR blindly. Expected work: 1 production file plus focused tests and investigation records (helper placement may touch bucket-replication-utils.go if clearer). No getReplicationState merge change is proposed: explicitly empty the creation/replica update fields only at the outer purge write; this enforces the intended no-update signal even for multiple empty target statuses. If tests expose a nil ResetStatusesMap on a newly successful purge resync, initialize that map before its existing assignment without changing its merge semantics. Branch only after exact-version Opus consensus. Normal implementation/tests authorized; merge, publish, deploy, and live storage rewrites excluded. + +## Review request + +Verify the classification and every exit against the exact source; challenge the canonical-wire choice, status conversion, MRF validity/retry budget, test sufficiency and scope. Respond with explicit GO/GO_WITH_NONBLOCKING_NOTES/REVISE for plan-v3.md and list blockers separately. Agreement must be on this exact plan hash. No implementation before resolving blockers. + +## v2 changes from Opus v1 + +B1 accepted: remove the proposed creation-status pin, require disk-block preservation and partial-fan-out regression. N1 accepted: outgoing versionId/header assertions remain mandatory. N2 accepted: include queue-full RetryCount increment. N3 accepted: assert status-conversion counter deltas. N4/N5 accepted: scope empty/null identities and unsaved old task shape accurately. N6 accepted: persist each failed entry again and synchronize actual queue delivery between rounds. N7 resolved: capacity-adapted real-storage baseline/PR probes completed; see research.md and baseline-mrf.log/pr184-mrf.log. A prior probe incorrectly treated the empty returned creation result as evidence of a disk-state loss; the reviewer corrected that inference. Raw logs are retained. + +## v3 incremental finding and correction + +The v2 proof that empty rinfo.ReplicationStatus necessarily leads to an empty composite was incomplete for multiple targets: regexp `([^=].*?)=([^,].*?);` matches `arn1=;arn2=;` with status `;arn2=`. The usual source purge already has VersionPurgeStatus on disk, which makes erasureObjects.DeleteObject set FileInfo.Deleted=false; that independently protects the disk creation block and explains why the ordinary v2 multi-target tests passed. A task carrying purge state while the disk marker still has only creation metadata can instead set FileInfo.Deleted=true; a failed two-target purge then rewrites the full creation block with empty entries and a new timestamp. + +Real erasure proof: `TestReplicationMRFMarkerRecovery/unrecorded-purge` in cmd/replication-delete-mrf_test.go; raw `v2-unrecorded-purge.log` in the evidence directory. Test fixture creates a normal signed purge task, then locally restores only its test source marker's creation metadata while retaining that task's purge state. With one target succeeding and another failing, v2 rewrites creation fields (test fails). This is a boundary/robustness scenario, not a claim that today's normal producer necessarily races its persisted purge state. It also reveals an inherited weakness; no unrelated parser fix is proposed. + +Minimal correction: the outer `replicateDelete`, immediately after `drs := getReplicationState(...)`, executes the three field assignments listed in §A inside `if isPurge`. `ReplicaStatus` must be empty too because CompositeReplicationStatus otherwise falls back to it. This is a no-update payload, not a request to delete disk creation/replica fields; the xlMetaV2 guard does not run for empty composite. Purge maps and reset timestamps retain their current behavior. Keep per-target status empty as agreed in v2. Add the real unrecorded-purge scenario, plus a small invariant over the source update payload if useful, and rerun ordinary multi-target/partial/replica preservation, MRF, race and lint. + +Production source under review is baseline 9ebe81c1 plus `/Users/vonng/tmp/silo-r6-20260915-aa3f/implementation-v2.diff` (v2 has been implemented after its recorded consensus). Only this additional correction awaits v3 agreement. All other v2 dispositions and scope limits remain. The earlier consensus/raw reviews remain immutable evidence, including the proof now corrected by the executable counterexample. diff --git a/docs/investigations/r6/research.md b/docs/investigations/r6/research.md new file mode 100644 index 000000000..af6e17797 --- /dev/null +++ b/docs/investigations/r6/research.md @@ -0,0 +1,52 @@ +# R6 research and PR #184 review + +## Source identity + +- Worktree baseline / freshly fetched origin/main: `9ebe81c1b3611f9cc73e676b5b741c2be62c467a`. +- PR URL: https://github.com/pgsty/silo/pull/184 +- PR head at this review: `6addf9eb916b5a4b837480cf534cd1efa5407d3c`; OPEN, no reviews returned by GitHub API. Its recorded PR base OID `89637554d60c27cfc51d2281d0a4fe15e415f06d` is older than the fetched branch; current worktree source wins. +- Production diff is exactly 24 insertions/11 removals in bucket-replication.go relative to this baseline. The PR additionally changes tests and adds a convergence write-up. +- No production edits were made during research. All test overlays and raw logs: `/Users/vonng/tmp/silo-r6-20260915-aa3f/`. + +## Reproduced target-path findings + +`review_probe_test.go` is a temporary in-package probe using a real minio-go HTTP client and counted HEAD/DELETE requests. `base-overlay.json` adds the probe; `pr-overlay.json` replaces only bucket-replication.go with the exact PR version for comparison. + +| Probe | Baseline | PR #184 | Required behavior | +|---|---|---|---| +| old shape, creation COMPLETED, purge PENDING, successful target | DELETE 0; creation COMPLETED; purge PENDING | DELETE 1; creation COMPLETED; purge COMPLETE | send purge and finish purge state | +| old shape, creation PENDING, HEAD 403 | creation FAILED; purge PENDING | same | purge must not overwrite creation | +| old shape, ExistingObjectReplicationType, DELETE 403 | purge still PENDING | purge FAILED, but current reset timestamp recorded | failed operation must not mark successful resync | +| old shape, purge already COMPLETE, creation PENDING | two HTTP calls, creation FAILED | two HTTP calls, purge becomes FAILED | skip completed purge, preserve states | + +Raw logs: `baseline.log`, `pr184-probes.log`. The first baseline run also attempted the existing storage tests, which failed at seed PutObject due to the host's free-space percentage threshold. This is a fixture/environment failure, not replication evidence. Follow-up overlays use the existing `tagTestCapacityDisk` adapter: only DiskInfo total/used capacity is adapted; actual storage writes remain on test disks. Host df showed about 10 GiB available but 100% used by filesystem percentage rounding. + +## Additional code-review findings + +- Outer `replicateDelete` chooses operation outcome using VersionID, so PR's preserved creation COMPLETED can conceal old-shape purge FAILED from ObjectReplicationFailed and queueMRFSave. Per-target stats comparisons also use creation fields. +- `VersionPurgeComplete` is `COMPLETE`, while ordinary replication completion is `COMPLETED`. A raw string cast does not take the completion branch of ReplicationStats.Update. +- The old marker-shape request sends ReplicationDeleteMarker=true for purges. Today's canonical purge tasks use false and explicit VersionID. Reuse that existing wire meaning to avoid creating a marker at the absent-version fallback after an ambiguous successful DELETE. +- MRF persists entries on actual local drives, removes the loaded file, then asynchronously reads each object. MethodNotAllowed accompanies real marker ObjectInfo in erasureObjects.getObjectInfo. Tests must cover save/load/fresh pool and subsequent queueing, not call queueReplicationHeal directly. +- DeletedObjectReplicationInfo currently lacks RetryCount; its ToMRFEntry always serializes zero. Restoring marker MRF makes the existing budget omission reachable on the fast retry path. + +## Claims excluded from acceptance + +The current DELETE, scanner/heal, and resync producers already build canonical purges. The inherited old-shape test intentionally repairs a legacy PENDING marker with scanner/heal. It therefore contradicts an unqualified claim that all failed purges persist forever or only manual resync repairs them. R6 does not establish the frequency of failures for any site count, nor prove the cited production 405 traffic is entirely caused by these paths. + +PR documentation describes missing-client status loss, replica relaying, tombstones and arbitrary-mesh recovery. They are separate mechanisms. This patch's acceptance concerns tracked source fan-out with configured reachable/recoverable targets; it does not establish global multi-site convergence under lost state, nil clients, delayed creation after purge, or process/storage failure at every possible point. + +## Real erasure and disk-MRF probes + +`review_integration_test.go` reuses the existing signed DELETE/storage fixture, injects a target 403, preserves the intended creation state explicitly, and persists the actual failed entry. A fresh ReplicationPool then loads the disk record and attempts queueMRFHeal. It validates source and target absence after recovery. + +- Baseline canonical failure: creation result PENDING (lost prior COMPLETED), purge FAILED, one MRF entry. Real source lookup yields matching marker ObjectInfo and MethodNotAllowed. Disk-MRF replay schedules no task (probe fails as expected). +- Baseline old-shaped failure: creation COMPLETED, purge still PENDING, zero MRF entries (probe fails as expected). +- PR canonical failure: creation result is still empty (disk preservation clarified below). Disk-MRF replay now runs and source/target removal succeeds on both single-drive and 16-drive fixtures. +- PR old-shaped failure: creation COMPLETED and purge FAILED, but zero MRF entries. This directly confirms the outer-function omission. +- The existing baseline old-shape recovery test PASSES using its explicit scanner/heal fallback, on both storage fixtures. Therefore scanner recovery is retained as observed evidence, not only a code inference. + +Raw logs: `baseline-mrf.log`, `pr184-mrf.log`. Temporary probe development first hit an unused import and then an uninitialized statistics fixture; those were corrected without changing production code. They do not count as defect evidence. Final logs above contain actual assertion outcomes. + +## Correction after independent Opus review + +Opus v1 B1 disproved the interpretation that a canonical purge's empty creation result means the disk creation state was lost. It is an intentional no-update signal in xlMetaV2.DeleteVersion: the disk block is retained if the composite creation state is empty. `baseline-mrf.log` and `pr184-mrf.log` include a temporary assertion that was too strong; their empty per-target result is not a disk-loss finding. v2 keeps that existing no-update signal, tests the actual persisted block/timestamp, and avoids introducing the partial-fan-out overwrite that a creation-status pin would cause. See decisions-v2.md for all dispositions. diff --git a/docs/investigations/r6/review_integration_test.go.txt b/docs/investigations/r6/review_integration_test.go.txt new file mode 100644 index 000000000..53ef5d8c3 --- /dev/null +++ b/docs/investigations/r6/review_integration_test.go.txt @@ -0,0 +1,181 @@ +// Copyright (c) 2026 PGSTY +// SPDX-License-Identifier: AGPL-3.0-only + +package cmd + +import ( + "bytes" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/minio/madmin-go/v3" + "github.com/minio/minio-go/v7" + "github.com/minio/minio/internal/auth" + "github.com/minio/minio/internal/bucket/replication" + xhttp "github.com/minio/minio/internal/http" + "github.com/minio/minio/internal/once" +) + +// Exercise the actual single-object DELETE handler and erasure metadata. A +// marker purge must delete the target version and finish the source purge; +// merely seeing a 405 on HEAD is not evidence of a completed permanent delete. +func TestReviewR6FailureMRF(t *testing.T) { + defer DetectTestLeak(t)() + for _, legacy := range []bool{false, true} { + t.Run(fmt.Sprintf("recover_legacy_%v", legacy), func(t *testing.T) { + ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{ + t: t, endpoints: []string{"DeleteObject"}, + objAPITest: func(obj ObjectLayer, instanceType, bucket string, router http.Handler, creds auth.Credentials, t *testing.T) { + testReviewR6FailureMRF(obj, instanceType, bucket, router, creds, t, legacy) + }, + }) + }) + } +} + +func testReviewR6FailureMRF(obj ObjectLayer, instanceType, bucket string, router http.Handler, creds auth.Credentials, t *testing.T, legacy bool) { + ctx := t.Context() + oldStats := globalReplicationStats.Swap(NewReplicationStats(ctx, nil)) + defer globalReplicationStats.Store(oldStats) + if pools, ok := obj.(*erasureServerPools); ok { + for _, pool := range pools.serverPools { for _, set := range pool.sets { + disks := set.getDisks() + wrapped := make([]StorageAPI, len(disks)) + for i,d := range disks { if d!=nil {wrapped[i]=tagTestCapacityDisk{StorageAPI:d}} } + set.getDisks = func() []StorageAPI {return wrapped} + }} + } + + const arn = "arn:minio:replication::af470089-d354-4473-934c-9e1f52f6da89:bucket" + const name = "marker" + version := mustGetUUID() + remoteBucket := getRandomBucketName() + if err := obj.MakeBucket(ctx, remoteBucket, MakeBucketOptions{}); err != nil { + t.Fatal(err) + } + if _, err := globalBucketMetadataSys.Update(ctx, bucket, bucketVersioningConfig, enabledBucketVersioningConfig); err != nil { + t.Fatal(err) + } + for _, b := range []string{bucket, remoteBucket} { + if _, err := obj.PutObject(ctx, b, name, mustGetPutObjReader(t, bytes.NewReader([]byte("data")), 4, "", ""), ObjectOptions{Versioned: true}); err != nil { + t.Fatal(err) + } + opts := ObjectOptions{VersionID: version, Versioned: true, DeleteMarker: true, ReplicationRequest: true, MTime: UTCNow()} + opts.SetReplicaStatus(replication.Replica) + if _, err := obj.DeleteObject(ctx, b, name, opts); err != nil { + t.Fatalf("%s: seed marker in %s: %v", instanceType, b, err) + } + } + var reject atomic.Bool + reject.Store(true) + remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + opts := ObjectOptions{VersionID: r.URL.Query().Get("versionId"), Versioned: true} + switch r.Method { + case http.MethodHead: + oi, err := obj.GetObjectInfo(r.Context(), remoteBucket, name, opts) + if oi.DeleteMarker { + w.Header().Set(xhttp.AmzDeleteMarker, "true") + w.Header().Set(xhttp.AmzVersionID, oi.VersionID) + } + if err != nil { + writeErrorResponseHeadersOnly(w, toAPIError(r.Context(), err)) + return + } + w.WriteHeader(http.StatusOK) + case http.MethodDelete: + if reject.Load() {w.WriteHeader(403); fmt.Fprint(w, `AccessDenied`);return} + opts.DeleteMarker = r.Header.Get(xhttp.MinIOSourceDeleteMarker) == "true" + opts.SetReplicaStatus(replication.Replica) + _, err := obj.DeleteObject(r.Context(), remoteBucket, name, opts) + if err != nil && !isErrVersionNotFound(err) && !isErrObjectNotFound(err) { + writeErrorResponse(r.Context(), w, toAPIError(r.Context(), err), r.URL) + return + } + w.WriteHeader(http.StatusNoContent) + default: + t.Errorf("unexpected remote method %s", r.Method) + w.WriteHeader(http.StatusBadRequest) + } + })) + defer remote.Close() + client, err := minio.New(strings.TrimPrefix(remote.URL, "http://"), &minio.Options{Region: "us-east-1"}) + if err != nil { + t.Fatal(err) + } + target := &TargetClient{Client: client, ARN: arn, Bucket: remoteBucket} + globalBucketTargetSys.Lock() + globalBucketTargetSys.arnRemotesMap[arn] = arnTarget{Client: target, lastRefresh: UTCNow()} + globalBucketTargetSys.targetsMap[bucket] = []madmin.BucketTarget{{Arn: arn, TargetBucket: remoteBucket}} + globalBucketTargetSys.Unlock() + globalBucketTargetSys.hMutex.Lock() + globalBucketTargetSys.hc[client.EndpointURL().Host] = epHealth{Online: true} + globalBucketTargetSys.hMutex.Unlock() + meta, err := globalBucketMetadataSys.Get(bucket) + if err != nil { + t.Fatal(err) + } + cfg := configs[0] + cfg.RoleArn = arn + meta.replicationConfig = &cfg + globalBucketMetadataSys.Set(bucket, meta) + worker := make(chan ReplicationWorkerOperation, 1) + p := &ReplicationPool{ + ctx: ctx, + objLayer: obj, + workers: []chan ReplicationWorkerOperation{worker}, + stats: globalReplicationStats.Load(), + mrfSaveCh: make(chan MRFReplicateEntry, 1), + } + oldPool := globalReplicationPool + globalReplicationPool = once.NewSingleton[ReplicationPool]() + globalReplicationPool.Set(p) + defer func() { globalReplicationPool = oldPool }() + + req, err := newTestSignedRequestV4(http.MethodDelete, "/"+bucket+"/"+name+"?versionId="+version, 0, nil, creds.AccessKey, creds.SecretKey, nil) + if err != nil { + t.Fatal(err) + } + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != http.StatusNoContent { + t.Fatalf("DELETE status %d: %s", w.Code, w.Body.String()) + } + var deletion DeletedObjectReplicationInfo + select { + case op := <-worker: + deletion = op.(DeletedObjectReplicationInfo) + case <-time.After(time.Second): + t.Fatal("DELETE did not schedule replication") + } + if deletion.VersionID != version || deletion.DeleteMarkerVersionID != "" { + t.Errorf("purge scheduled as marker creation: version=%q marker=%q", deletion.VersionID, deletion.DeleteMarkerVersionID) + } + deletion.ReplicationState.Targets = map[string]replication.StatusType{arn: replication.Completed} + deletion.ReplicationState.ReplicationStatusInternal = arn+"=COMPLETED;" + if legacy {deletion.VersionID,deletion.DeleteMarkerVersionID="",version} + result:=replicateDelete(ctx,deletion,obj) + t.Logf("legacy=%v result creation=%s purge=%s MRF=%d",legacy,result.ReplicationStatus(),result.VersionPurgeStatus(),len(p.mrfSaveCh)) + if result.VersionPurgeStatus()!=replication.VersionPurgeFailed || result.ReplicationStatus()!=replication.Completed {t.Error("failure stored in incorrect status field")} + var entry MRFReplicateEntry + select {case entry= <-p.mrfSaveCh:default:t.Fatal("failed purge did not enter MRF")} + oi,lookupErr:=obj.GetObjectInfo(ctx,bucket,name,ObjectOptions{VersionID:version}) + t.Logf("real source lookup: name=%s version=%s marker=%v purge=%s err=%v",oi.Name,oi.VersionID,oi.DeleteMarker,oi.VersionPurgeStatus,lookupErr) + if !isErrMethodNotAllowed(lookupErr)||!oi.DeleteMarker {t.Fatal("expected real marker plus 405")} + p.saveMRFEntries(ctx,map[string]MRFReplicateEntry{entry.versionID:entry}) + fresh:= &ReplicationPool{ctx:ctx,objLayer:obj,workers:[]chan ReplicationWorkerOperation{worker},stats:globalReplicationStats.Load(),mrfSaveCh:make(chan MRFReplicateEntry,1)} + globalReplicationPool=once.NewSingleton[ReplicationPool]();globalReplicationPool.Set(fresh) + reject.Store(false) + if err:=fresh.queueMRFHeal();err!=nil{t.Fatal(err)} + select {case op:= <-worker:deletion=op.(DeletedObjectReplicationInfo);case <-time.After(time.Second):t.Fatal("persisted MRF marker lookup was skipped")} + result=replicateDelete(ctx,deletion,obj) + if result.VersionPurgeStatus()!=replication.VersionPurgeComplete {t.Fatalf("MRF retry result: %+v",result)} + for _,b:=range []string{bucket,remoteBucket}{ + oi,err:=obj.GetObjectInfo(ctx,b,name,ObjectOptions{VersionID:version}) + if !isErrVersionNotFound(err)&&!isErrObjectNotFound(err){t.Errorf("marker remains in %s: %+v %v",b,oi,err)} + } +} diff --git a/docs/investigations/r6/review_probe_test.go.txt b/docs/investigations/r6/review_probe_test.go.txt new file mode 100644 index 000000000..4d678e100 --- /dev/null +++ b/docs/investigations/r6/review_probe_test.go.txt @@ -0,0 +1,68 @@ +package cmd +import ( + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "github.com/minio/minio-go/v7" + "github.com/minio/minio/internal/bucket/replication" + xhttp "github.com/minio/minio/internal/http" +) +func TestReviewR6LegacyPurgeRetries(t *testing.T) { + var deletes atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodHead { + w.Header().Set(xhttp.AmzDeleteMarker, "true") + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + deletes.Add(1) + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + client, err := minio.New(strings.TrimPrefix(server.URL, "http://"), &minio.Options{Region: "us-east-1", MaxRetries: 1}) + if err != nil { + t.Fatal(err) + } + old := globalBucketTargetSys + globalBucketTargetSys = &BucketTargetSys{hc: map[string]epHealth{client.EndpointURL().Host: {Online: true}}} + defer func() { globalBucketTargetSys = old }() + d := DeletedObjectReplicationInfo{Bucket: "source", DeletedObject: DeletedObject{ObjectName: "marker", DeleteMarker: true, DeleteMarkerVersionID: mustGetUUID()}} + d.ReplicationState.Targets = map[string]replication.StatusType{"arn1": replication.Completed} + d.ReplicationState.PurgeTargets = map[string]VersionPurgeStatusType{"arn1": replication.VersionPurgePending} + result := replicateDeleteToTarget(t.Context(), d, &TargetClient{Client: client, ARN: "arn1", Bucket: "target"}) + t.Logf("remote DELETE calls=%d, creation=%s, purge=%s", deletes.Load(), result.ReplicationStatus, result.VersionPurgeStatus) + if deletes.Load() != 1 || result.VersionPurgeStatus != replication.VersionPurgeComplete { + t.Error("pending legacy purge was not delivered") + } +} + + +func TestReviewR6PurgeExits(t *testing.T) { + for _, tc := range []struct{name string; creation replication.StatusType; purge VersionPurgeStatusType; head int; existing bool; want VersionPurgeStatusType; wantCalls int; wantReset bool}{ + {"head_forbidden", replication.Pending, replication.VersionPurgePending, 403, false, replication.VersionPurgeFailed, 1, false}, + {"failed_resync", replication.Completed, replication.VersionPurgePending, 405, true, replication.VersionPurgeFailed, 2, false}, + {"already_purged", replication.Pending, replication.VersionPurgeComplete, 405, false, replication.VersionPurgeComplete, 0, false}, + } { + t.Run(tc.name, func(t *testing.T){ + var calls atomic.Int32 + server:=httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter,r *http.Request){ + calls.Add(1) + if r.Method==http.MethodHead {w.Header().Set(xhttp.AmzDeleteMarker,"true"); w.WriteHeader(tc.head);return} + w.WriteHeader(403);fmt.Fprint(w,`AccessDenied`) + })) + defer server.Close() + client,err:=minio.New(strings.TrimPrefix(server.URL,"http://"),&minio.Options{Region:"us-east-1",MaxRetries:1});if err!=nil{t.Fatal(err)} + old:=globalBucketTargetSys;globalBucketTargetSys=&BucketTargetSys{hc:map[string]epHealth{client.EndpointURL().Host:{Online:true}}};defer func(){globalBucketTargetSys=old}() + d:=DeletedObjectReplicationInfo{Bucket:"source",DeletedObject:DeletedObject{ObjectName:"marker",DeleteMarker:true,DeleteMarkerVersionID:mustGetUUID()}} + d.ReplicationState.Targets=map[string]replication.StatusType{"arn1":tc.creation} + d.ReplicationState.PurgeTargets=map[string]VersionPurgeStatusType{"arn1":tc.purge} + if tc.existing {d.OpType=replication.ExistingObjectReplicationType} + got:=replicateDeleteToTarget(t.Context(),d,&TargetClient{Client:client,ARN:"arn1",Bucket:"target",ResetID:"new-reset"}) + t.Logf("calls=%d, creation=%s, purge=%s, reset=%q",calls.Load(),got.ReplicationStatus,got.VersionPurgeStatus,got.ResyncTimestamp) + if got.ReplicationStatus!=tc.creation || got.VersionPurgeStatus!=tc.want || int(calls.Load())!=tc.wantCalls || (got.ResyncTimestamp!="")!=tc.wantReset {t.Errorf("operation classified inconsistently: %+v",got)} + }) + } +} diff --git a/docs/investigations/r6/verification-notes.md b/docs/investigations/r6/verification-notes.md new file mode 100644 index 000000000..530b01d0f --- /dev/null +++ b/docs/investigations/r6/verification-notes.md @@ -0,0 +1,39 @@ +# R6 verification notes and evidence boundaries + +## Reproduction evidence before acceptance + +Raw evidence directory: `/Users/vonng/tmp/silo-r6-20260915-aa3f/`. + +| Log | What it establishes | +|---|---| +| baseline.log | Old task shape skips DELETE; wrong HEAD/outcome fields and already-complete behavior. Initial storage fixture attempts hit the host capacity threshold. | +| baseline-mrf.log | After adapting fixture capacity, existing scanner/heal recovery passes. Actual disk-MRF replay drops a real marker+405. Old-shaped task queues no MRF. The canonical per-target creation-result assertion was later retracted as a disk-loss inference; see decisions-v2.md. | +| pr184-probes.log | Exact PR #184 fixes old successful delivery but leaves HEAD failure, failed resync stamp and already-complete retry errors. | +| pr184-mrf.log | PR canonical disk-MRF recovery works; failed old-shaped purge still queues no MRF. | +| v2-unrecorded-purge.log | V2 can rewrite creation metadata with two empty target statuses when disk purge state is absent. Real source/target storage, no simulated metadata layer. This led to v3 review and the explicit empty update payload. | + +The temporary baseline probes were developed with two fixture corrections (unused import, then missing ReplicationStats initialization). Those development failures are not treated as product evidence. Likewise the initial in-memory `creation=PENDING` observation does not establish a disk-state loss; Opus v1 corrected that inference. Opus v3 then corrected the earlier multi-target regex proof after the real storage counterexample. + +## What the local storage tests exercise + +The suite uses the existing single-drive and 16-drive erasure fixtures, the real signed source DELETE handler, real source/target marker metadata and a real minio-go client over HTTP. Target HTTP adapters call the real ObjectLayer, and can reject a request, be marked offline, or close the connection **after** removing the marker. They are controlled replication target adapters, not three independently booted SILO site-replication daemons. The target adapters do not exercise receiver authentication or the complete target HTTP router; those were not changed by this patch. + +`saveMRFEntries` writes real MRF files to registered fixture drives. Tests read and check the encoded record, re-persist it because loadMRF consumes the file, construct a new empty ReplicationPool, and call queueMRFHeal. The real GetObjectInfo(VersionID) returns marker metadata with 405 and the queued task goes through the actual replication worker channel. The test explicitly receives that task and invokes production replicateDelete to control each failure/recovery round; it does not start the long-running background worker loop. Tests drive persistence directly, rather than waiting for the five-minute timer. They prove pool replacement/disk reload, not process crash or power-loss durability. + +Negative MRF lookups wait for the actual lookup and assert no task arrives within a bounded observation window. Missing, read-error, nonmarker, wrong bucket/object/version, empty info and zero timestamp responses are covered. Null/empty marker version identities that return ObjectNotFound are explicitly outside the new valid-405 gate. + +The multi-target fixture checks one target complete while the other fails/offline, persisted per-target purge states, no extra delivery to the successful target, then recovery and marker removal on source and both targets. The separate partial-fan-out test preserves the complete creation block and its timestamp. It does not claim to fix the pre-existing purge-status subset replacement. + +## Environment failures and fixes to test inputs + +- During the exploratory broad run, the host reported a high used-space percentage. Six unrelated DELETE tests aborted at seed PutObject with `Storage reached its minimum free drive threshold`: TestDeleteObjectConditional, TestDeleteObjectConditionalWithReadQuorumFailure, TestDeleteObjectConditionalVersioned, TestDeleteObjectsVersioned, TestDeleteObject, TestDeleteObjectVersionMarker. See replication-suite.log. That exploratory broad suite was not a pass. After host free space recovered, all six exact tests passed on the final integrated source, without changing those tests or production capacity policy; see verification/rebased-delete-verification.json and rebased-delete-recheck.log. This recheck is not a full cmd-suite run. +- R6 storage fixtures reuse the repository's tagTestCapacityDisk adapter: Total/Used are adapted to the actual free space; object/MRF metadata and data still go to real disks. This isolates host occupancy from replication semantics. +- A test link later failed with `no space left on device` (head-exits.log). Thirteen old Go cache artifacts containing this exact worktree path, totaling 1495 MiB, were removed after identification. No repository data or other tasks' cache entries were selected. The manifest is owned-cache-cleanup.json in the raw evidence directory. Available space also changed due to unrelated host activity; we do not attribute the whole increase to this cleanup. +- The HEAD quorum test initially used HTTP 503 and expected a quorum-code fall-through. Existing ErrorRespToObjectError classifies 503 as backend-down before its S3 code conversion. Final tests separately cover 503 not-ready failure and a non-503 SlowDownRead code reaching the existing quorum branch. No production change to that classification was made. +- Initial lint reported five test-style issues, subsequently corrected. v2-scope-regression.log is an intermediate failed run, including the still-unfixed v3 counterexample and the initial quorum test expectation. It is not final acceptance. + +## Limits retained after R6 + +No three-daemon/full-mesh SR deployment, process restart/crash, cross-region test, production repair, merge or release is claimed. The following pre-existing mechanisms remain separate: absent configured clients and omitted purge target state, target-scoped resync replacing the purge subset, loss of replication tracking, replica relay behavior, delayed marker **creation** after a purge without tombstones, local source metadata-write failures relying on scanner recovery, generic status parser robustness, and new MRF timer/backoff/observability design. + +Current normal producers already emit canonical version purges. The old in-memory task shape is not serialized through restart. Its support is robustness compatibility; marker MRF is the directly active retry-path repair. Do not reuse PR #184's unqualified permanence, request-rate or site-count claims as acceptance conclusions. diff --git a/docs/investigations/r6/verification/baseline-build.log b/docs/investigations/r6/verification/baseline-build.log new file mode 100644 index 000000000..e69de29bb diff --git a/docs/investigations/r6/verification/baseline-lint.log b/docs/investigations/r6/verification/baseline-lint.log new file mode 100644 index 000000000..6a3ebaa7e --- /dev/null +++ b/docs/investigations/r6/verification/baseline-lint.log @@ -0,0 +1 @@ +0 issues. diff --git a/docs/investigations/r6/verification/baseline-race.log b/docs/investigations/r6/verification/baseline-race.log new file mode 100644 index 000000000..320c759d0 --- /dev/null +++ b/docs/investigations/r6/verification/baseline-race.log @@ -0,0 +1,288 @@ +=== RUN TestReplicateDeleteMarkerPurge +=== RUN TestReplicateDeleteMarkerPurge/recover_legacy_false +=== RUN TestReplicateDeleteMarkerPurge/recover_legacy_true +--- PASS: TestReplicateDeleteMarkerPurge (0.84s) + --- PASS: TestReplicateDeleteMarkerPurge/recover_legacy_false (0.39s) + --- PASS: TestReplicateDeleteMarkerPurge/recover_legacy_true (0.44s) +=== RUN TestReplicateDeleteMarkerTargetSemantics +=== RUN TestReplicateDeleteMarkerTargetSemantics/existing_marker_is_idempotent +=== RUN TestReplicateDeleteMarkerTargetSemantics/purge_removes_an_existing_marker +=== RUN TestReplicateDeleteMarkerTargetSemantics/purge_forbidden +=== RUN TestReplicateDeleteMarkerTargetSemantics/purge_method_rejected +=== RUN TestReplicateDeleteMarkerTargetSemantics/purge_unavailable +--- PASS: TestReplicateDeleteMarkerTargetSemantics (0.01s) + --- PASS: TestReplicateDeleteMarkerTargetSemantics/existing_marker_is_idempotent (0.00s) + --- PASS: TestReplicateDeleteMarkerTargetSemantics/purge_removes_an_existing_marker (0.00s) + --- PASS: TestReplicateDeleteMarkerTargetSemantics/purge_forbidden (0.00s) + --- PASS: TestReplicateDeleteMarkerTargetSemantics/purge_method_rejected (0.00s) + --- PASS: TestReplicateDeleteMarkerTargetSemantics/purge_unavailable (0.01s) +=== RUN TestReplicationMRFMarkerRecovery +=== RUN TestReplicationMRFMarkerRecovery/canonical + replication-delete-mrf_test.go:485: ErasureSD: canonical recovered; 1 target(s), persisted MRF, source/target metadata checked +goroutine 5773 [running]: +runtime/debug.Stack() + /opt/homebrew/Cellar/go/1.27.1/libexec/src/runtime/debug/stack.go:26 +0x6c +runtime/debug.PrintStack() + /opt/homebrew/Cellar/go/1.27.1/libexec/src/runtime/debug/stack.go:18 +0x24 +github.com/minio/minio/internal/ioutil.SafeClose[...](...) + /Users/vonng/.codex/worktrees/aa3f/silo/internal/ioutil/ioutil.go:470 +github.com/minio/minio/internal/logger/target/http.(*Target).Cancel(0xc002fd4680) + /Users/vonng/.codex/worktrees/aa3f/silo/internal/logger/target/http/http.go:630 +0x98 +github.com/minio/minio/cmd.replicationTestAudit.func2() + /Users/vonng/.codex/worktrees/aa3f/silo/cmd/replication-delete-mrf_test.go:593 +0xa4 +github.com/minio/minio/cmd.testReplicationMRFMarkerRecovery(0xc000f31d48, {0x1099ca8b0, _}, {_, _}, {_, _}, {_, _}, {{0x105dfce44, ...}, ...}, ...) + /Users/vonng/.codex/worktrees/aa3f/silo/cmd/replication-delete-mrf_test.go:486 +0x5cb8 +github.com/minio/minio/cmd.TestReplicationMRFMarkerRecovery.func1.1({0x1099ca8b0, 0xc002432410}, {0x105df9251, 0x9}, {0xc0024cc180, 0x3c}, {0x109974420, _}, {{0x105dfce44, 0xa}, ...}, ...) + /Users/vonng/.codex/worktrees/aa3f/silo/cmd/replication-delete-mrf_test.go:53 +0x124 +github.com/minio/minio/cmd.ExecObjectLayerAPITest({0xc000f31d48, 0xc002f98030, {0xc002e26720, 0x1, 0x1}, 0x0, {0x0, 0x0, 0x0, {0x0, ...}, ...}}) + /Users/vonng/.codex/worktrees/aa3f/silo/cmd/test-utils_test.go:1789 +0x32c +github.com/minio/minio/cmd.TestReplicationMRFMarkerRecovery.func1(0xc000f31d48) + /Users/vonng/.codex/worktrees/aa3f/silo/cmd/replication-delete-mrf_test.go:52 +0x15c +testing.tRunner(0xc000f31d48, 0xc002777e00) + /opt/homebrew/Cellar/go/1.27.1/libexec/src/testing/testing.go:2193 +0x168 +created by testing.(*T).Run in goroutine 5772 + /opt/homebrew/Cellar/go/1.27.1/libexec/src/testing/testing.go:2258 +0x7c0 + replication-delete-mrf_test.go:485: Erasure: canonical recovered; 1 target(s), persisted MRF, source/target metadata checked +goroutine 5773 [running]: +runtime/debug.Stack() + /opt/homebrew/Cellar/go/1.27.1/libexec/src/runtime/debug/stack.go:26 +0x6c +runtime/debug.PrintStack() + /opt/homebrew/Cellar/go/1.27.1/libexec/src/runtime/debug/stack.go:18 +0x24 +github.com/minio/minio/internal/ioutil.SafeClose[...](...) + /Users/vonng/.codex/worktrees/aa3f/silo/internal/ioutil/ioutil.go:470 +github.com/minio/minio/internal/logger/target/http.(*Target).Cancel(0xc000fed6c0) + /Users/vonng/.codex/worktrees/aa3f/silo/internal/logger/target/http/http.go:630 +0x98 +github.com/minio/minio/cmd.replicationTestAudit.func2() + /Users/vonng/.codex/worktrees/aa3f/silo/cmd/replication-delete-mrf_test.go:593 +0xa4 +github.com/minio/minio/cmd.testReplicationMRFMarkerRecovery(0xc000f31d48, {0x1099ca8b0, _}, {_, _}, {_, _}, {_, _}, {{0x105dfce44, ...}, ...}, ...) + /Users/vonng/.codex/worktrees/aa3f/silo/cmd/replication-delete-mrf_test.go:486 +0x5cb8 +github.com/minio/minio/cmd.TestReplicationMRFMarkerRecovery.func1.1({0x1099ca8b0, 0xc00251aea0}, {0x105df2e8e, 0x7}, {0xc0024cc700, 0x3c}, {0x109974420, _}, {{0x105dfce44, 0xa}, ...}, ...) + /Users/vonng/.codex/worktrees/aa3f/silo/cmd/replication-delete-mrf_test.go:53 +0x124 +github.com/minio/minio/cmd.ExecObjectLayerAPITest({0xc000f31d48, 0xc002f98030, {0xc002e26720, 0x1, 0x1}, 0x0, {0x0, 0x0, 0x0, {0x0, ...}, ...}}) + /Users/vonng/.codex/worktrees/aa3f/silo/cmd/test-utils_test.go:1817 +0x5f0 +github.com/minio/minio/cmd.TestReplicationMRFMarkerRecovery.func1(0xc000f31d48) + /Users/vonng/.codex/worktrees/aa3f/silo/cmd/replication-delete-mrf_test.go:52 +0x15c +testing.tRunner(0xc000f31d48, 0xc002777e00) + /opt/homebrew/Cellar/go/1.27.1/libexec/src/testing/testing.go:2193 +0x168 +created by testing.(*T).Run in goroutine 5772 + /opt/homebrew/Cellar/go/1.27.1/libexec/src/testing/testing.go:2258 +0x7c0 +=== RUN TestReplicationMRFMarkerRecovery/lock-failure + replication-delete-mrf_test.go:485: ErasureSD: lock-failure recovered; 1 target(s), persisted MRF, source/target metadata checked + replication-delete-mrf_test.go:485: Erasure: lock-failure recovered; 1 target(s), persisted MRF, source/target metadata checked +=== RUN TestReplicationMRFMarkerRecovery/invalid-MRF-metadata +=== RUN TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/empty-info +=== RUN TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/not-a-marker +=== RUN TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/wrong-version +=== RUN TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/wrong-bucket +=== RUN TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/wrong-object +=== RUN TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/zero-modtime +=== RUN TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/missing +=== RUN TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/read-error +=== RUN TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/empty-info#01 +=== RUN TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/not-a-marker#01 +=== RUN TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/wrong-version#01 +=== RUN TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/wrong-bucket#01 +=== RUN TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/wrong-object#01 +=== RUN TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/zero-modtime#01 +=== RUN TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/missing#01 +=== RUN TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/read-error#01 +=== RUN TestReplicationMRFMarkerRecovery/legacy + replication-delete-mrf_test.go:485: ErasureSD: legacy recovered; 1 target(s), persisted MRF, source/target metadata checked + replication-delete-mrf_test.go:485: Erasure: legacy recovered; 1 target(s), persisted MRF, source/target metadata checked +=== RUN TestReplicationMRFMarkerRecovery/unrecorded-purge + replication-delete-mrf_test.go:485: ErasureSD: unrecorded-purge recovered; 2 target(s), persisted MRF, source/target metadata checked + replication-delete-mrf_test.go:485: Erasure: unrecorded-purge recovered; 2 target(s), persisted MRF, source/target metadata checked +=== RUN TestReplicationMRFMarkerRecovery/two-targets + replication-delete-mrf_test.go:485: ErasureSD: two-targets recovered; 2 target(s), persisted MRF, source/target metadata checked + replication-delete-mrf_test.go:485: Erasure: two-targets recovered; 2 target(s), persisted MRF, source/target metadata checked +=== RUN TestReplicationMRFMarkerRecovery/two-targets-offline + replication-delete-mrf_test.go:485: ErasureSD: two-targets-offline recovered; 2 target(s), persisted MRF, source/target metadata checked + replication-delete-mrf_test.go:485: Erasure: two-targets-offline recovered; 2 target(s), persisted MRF, source/target metadata checked +=== RUN TestReplicationMRFMarkerRecovery/replica-source + replication-delete-mrf_test.go:485: ErasureSD: replica-source recovered; 1 target(s), persisted MRF, source/target metadata checked + replication-delete-mrf_test.go:485: Erasure: replica-source recovered; 1 target(s), persisted MRF, source/target metadata checked +=== RUN TestReplicationMRFMarkerRecovery/lost-reply-canonical + replication-delete-mrf_test.go:485: ErasureSD: lost-reply-canonical recovered; 1 target(s), persisted MRF, source/target metadata checked + replication-delete-mrf_test.go:485: Erasure: lost-reply-canonical recovered; 1 target(s), persisted MRF, source/target metadata checked +=== RUN TestReplicationMRFMarkerRecovery/lost-reply-legacy + replication-delete-mrf_test.go:485: ErasureSD: lost-reply-legacy recovered; 1 target(s), persisted MRF, source/target metadata checked + replication-delete-mrf_test.go:485: Erasure: lost-reply-legacy recovered; 1 target(s), persisted MRF, source/target metadata checked +=== RUN TestReplicationMRFMarkerRecovery/creation + replication-delete-mrf_test.go:485: ErasureSD: creation recovered; 1 target(s), persisted MRF, source/target metadata checked + replication-delete-mrf_test.go:485: Erasure: creation recovered; 1 target(s), persisted MRF, source/target metadata checked +=== RUN TestReplicationMRFMarkerRecovery/partial-creation-block +=== RUN TestReplicationMRFMarkerRecovery/retry-budget-and-scanner + replication-delete-mrf_test.go:485: ErasureSD: retry-budget-and-scanner recovered; 1 target(s), persisted MRF, source/target metadata checked + replication-delete-mrf_test.go:485: Erasure: retry-budget-and-scanner recovered; 1 target(s), persisted MRF, source/target metadata checked +--- PASS: TestReplicationMRFMarkerRecovery (8.55s) + --- PASS: TestReplicationMRFMarkerRecovery/canonical (0.51s) + --- PASS: TestReplicationMRFMarkerRecovery/lock-failure (0.56s) + --- PASS: TestReplicationMRFMarkerRecovery/invalid-MRF-metadata (2.02s) + --- PASS: TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/empty-info (0.10s) + --- PASS: TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/not-a-marker (0.11s) + --- PASS: TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/wrong-version (0.10s) + --- PASS: TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/wrong-bucket (0.10s) + --- PASS: TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/wrong-object (0.10s) + --- PASS: TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/zero-modtime (0.10s) + --- PASS: TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/missing (0.10s) + --- PASS: TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/read-error (0.10s) + --- PASS: TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/empty-info#01 (0.10s) + --- PASS: TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/not-a-marker#01 (0.11s) + --- PASS: TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/wrong-version#01 (0.11s) + --- PASS: TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/wrong-bucket#01 (0.11s) + --- PASS: TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/wrong-object#01 (0.11s) + --- PASS: TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/zero-modtime#01 (0.11s) + --- PASS: TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/missing#01 (0.11s) + --- PASS: TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/read-error#01 (0.11s) + --- PASS: TestReplicationMRFMarkerRecovery/legacy (0.48s) + --- PASS: TestReplicationMRFMarkerRecovery/unrecorded-purge (0.59s) + --- PASS: TestReplicationMRFMarkerRecovery/two-targets (0.47s) + --- PASS: TestReplicationMRFMarkerRecovery/two-targets-offline (0.53s) + --- PASS: TestReplicationMRFMarkerRecovery/replica-source (0.43s) + --- PASS: TestReplicationMRFMarkerRecovery/lost-reply-canonical (0.68s) + --- PASS: TestReplicationMRFMarkerRecovery/lost-reply-legacy (0.52s) + --- PASS: TestReplicationMRFMarkerRecovery/creation (0.43s) + --- PASS: TestReplicationMRFMarkerRecovery/partial-creation-block (0.78s) + --- PASS: TestReplicationMRFMarkerRecovery/retry-budget-and-scanner (0.55s) +=== RUN TestReplicationDeleteQueueFullRetryBudget +--- PASS: TestReplicationDeleteQueueFullRetryBudget (0.00s) +=== RUN TestReplicateDeleteOperationExits +=== RUN TestReplicateDeleteOperationExits/marker-creation/pending-success +=== RUN TestReplicateDeleteOperationExits/marker-creation/completed-creation +=== RUN TestReplicateDeleteOperationExits/marker-creation/existing-marker +=== RUN TestReplicateDeleteOperationExits/marker-creation/head-read-quorum +=== RUN TestReplicateDeleteOperationExits/marker-creation/replica-creation-status +=== RUN TestReplicateDeleteOperationExits/marker-creation/head-unavailable +=== RUN TestReplicateDeleteOperationExits/marker-creation/head-forbidden +=== RUN TestReplicateDeleteOperationExits/marker-creation/delete-forbidden +=== RUN TestReplicateDeleteOperationExits/marker-creation/delete-method-rejected +=== RUN TestReplicateDeleteOperationExits/marker-creation/delete-unavailable +=== RUN TestReplicateDeleteOperationExits/marker-creation/offline +=== RUN TestReplicateDeleteOperationExits/marker-creation/retry-failed +=== RUN TestReplicateDeleteOperationExits/marker-creation/purge-complete +=== RUN TestReplicateDeleteOperationExits/marker-creation/resync-success +=== RUN TestReplicateDeleteOperationExits/marker-creation/resync-already-purged +=== RUN TestReplicateDeleteOperationExits/marker-creation/resync-failure +=== RUN TestReplicateDeleteOperationExits/legacy-marker-purge/pending-success +=== RUN TestReplicateDeleteOperationExits/legacy-marker-purge/completed-creation +=== RUN TestReplicateDeleteOperationExits/legacy-marker-purge/existing-marker +=== RUN TestReplicateDeleteOperationExits/legacy-marker-purge/head-read-quorum +=== RUN TestReplicateDeleteOperationExits/legacy-marker-purge/replica-creation-status +=== RUN TestReplicateDeleteOperationExits/legacy-marker-purge/head-unavailable +=== RUN TestReplicateDeleteOperationExits/legacy-marker-purge/head-forbidden +=== RUN TestReplicateDeleteOperationExits/legacy-marker-purge/delete-forbidden +=== RUN TestReplicateDeleteOperationExits/legacy-marker-purge/delete-method-rejected +=== RUN TestReplicateDeleteOperationExits/legacy-marker-purge/delete-unavailable +=== RUN TestReplicateDeleteOperationExits/legacy-marker-purge/offline +=== RUN TestReplicateDeleteOperationExits/legacy-marker-purge/retry-failed +=== RUN TestReplicateDeleteOperationExits/legacy-marker-purge/purge-complete +=== RUN TestReplicateDeleteOperationExits/legacy-marker-purge/resync-success +=== RUN TestReplicateDeleteOperationExits/legacy-marker-purge/resync-already-purged +=== RUN TestReplicateDeleteOperationExits/legacy-marker-purge/resync-failure +=== RUN TestReplicateDeleteOperationExits/marker-purge/pending-success +=== RUN TestReplicateDeleteOperationExits/marker-purge/completed-creation +=== RUN TestReplicateDeleteOperationExits/marker-purge/existing-marker +=== RUN TestReplicateDeleteOperationExits/marker-purge/head-read-quorum +=== RUN TestReplicateDeleteOperationExits/marker-purge/replica-creation-status +=== RUN TestReplicateDeleteOperationExits/marker-purge/head-unavailable +=== RUN TestReplicateDeleteOperationExits/marker-purge/head-forbidden +=== RUN TestReplicateDeleteOperationExits/marker-purge/delete-forbidden +=== RUN TestReplicateDeleteOperationExits/marker-purge/delete-method-rejected +=== RUN TestReplicateDeleteOperationExits/marker-purge/delete-unavailable +=== RUN TestReplicateDeleteOperationExits/marker-purge/offline +=== RUN TestReplicateDeleteOperationExits/marker-purge/retry-failed +=== RUN TestReplicateDeleteOperationExits/marker-purge/purge-complete +=== RUN TestReplicateDeleteOperationExits/marker-purge/resync-success +=== RUN TestReplicateDeleteOperationExits/marker-purge/resync-already-purged +=== RUN TestReplicateDeleteOperationExits/marker-purge/resync-failure +=== RUN TestReplicateDeleteOperationExits/object-purge/pending-success +=== RUN TestReplicateDeleteOperationExits/object-purge/completed-creation +=== RUN TestReplicateDeleteOperationExits/object-purge/existing-marker +=== RUN TestReplicateDeleteOperationExits/object-purge/head-read-quorum +=== RUN TestReplicateDeleteOperationExits/object-purge/replica-creation-status +=== RUN TestReplicateDeleteOperationExits/object-purge/head-unavailable +=== RUN TestReplicateDeleteOperationExits/object-purge/head-forbidden +=== RUN TestReplicateDeleteOperationExits/object-purge/delete-forbidden +=== RUN TestReplicateDeleteOperationExits/object-purge/delete-method-rejected +=== RUN TestReplicateDeleteOperationExits/object-purge/delete-unavailable +=== RUN TestReplicateDeleteOperationExits/object-purge/offline +=== RUN TestReplicateDeleteOperationExits/object-purge/retry-failed +=== RUN TestReplicateDeleteOperationExits/object-purge/purge-complete +=== RUN TestReplicateDeleteOperationExits/object-purge/resync-success +=== RUN TestReplicateDeleteOperationExits/object-purge/resync-already-purged +=== RUN TestReplicateDeleteOperationExits/object-purge/resync-failure +--- PASS: TestReplicateDeleteOperationExits (0.58s) + --- PASS: TestReplicateDeleteOperationExits/marker-creation/pending-success (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-creation/completed-creation (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-creation/existing-marker (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-creation/head-read-quorum (0.07s) + --- PASS: TestReplicateDeleteOperationExits/marker-creation/replica-creation-status (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-creation/head-unavailable (0.11s) + --- PASS: TestReplicateDeleteOperationExits/marker-creation/head-forbidden (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-creation/delete-forbidden (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-creation/delete-method-rejected (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-creation/delete-unavailable (0.10s) + --- PASS: TestReplicateDeleteOperationExits/marker-creation/offline (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-creation/retry-failed (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-creation/purge-complete (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-creation/resync-success (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-creation/resync-already-purged (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-creation/resync-failure (0.00s) + --- PASS: TestReplicateDeleteOperationExits/legacy-marker-purge/pending-success (0.00s) + --- PASS: TestReplicateDeleteOperationExits/legacy-marker-purge/completed-creation (0.00s) + --- PASS: TestReplicateDeleteOperationExits/legacy-marker-purge/existing-marker (0.00s) + --- PASS: TestReplicateDeleteOperationExits/legacy-marker-purge/head-read-quorum (0.00s) + --- PASS: TestReplicateDeleteOperationExits/legacy-marker-purge/replica-creation-status (0.00s) + --- PASS: TestReplicateDeleteOperationExits/legacy-marker-purge/head-unavailable (0.00s) + --- PASS: TestReplicateDeleteOperationExits/legacy-marker-purge/head-forbidden (0.00s) + --- PASS: TestReplicateDeleteOperationExits/legacy-marker-purge/delete-forbidden (0.00s) + --- PASS: TestReplicateDeleteOperationExits/legacy-marker-purge/delete-method-rejected (0.00s) + --- PASS: TestReplicateDeleteOperationExits/legacy-marker-purge/delete-unavailable (0.06s) + --- PASS: TestReplicateDeleteOperationExits/legacy-marker-purge/offline (0.00s) + --- PASS: TestReplicateDeleteOperationExits/legacy-marker-purge/retry-failed (0.00s) + --- PASS: TestReplicateDeleteOperationExits/legacy-marker-purge/purge-complete (0.00s) + --- PASS: TestReplicateDeleteOperationExits/legacy-marker-purge/resync-success (0.00s) + --- PASS: TestReplicateDeleteOperationExits/legacy-marker-purge/resync-already-purged (0.00s) + --- PASS: TestReplicateDeleteOperationExits/legacy-marker-purge/resync-failure (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-purge/pending-success (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-purge/completed-creation (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-purge/existing-marker (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-purge/head-read-quorum (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-purge/replica-creation-status (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-purge/head-unavailable (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-purge/head-forbidden (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-purge/delete-forbidden (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-purge/delete-method-rejected (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-purge/delete-unavailable (0.03s) + --- PASS: TestReplicateDeleteOperationExits/marker-purge/offline (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-purge/retry-failed (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-purge/purge-complete (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-purge/resync-success (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-purge/resync-already-purged (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-purge/resync-failure (0.00s) + --- PASS: TestReplicateDeleteOperationExits/object-purge/pending-success (0.00s) + --- PASS: TestReplicateDeleteOperationExits/object-purge/completed-creation (0.00s) + --- PASS: TestReplicateDeleteOperationExits/object-purge/existing-marker (0.00s) + --- PASS: TestReplicateDeleteOperationExits/object-purge/head-read-quorum (0.00s) + --- PASS: TestReplicateDeleteOperationExits/object-purge/replica-creation-status (0.00s) + --- PASS: TestReplicateDeleteOperationExits/object-purge/head-unavailable (0.00s) + --- PASS: TestReplicateDeleteOperationExits/object-purge/head-forbidden (0.00s) + --- PASS: TestReplicateDeleteOperationExits/object-purge/delete-forbidden (0.00s) + --- PASS: TestReplicateDeleteOperationExits/object-purge/delete-method-rejected (0.00s) + --- PASS: TestReplicateDeleteOperationExits/object-purge/delete-unavailable (0.16s) + --- PASS: TestReplicateDeleteOperationExits/object-purge/offline (0.00s) + --- PASS: TestReplicateDeleteOperationExits/object-purge/retry-failed (0.00s) + --- PASS: TestReplicateDeleteOperationExits/object-purge/purge-complete (0.00s) + --- PASS: TestReplicateDeleteOperationExits/object-purge/resync-success (0.00s) + --- PASS: TestReplicateDeleteOperationExits/object-purge/resync-already-purged (0.00s) + --- PASS: TestReplicateDeleteOperationExits/object-purge/resync-failure (0.00s) +=== RUN TestReplicateDeletePurgeMissingTargetState +--- PASS: TestReplicateDeletePurgeMissingTargetState (0.00s) +=== RUN TestReplicationMRFDropsVisible +=== RUN TestReplicationMRFDropsVisible/bucket +=== RUN TestReplicationMRFDropsVisible/node +--- PASS: TestReplicationMRFDropsVisible (0.01s) + --- PASS: TestReplicationMRFDropsVisible/bucket (0.00s) + --- PASS: TestReplicationMRFDropsVisible/node (0.00s) +PASS +ok github.com/minio/minio/cmd 14.031s diff --git a/docs/investigations/r6/verification/baseline-scope.log b/docs/investigations/r6/verification/baseline-scope.log new file mode 100644 index 000000000..489b98293 --- /dev/null +++ b/docs/investigations/r6/verification/baseline-scope.log @@ -0,0 +1,444 @@ +=== RUN TestReplicatedInfos +--- PASS: TestReplicatedInfos (0.00s) +=== RUN TestReplicationResync +--- PASS: TestReplicationResync (0.00s) +=== RUN TestReplicationResyncwrapper +--- PASS: TestReplicationResyncwrapper (0.00s) +=== RUN TestReplicationValidationObjectUsesRulePrefix +=== RUN TestReplicationValidationObjectUsesRulePrefix/empty_prefix +=== RUN TestReplicationValidationObjectUsesRulePrefix/filter_prefix +=== RUN TestReplicationValidationObjectUsesRulePrefix/and_prefix +--- PASS: TestReplicationValidationObjectUsesRulePrefix (0.00s) + --- PASS: TestReplicationValidationObjectUsesRulePrefix/empty_prefix (0.00s) + --- PASS: TestReplicationValidationObjectUsesRulePrefix/filter_prefix (0.00s) + --- PASS: TestReplicationValidationObjectUsesRulePrefix/and_prefix (0.00s) +=== RUN TestResyncBucketFinalize +=== RUN TestResyncBucketFinalize/persists_complete_counts +=== RUN TestResyncBucketFinalize/parent_cancel_during_drain_downgrades_to_failed +=== RUN TestResyncBucketFinalize/user_cancel_persists_canceled +--- PASS: TestResyncBucketFinalize (0.11s) + --- PASS: TestResyncBucketFinalize/persists_complete_counts (0.01s) + --- PASS: TestResyncBucketFinalize/parent_cancel_during_drain_downgrades_to_failed (0.01s) + --- PASS: TestResyncBucketFinalize/user_cancel_persists_canceled (0.01s) +=== RUN TestResyncFinishDrainsResults +--- PASS: TestResyncFinishDrainsResults (0.00s) +=== RUN TestResyncFinishWaitsForInflightWorker +--- PASS: TestResyncFinishWaitsForInflightWorker (0.00s) +=== RUN TestResyncResultFor +=== RUN TestResyncResultFor/completed_update +=== RUN TestResyncResultFor/failed_update_over_existing_version +=== RUN TestResyncResultFor/completed_but_errored_is_a_failure +=== RUN TestResyncResultFor/delete_failed +=== RUN TestResyncResultFor/delete_marker_replicated_counts_zero_bytes +=== RUN TestResyncResultFor/arn_not_attempted_is_a_failure +=== RUN TestResyncResultFor/completed_with_zero_size_falls_back_to_object_size +=== RUN TestResyncResultFor/version_purge_complete_is_a_success +=== RUN TestResyncResultFor/version_purge_failed_is_a_failure +=== RUN TestResyncResultFor/benign_duplicate_412_is_a_success +--- PASS: TestResyncResultFor (0.00s) + --- PASS: TestResyncResultFor/completed_update (0.00s) + --- PASS: TestResyncResultFor/failed_update_over_existing_version (0.00s) + --- PASS: TestResyncResultFor/completed_but_errored_is_a_failure (0.00s) + --- PASS: TestResyncResultFor/delete_failed (0.00s) + --- PASS: TestResyncResultFor/delete_marker_replicated_counts_zero_bytes (0.00s) + --- PASS: TestResyncResultFor/arn_not_attempted_is_a_failure (0.00s) + --- PASS: TestResyncResultFor/completed_with_zero_size_falls_back_to_object_size (0.00s) + --- PASS: TestResyncResultFor/version_purge_complete_is_a_success (0.00s) + --- PASS: TestResyncResultFor/version_purge_failed_is_a_failure (0.00s) + --- PASS: TestResyncResultFor/benign_duplicate_412_is_a_success (0.00s) +=== RUN TestReplicationActionForTargetRetentionRemoval +=== RUN TestReplicationActionForTargetRetentionRemoval/removal_confirmed_by_destination +=== RUN TestReplicationActionForTargetRetentionRemoval/destination_still_holds_the_retention_hidden_from_HEAD +=== RUN TestReplicationActionForTargetRetentionRemoval/retention_hidden_from_HEAD_by_permissions +=== RUN TestReplicationActionForTargetRetentionRemoval/destination_reports_no_object_lock_configuration +=== RUN TestReplicationActionForTargetRetentionRemoval/version_never_had_retention_is_not_confirmed +--- PASS: TestReplicationActionForTargetRetentionRemoval (0.00s) + --- PASS: TestReplicationActionForTargetRetentionRemoval/removal_confirmed_by_destination (0.00s) + --- PASS: TestReplicationActionForTargetRetentionRemoval/destination_still_holds_the_retention_hidden_from_HEAD (0.00s) + --- PASS: TestReplicationActionForTargetRetentionRemoval/retention_hidden_from_HEAD_by_permissions (0.00s) + --- PASS: TestReplicationActionForTargetRetentionRemoval/destination_reports_no_object_lock_configuration (0.00s) + --- PASS: TestReplicationActionForTargetRetentionRemoval/version_never_had_retention_is_not_confirmed (0.00s) +=== RUN TestReplicationActionForTargetNullVersionResync +=== RUN TestReplicationActionForTargetNullVersionResync/destination_holds_retention +=== RUN TestReplicationActionForTargetNullVersionResync/retention_read_denied +--- PASS: TestReplicationActionForTargetNullVersionResync (0.00s) + --- PASS: TestReplicationActionForTargetNullVersionResync/destination_holds_retention (0.00s) + --- PASS: TestReplicationActionForTargetNullVersionResync/retention_read_denied (0.00s) +=== RUN TestReplicationActionForTargetTimestampOnlyRemoval +=== RUN TestReplicationActionForTargetTimestampOnlyRemoval/retention_hidden_from_HEAD_by_permissions +=== RUN TestReplicationActionForTargetTimestampOnlyRemoval/destination_still_holds_the_retention +=== RUN TestReplicationActionForTargetTimestampOnlyRemoval/removal_confirmed_by_destination +--- PASS: TestReplicationActionForTargetTimestampOnlyRemoval (0.00s) + --- PASS: TestReplicationActionForTargetTimestampOnlyRemoval/retention_hidden_from_HEAD_by_permissions (0.00s) + --- PASS: TestReplicationActionForTargetTimestampOnlyRemoval/destination_still_holds_the_retention (0.00s) + --- PASS: TestReplicationActionForTargetTimestampOnlyRemoval/removal_confirmed_by_destination (0.00s) +=== RUN TestReplicateDeleteMarkerPurge +=== RUN TestReplicateDeleteMarkerPurge/recover_legacy_false +=== RUN TestReplicateDeleteMarkerPurge/recover_legacy_true +--- PASS: TestReplicateDeleteMarkerPurge (0.47s) + --- PASS: TestReplicateDeleteMarkerPurge/recover_legacy_false (0.26s) + --- PASS: TestReplicateDeleteMarkerPurge/recover_legacy_true (0.21s) +=== RUN TestReplicateDeleteMarkerTargetSemantics +=== RUN TestReplicateDeleteMarkerTargetSemantics/existing_marker_is_idempotent +=== RUN TestReplicateDeleteMarkerTargetSemantics/purge_removes_an_existing_marker +=== RUN TestReplicateDeleteMarkerTargetSemantics/purge_forbidden +=== RUN TestReplicateDeleteMarkerTargetSemantics/purge_method_rejected +=== RUN TestReplicateDeleteMarkerTargetSemantics/purge_unavailable +--- PASS: TestReplicateDeleteMarkerTargetSemantics (0.17s) + --- PASS: TestReplicateDeleteMarkerTargetSemantics/existing_marker_is_idempotent (0.00s) + --- PASS: TestReplicateDeleteMarkerTargetSemantics/purge_removes_an_existing_marker (0.00s) + --- PASS: TestReplicateDeleteMarkerTargetSemantics/purge_forbidden (0.00s) + --- PASS: TestReplicateDeleteMarkerTargetSemantics/purge_method_rejected (0.00s) + --- PASS: TestReplicateDeleteMarkerTargetSemantics/purge_unavailable (0.17s) +=== RUN TestReplicationMRFMarkerRecovery +=== RUN TestReplicationMRFMarkerRecovery/canonical + replication-delete-mrf_test.go:485: ErasureSD: canonical recovered; 1 target(s), persisted MRF, source/target metadata checked +goroutine 6684 [running]: +runtime/debug.Stack() + /opt/homebrew/Cellar/go/1.27.1/libexec/src/runtime/debug/stack.go:26 +0x64 +runtime/debug.PrintStack() + /opt/homebrew/Cellar/go/1.27.1/libexec/src/runtime/debug/stack.go:18 +0x1c +github.com/minio/minio/internal/ioutil.SafeClose[...](...) + /Users/vonng/.codex/worktrees/aa3f/silo/internal/ioutil/ioutil.go:470 +github.com/minio/minio/internal/logger/target/http.(*Target).Cancel(0x4add905116c0) + /Users/vonng/.codex/worktrees/aa3f/silo/internal/logger/target/http/http.go:630 +0x74 +created by github.com/minio/minio/internal/logger.cancelTargets in goroutine 6745 + /Users/vonng/.codex/worktrees/aa3f/silo/internal/logger/targets.go:213 +0x28 + replication-delete-mrf_test.go:485: Erasure: canonical recovered; 1 target(s), persisted MRF, source/target metadata checked +goroutine 10189 [running]: +runtime/debug.Stack() + /opt/homebrew/Cellar/go/1.27.1/libexec/src/runtime/debug/stack.go:26 +0x64 +runtime/debug.PrintStack() + /opt/homebrew/Cellar/go/1.27.1/libexec/src/runtime/debug/stack.go:18 +0x1c +github.com/minio/minio/internal/ioutil.SafeClose[...](...) + /Users/vonng/.codex/worktrees/aa3f/silo/internal/ioutil/ioutil.go:470 +github.com/minio/minio/internal/logger/target/http.(*Target).Cancel(0x4add9bf7d380) + /Users/vonng/.codex/worktrees/aa3f/silo/internal/logger/target/http/http.go:630 +0x74 +created by github.com/minio/minio/internal/logger.cancelTargets in goroutine 6745 + /Users/vonng/.codex/worktrees/aa3f/silo/internal/logger/targets.go:213 +0x28 +=== RUN TestReplicationMRFMarkerRecovery/lock-failure + replication-delete-mrf_test.go:485: ErasureSD: lock-failure recovered; 1 target(s), persisted MRF, source/target metadata checked + replication-delete-mrf_test.go:485: Erasure: lock-failure recovered; 1 target(s), persisted MRF, source/target metadata checked +=== RUN TestReplicationMRFMarkerRecovery/invalid-MRF-metadata +=== RUN TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/empty-info +=== RUN TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/not-a-marker +=== RUN TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/wrong-version +=== RUN TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/wrong-bucket +=== RUN TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/wrong-object +=== RUN TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/zero-modtime +=== RUN TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/missing +=== RUN TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/read-error +=== RUN TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/empty-info#01 +=== RUN TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/not-a-marker#01 +=== RUN TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/wrong-version#01 +=== RUN TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/wrong-bucket#01 +=== RUN TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/wrong-object#01 +=== RUN TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/zero-modtime#01 +=== RUN TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/missing#01 +=== RUN TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/read-error#01 +=== RUN TestReplicationMRFMarkerRecovery/legacy + replication-delete-mrf_test.go:485: ErasureSD: legacy recovered; 1 target(s), persisted MRF, source/target metadata checked + replication-delete-mrf_test.go:485: Erasure: legacy recovered; 1 target(s), persisted MRF, source/target metadata checked +=== RUN TestReplicationMRFMarkerRecovery/unrecorded-purge + replication-delete-mrf_test.go:485: ErasureSD: unrecorded-purge recovered; 2 target(s), persisted MRF, source/target metadata checked + replication-delete-mrf_test.go:485: Erasure: unrecorded-purge recovered; 2 target(s), persisted MRF, source/target metadata checked +=== RUN TestReplicationMRFMarkerRecovery/two-targets + replication-delete-mrf_test.go:485: ErasureSD: two-targets recovered; 2 target(s), persisted MRF, source/target metadata checked + replication-delete-mrf_test.go:485: Erasure: two-targets recovered; 2 target(s), persisted MRF, source/target metadata checked +=== RUN TestReplicationMRFMarkerRecovery/two-targets-offline + replication-delete-mrf_test.go:485: ErasureSD: two-targets-offline recovered; 2 target(s), persisted MRF, source/target metadata checked + replication-delete-mrf_test.go:485: Erasure: two-targets-offline recovered; 2 target(s), persisted MRF, source/target metadata checked +=== RUN TestReplicationMRFMarkerRecovery/replica-source + replication-delete-mrf_test.go:485: ErasureSD: replica-source recovered; 1 target(s), persisted MRF, source/target metadata checked + replication-delete-mrf_test.go:485: Erasure: replica-source recovered; 1 target(s), persisted MRF, source/target metadata checked +=== RUN TestReplicationMRFMarkerRecovery/lost-reply-canonical + replication-delete-mrf_test.go:485: ErasureSD: lost-reply-canonical recovered; 1 target(s), persisted MRF, source/target metadata checked + replication-delete-mrf_test.go:485: Erasure: lost-reply-canonical recovered; 1 target(s), persisted MRF, source/target metadata checked +=== RUN TestReplicationMRFMarkerRecovery/lost-reply-legacy + replication-delete-mrf_test.go:485: ErasureSD: lost-reply-legacy recovered; 1 target(s), persisted MRF, source/target metadata checked + replication-delete-mrf_test.go:485: Erasure: lost-reply-legacy recovered; 1 target(s), persisted MRF, source/target metadata checked +=== RUN TestReplicationMRFMarkerRecovery/creation + replication-delete-mrf_test.go:485: ErasureSD: creation recovered; 1 target(s), persisted MRF, source/target metadata checked + replication-delete-mrf_test.go:485: Erasure: creation recovered; 1 target(s), persisted MRF, source/target metadata checked +=== RUN TestReplicationMRFMarkerRecovery/partial-creation-block +=== RUN TestReplicationMRFMarkerRecovery/retry-budget-and-scanner + replication-delete-mrf_test.go:485: ErasureSD: retry-budget-and-scanner recovered; 1 target(s), persisted MRF, source/target metadata checked + replication-delete-mrf_test.go:485: Erasure: retry-budget-and-scanner recovered; 1 target(s), persisted MRF, source/target metadata checked +--- PASS: TestReplicationMRFMarkerRecovery (6.13s) + --- PASS: TestReplicationMRFMarkerRecovery/canonical (0.22s) + --- PASS: TestReplicationMRFMarkerRecovery/lock-failure (0.24s) + --- PASS: TestReplicationMRFMarkerRecovery/invalid-MRF-metadata (1.90s) + --- PASS: TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/empty-info (0.10s) + --- PASS: TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/not-a-marker (0.10s) + --- PASS: TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/wrong-version (0.10s) + --- PASS: TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/wrong-bucket (0.10s) + --- PASS: TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/wrong-object (0.10s) + --- PASS: TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/zero-modtime (0.10s) + --- PASS: TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/missing (0.10s) + --- PASS: TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/read-error (0.10s) + --- PASS: TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/empty-info#01 (0.10s) + --- PASS: TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/not-a-marker#01 (0.10s) + --- PASS: TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/wrong-version#01 (0.10s) + --- PASS: TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/wrong-bucket#01 (0.10s) + --- PASS: TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/wrong-object#01 (0.12s) + --- PASS: TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/zero-modtime#01 (0.10s) + --- PASS: TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/missing#01 (0.10s) + --- PASS: TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/read-error#01 (0.10s) + --- PASS: TestReplicationMRFMarkerRecovery/legacy (0.26s) + --- PASS: TestReplicationMRFMarkerRecovery/unrecorded-purge (0.27s) + --- PASS: TestReplicationMRFMarkerRecovery/two-targets (0.28s) + --- PASS: TestReplicationMRFMarkerRecovery/two-targets-offline (0.29s) + --- PASS: TestReplicationMRFMarkerRecovery/replica-source (0.28s) + --- PASS: TestReplicationMRFMarkerRecovery/lost-reply-canonical (0.56s) + --- PASS: TestReplicationMRFMarkerRecovery/lost-reply-legacy (0.56s) + --- PASS: TestReplicationMRFMarkerRecovery/creation (0.34s) + --- PASS: TestReplicationMRFMarkerRecovery/partial-creation-block (0.47s) + --- PASS: TestReplicationMRFMarkerRecovery/retry-budget-and-scanner (0.45s) +=== RUN TestReplicationDeleteQueueFullRetryBudget +--- PASS: TestReplicationDeleteQueueFullRetryBudget (0.00s) +=== RUN TestReplicateDeleteOperationExits +=== RUN TestReplicateDeleteOperationExits/marker-creation/pending-success +=== RUN TestReplicateDeleteOperationExits/marker-creation/completed-creation +=== RUN TestReplicateDeleteOperationExits/marker-creation/existing-marker +=== RUN TestReplicateDeleteOperationExits/marker-creation/head-read-quorum +=== RUN TestReplicateDeleteOperationExits/marker-creation/replica-creation-status +=== RUN TestReplicateDeleteOperationExits/marker-creation/head-unavailable +=== RUN TestReplicateDeleteOperationExits/marker-creation/head-forbidden +=== RUN TestReplicateDeleteOperationExits/marker-creation/delete-forbidden +=== RUN TestReplicateDeleteOperationExits/marker-creation/delete-method-rejected +=== RUN TestReplicateDeleteOperationExits/marker-creation/delete-unavailable +=== RUN TestReplicateDeleteOperationExits/marker-creation/offline +=== RUN TestReplicateDeleteOperationExits/marker-creation/retry-failed +=== RUN TestReplicateDeleteOperationExits/marker-creation/purge-complete +=== RUN TestReplicateDeleteOperationExits/marker-creation/resync-success +=== RUN TestReplicateDeleteOperationExits/marker-creation/resync-already-purged +=== RUN TestReplicateDeleteOperationExits/marker-creation/resync-failure +=== RUN TestReplicateDeleteOperationExits/legacy-marker-purge/pending-success +=== RUN TestReplicateDeleteOperationExits/legacy-marker-purge/completed-creation +=== RUN TestReplicateDeleteOperationExits/legacy-marker-purge/existing-marker +=== RUN TestReplicateDeleteOperationExits/legacy-marker-purge/head-read-quorum +=== RUN TestReplicateDeleteOperationExits/legacy-marker-purge/replica-creation-status +=== RUN TestReplicateDeleteOperationExits/legacy-marker-purge/head-unavailable +=== RUN TestReplicateDeleteOperationExits/legacy-marker-purge/head-forbidden +=== RUN TestReplicateDeleteOperationExits/legacy-marker-purge/delete-forbidden +=== RUN TestReplicateDeleteOperationExits/legacy-marker-purge/delete-method-rejected +=== RUN TestReplicateDeleteOperationExits/legacy-marker-purge/delete-unavailable +=== RUN TestReplicateDeleteOperationExits/legacy-marker-purge/offline +=== RUN TestReplicateDeleteOperationExits/legacy-marker-purge/retry-failed +=== RUN TestReplicateDeleteOperationExits/legacy-marker-purge/purge-complete +=== RUN TestReplicateDeleteOperationExits/legacy-marker-purge/resync-success +=== RUN TestReplicateDeleteOperationExits/legacy-marker-purge/resync-already-purged +=== RUN TestReplicateDeleteOperationExits/legacy-marker-purge/resync-failure +=== RUN TestReplicateDeleteOperationExits/marker-purge/pending-success +=== RUN TestReplicateDeleteOperationExits/marker-purge/completed-creation +=== RUN TestReplicateDeleteOperationExits/marker-purge/existing-marker +=== RUN TestReplicateDeleteOperationExits/marker-purge/head-read-quorum +=== RUN TestReplicateDeleteOperationExits/marker-purge/replica-creation-status +=== RUN TestReplicateDeleteOperationExits/marker-purge/head-unavailable +=== RUN TestReplicateDeleteOperationExits/marker-purge/head-forbidden +=== RUN TestReplicateDeleteOperationExits/marker-purge/delete-forbidden +=== RUN TestReplicateDeleteOperationExits/marker-purge/delete-method-rejected +=== RUN TestReplicateDeleteOperationExits/marker-purge/delete-unavailable +=== RUN TestReplicateDeleteOperationExits/marker-purge/offline +=== RUN TestReplicateDeleteOperationExits/marker-purge/retry-failed +=== RUN TestReplicateDeleteOperationExits/marker-purge/purge-complete +=== RUN TestReplicateDeleteOperationExits/marker-purge/resync-success +=== RUN TestReplicateDeleteOperationExits/marker-purge/resync-already-purged +=== RUN TestReplicateDeleteOperationExits/marker-purge/resync-failure +=== RUN TestReplicateDeleteOperationExits/object-purge/pending-success +=== RUN TestReplicateDeleteOperationExits/object-purge/completed-creation +=== RUN TestReplicateDeleteOperationExits/object-purge/existing-marker +=== RUN TestReplicateDeleteOperationExits/object-purge/head-read-quorum +=== RUN TestReplicateDeleteOperationExits/object-purge/replica-creation-status +=== RUN TestReplicateDeleteOperationExits/object-purge/head-unavailable +=== RUN TestReplicateDeleteOperationExits/object-purge/head-forbidden +=== RUN TestReplicateDeleteOperationExits/object-purge/delete-forbidden +=== RUN TestReplicateDeleteOperationExits/object-purge/delete-method-rejected +=== RUN TestReplicateDeleteOperationExits/object-purge/delete-unavailable +=== RUN TestReplicateDeleteOperationExits/object-purge/offline +=== RUN TestReplicateDeleteOperationExits/object-purge/retry-failed +=== RUN TestReplicateDeleteOperationExits/object-purge/purge-complete +=== RUN TestReplicateDeleteOperationExits/object-purge/resync-success +=== RUN TestReplicateDeleteOperationExits/object-purge/resync-already-purged +=== RUN TestReplicateDeleteOperationExits/object-purge/resync-failure +--- PASS: TestReplicateDeleteOperationExits (0.79s) + --- PASS: TestReplicateDeleteOperationExits/marker-creation/pending-success (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-creation/completed-creation (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-creation/existing-marker (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-creation/head-read-quorum (0.06s) + --- PASS: TestReplicateDeleteOperationExits/marker-creation/replica-creation-status (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-creation/head-unavailable (0.15s) + --- PASS: TestReplicateDeleteOperationExits/marker-creation/head-forbidden (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-creation/delete-forbidden (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-creation/delete-method-rejected (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-creation/delete-unavailable (0.20s) + --- PASS: TestReplicateDeleteOperationExits/marker-creation/offline (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-creation/retry-failed (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-creation/purge-complete (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-creation/resync-success (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-creation/resync-already-purged (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-creation/resync-failure (0.00s) + --- PASS: TestReplicateDeleteOperationExits/legacy-marker-purge/pending-success (0.00s) + --- PASS: TestReplicateDeleteOperationExits/legacy-marker-purge/completed-creation (0.00s) + --- PASS: TestReplicateDeleteOperationExits/legacy-marker-purge/existing-marker (0.00s) + --- PASS: TestReplicateDeleteOperationExits/legacy-marker-purge/head-read-quorum (0.00s) + --- PASS: TestReplicateDeleteOperationExits/legacy-marker-purge/replica-creation-status (0.00s) + --- PASS: TestReplicateDeleteOperationExits/legacy-marker-purge/head-unavailable (0.00s) + --- PASS: TestReplicateDeleteOperationExits/legacy-marker-purge/head-forbidden (0.00s) + --- PASS: TestReplicateDeleteOperationExits/legacy-marker-purge/delete-forbidden (0.00s) + --- PASS: TestReplicateDeleteOperationExits/legacy-marker-purge/delete-method-rejected (0.00s) + --- PASS: TestReplicateDeleteOperationExits/legacy-marker-purge/delete-unavailable (0.05s) + --- PASS: TestReplicateDeleteOperationExits/legacy-marker-purge/offline (0.00s) + --- PASS: TestReplicateDeleteOperationExits/legacy-marker-purge/retry-failed (0.00s) + --- PASS: TestReplicateDeleteOperationExits/legacy-marker-purge/purge-complete (0.00s) + --- PASS: TestReplicateDeleteOperationExits/legacy-marker-purge/resync-success (0.00s) + --- PASS: TestReplicateDeleteOperationExits/legacy-marker-purge/resync-already-purged (0.00s) + --- PASS: TestReplicateDeleteOperationExits/legacy-marker-purge/resync-failure (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-purge/pending-success (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-purge/completed-creation (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-purge/existing-marker (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-purge/head-read-quorum (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-purge/replica-creation-status (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-purge/head-unavailable (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-purge/head-forbidden (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-purge/delete-forbidden (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-purge/delete-method-rejected (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-purge/delete-unavailable (0.14s) + --- PASS: TestReplicateDeleteOperationExits/marker-purge/offline (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-purge/retry-failed (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-purge/purge-complete (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-purge/resync-success (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-purge/resync-already-purged (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-purge/resync-failure (0.00s) + --- PASS: TestReplicateDeleteOperationExits/object-purge/pending-success (0.00s) + --- PASS: TestReplicateDeleteOperationExits/object-purge/completed-creation (0.00s) + --- PASS: TestReplicateDeleteOperationExits/object-purge/existing-marker (0.00s) + --- PASS: TestReplicateDeleteOperationExits/object-purge/head-read-quorum (0.00s) + --- PASS: TestReplicateDeleteOperationExits/object-purge/replica-creation-status (0.00s) + --- PASS: TestReplicateDeleteOperationExits/object-purge/head-unavailable (0.00s) + --- PASS: TestReplicateDeleteOperationExits/object-purge/head-forbidden (0.00s) + --- PASS: TestReplicateDeleteOperationExits/object-purge/delete-forbidden (0.00s) + --- PASS: TestReplicateDeleteOperationExits/object-purge/delete-method-rejected (0.00s) + --- PASS: TestReplicateDeleteOperationExits/object-purge/delete-unavailable (0.16s) + --- PASS: TestReplicateDeleteOperationExits/object-purge/offline (0.00s) + --- PASS: TestReplicateDeleteOperationExits/object-purge/retry-failed (0.00s) + --- PASS: TestReplicateDeleteOperationExits/object-purge/purge-complete (0.00s) + --- PASS: TestReplicateDeleteOperationExits/object-purge/resync-success (0.00s) + --- PASS: TestReplicateDeleteOperationExits/object-purge/resync-already-purged (0.00s) + --- PASS: TestReplicateDeleteOperationExits/object-purge/resync-failure (0.00s) +=== RUN TestReplicateDeletePurgeMissingTargetState +--- PASS: TestReplicateDeletePurgeMissingTargetState (0.00s) +=== RUN TestReplicationMRFDropsVisible +=== RUN TestReplicationMRFDropsVisible/bucket +=== RUN TestReplicationMRFDropsVisible/node +--- PASS: TestReplicationMRFDropsVisible (0.00s) + --- PASS: TestReplicationMRFDropsVisible/bucket (0.00s) + --- PASS: TestReplicationMRFDropsVisible/node (0.00s) +=== RUN TestReplicationObjectDeleteWorkerAffinity +--- PASS: TestReplicationObjectDeleteWorkerAffinity (0.00s) +=== RUN TestSiteResyncCancelState +--- PASS: TestSiteResyncCancelState (0.00s) +=== RUN TestResyncRecoveryOwnsLeaderContext +=== RUN TestResyncRecoveryOwnsLeaderContext/lose_leader_false +=== RUN TestResyncRecoveryOwnsLeaderContext/lose_leader_true +--- PASS: TestResyncRecoveryOwnsLeaderContext (0.00s) + --- PASS: TestResyncRecoveryOwnsLeaderContext/lose_leader_false (0.00s) + --- PASS: TestResyncRecoveryOwnsLeaderContext/lose_leader_true (0.00s) +=== RUN TestResyncCancelRouting +--- PASS: TestResyncCancelRouting (0.00s) +=== RUN TestResyncCancellationWinsFinalization +--- PASS: TestResyncCancellationWinsFinalization (0.00s) +=== RUN TestResyncCancelFullWorkerQueue +--- PASS: TestResyncCancelFullWorkerQueue (0.00s) +=== RUN TestResyncCancelBlockedWalkReceive +--- PASS: TestResyncCancelBlockedWalkReceive (0.00s) +=== RUN TestResyncCancelsOwnedWalkOnError +--- PASS: TestResyncCancelsOwnedWalkOnError (0.00s) +=== RUN TestReplicationTrustControlsInternalOptionsAndEvents +--- PASS: TestReplicationTrustControlsInternalOptionsAndEvents (0.00s) +PASS +ok github.com/minio/minio/cmd 10.118s +=== RUN TestReplicate +=== RUN TestReplicate/#00 +=== RUN TestReplicate/c1test +=== RUN TestReplicate/c1test#01 +=== RUN TestReplicate/c1test#02 +=== RUN TestReplicate/c1test#03 +=== RUN TestReplicate/c1test#04 +=== RUN TestReplicate/c1test#05 +=== RUN TestReplicate/c1test#06 +=== RUN TestReplicate/c1test#07 +=== RUN TestReplicate/c2test +=== RUN TestReplicate/c2test#01 +=== RUN TestReplicate/c2test#02 +=== RUN TestReplicate/c2test#03 +=== RUN TestReplicate/c2test#04 +=== RUN TestReplicate/c2test#05 +=== RUN TestReplicate/c2test#06 +=== RUN TestReplicate/xy/c3test +=== RUN TestReplicate/xyz/c3test +=== RUN TestReplicate/xyz/c3test#01 +=== RUN TestReplicate/xyz/c3test#02 +=== RUN TestReplicate/xyz/c3test#03 +=== RUN TestReplicate/xyz/c3test#04 +=== RUN TestReplicate/xy/c3test#01 +=== RUN TestReplicate/xyz/c3test#05 +=== RUN TestReplicate/xyz/c3test#06 +=== RUN TestReplicate/xyz/c3test#07 +=== RUN TestReplicate/abc/c3test +=== RUN TestReplicate/xy/c4test +=== RUN TestReplicate/xa/c4test +=== RUN TestReplicate/xyz/c4test +=== RUN TestReplicate/xyz/c4test#01 +=== RUN TestReplicate/xyz/c4test#02 +=== RUN TestReplicate/xyz/c4test#03 +=== RUN TestReplicate/abc/c4test +=== RUN TestReplicate/abc/c4test#01 +=== RUN TestReplicate/abc/c4test#02 +=== RUN TestReplicate/abc/c4test#03 +=== RUN TestReplicate/abc/c4test#04 +=== RUN TestReplicate/xy/c5test +=== RUN TestReplicate/xa/c5test +--- PASS: TestReplicate (0.00s) + --- PASS: TestReplicate/#00 (0.00s) + --- PASS: TestReplicate/c1test (0.00s) + --- PASS: TestReplicate/c1test#01 (0.00s) + --- PASS: TestReplicate/c1test#02 (0.00s) + --- PASS: TestReplicate/c1test#03 (0.00s) + --- PASS: TestReplicate/c1test#04 (0.00s) + --- PASS: TestReplicate/c1test#05 (0.00s) + --- PASS: TestReplicate/c1test#06 (0.00s) + --- PASS: TestReplicate/c1test#07 (0.00s) + --- PASS: TestReplicate/c2test (0.00s) + --- PASS: TestReplicate/c2test#01 (0.00s) + --- PASS: TestReplicate/c2test#02 (0.00s) + --- PASS: TestReplicate/c2test#03 (0.00s) + --- PASS: TestReplicate/c2test#04 (0.00s) + --- PASS: TestReplicate/c2test#05 (0.00s) + --- PASS: TestReplicate/c2test#06 (0.00s) + --- PASS: TestReplicate/xy/c3test (0.00s) + --- PASS: TestReplicate/xyz/c3test (0.00s) + --- PASS: TestReplicate/xyz/c3test#01 (0.00s) + --- PASS: TestReplicate/xyz/c3test#02 (0.00s) + --- PASS: TestReplicate/xyz/c3test#03 (0.00s) + --- PASS: TestReplicate/xyz/c3test#04 (0.00s) + --- PASS: TestReplicate/xy/c3test#01 (0.00s) + --- PASS: TestReplicate/xyz/c3test#05 (0.00s) + --- PASS: TestReplicate/xyz/c3test#06 (0.00s) + --- PASS: TestReplicate/xyz/c3test#07 (0.00s) + --- PASS: TestReplicate/abc/c3test (0.00s) + --- PASS: TestReplicate/xy/c4test (0.00s) + --- PASS: TestReplicate/xa/c4test (0.00s) + --- PASS: TestReplicate/xyz/c4test (0.00s) + --- PASS: TestReplicate/xyz/c4test#01 (0.00s) + --- PASS: TestReplicate/xyz/c4test#02 (0.00s) + --- PASS: TestReplicate/xyz/c4test#03 (0.00s) + --- PASS: TestReplicate/abc/c4test (0.00s) + --- PASS: TestReplicate/abc/c4test#01 (0.00s) + --- PASS: TestReplicate/abc/c4test#02 (0.00s) + --- PASS: TestReplicate/abc/c4test#03 (0.00s) + --- PASS: TestReplicate/abc/c4test#04 (0.00s) + --- PASS: TestReplicate/xy/c5test (0.00s) + --- PASS: TestReplicate/xa/c5test (0.00s) +PASS +ok github.com/minio/minio/internal/bucket/replication 0.624s diff --git a/docs/investigations/r6/verification/baseline-verification.json b/docs/investigations/r6/verification/baseline-verification.json new file mode 100644 index 000000000..bd58e9b87 --- /dev/null +++ b/docs/investigations/r6/verification/baseline-verification.json @@ -0,0 +1,101 @@ +{ + "baseline": "9ebe81c1b3611f9cc73e676b5b741c2be62c467a", + "source_sha256": { + "cmd/bucket-replication.go": "999c2818a8980cbeb55cfcbc244840069fb44e042b4b4d6ef7668c2660ce31e0", + "cmd/bucket-replication-utils.go": "365641c760901641e8320cc5123697ab92a46f1add612048b991ed2d1cfad43b", + "cmd/replication-delete-marker_test.go": "d967787804d558ac6266b113228fdf4a4f9fcb7cab39138a4fb07558814ccca4", + "cmd/replication-delete-operation_test.go": "2888a04a543324776041316de2821f388d28c3c1a6f5d0031e5ac9d34f58d504", + "cmd/replication-delete-mrf_test.go": "5e160f7e19cbbb8cd5fa4e7ffd9cff9e09361b3fc4c5ee5ae61458f99c777781" + }, + "checks": [ + { + "name": "scope", + "command": [ + "go", + "test", + "-p", + "2", + "./cmd", + "./internal/bucket/replication", + "-run", + "TestReplication|TestReplicate|TestMRF|TestResync|TestSiteResync", + "-count=1", + "-v" + ], + "exit_code": 0, + "seconds": 38.664, + "log": "/Users/vonng/tmp/silo-r6-20260915-aa3f/final-scope.log", + "log_sha256": "c0f286b61b0a8e668dc92f4d62800f010970331e257ea49db504373d39574370", + "finished_at_utc": "2026-09-15T16:16:22.027596+00:00" + }, + { + "name": "race", + "command": [ + "go", + "test", + "-race", + "-p", + "2", + "./cmd", + "-run", + "TestReplicateDelete|TestReplicationMRF|TestReplicationDeleteQueueFull", + "-count=1", + "-v" + ], + "exit_code": 0, + "seconds": 72.883, + "log": "/Users/vonng/tmp/silo-r6-20260915-aa3f/final-race.log", + "log_sha256": "826c3de1632f026f806474328807408c8e197726a30a70ffff4376004ec51e53", + "finished_at_utc": "2026-09-15T16:17:34.912761+00:00" + }, + { + "name": "build", + "command": [ + "go", + "build", + "-p", + "2", + "./..." + ], + "exit_code": 0, + "seconds": 39.464, + "log": "/Users/vonng/tmp/silo-r6-20260915-aa3f/final-build.log", + "log_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "finished_at_utc": "2026-09-15T16:18:14.378361+00:00" + }, + { + "name": "vet", + "command": [ + "go", + "vet", + "-p", + "2", + "./cmd", + "./internal/bucket/replication" + ], + "exit_code": 0, + "seconds": 10.278, + "log": "/Users/vonng/tmp/silo-r6-20260915-aa3f/final-vet.log", + "log_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "finished_at_utc": "2026-09-15T16:18:24.657311+00:00" + }, + { + "name": "lint", + "command": [ + "/Users/vonng/pgsty/silo/.bin/golangci/v2.13.1/golangci-lint", + "run", + "--build-tags", + "kqueue", + "--timeout=10m", + "--config", + "./.golangci.yml" + ], + "exit_code": 0, + "seconds": 126.658, + "log": "/Users/vonng/tmp/silo-r6-20260915-aa3f/final-lint.log", + "log_sha256": "e92606b0bf483111dff0a120c315ea165821348f31365020e2468a0059095c47", + "finished_at_utc": "2026-09-15T16:20:31.317818+00:00" + } + ], + "source_unchanged": true +} diff --git a/docs/investigations/r6/verification/baseline-vet.log b/docs/investigations/r6/verification/baseline-vet.log new file mode 100644 index 000000000..e69de29bb diff --git a/docs/investigations/r6/verification/rebased-build.log b/docs/investigations/r6/verification/rebased-build.log new file mode 100644 index 000000000..e69de29bb diff --git a/docs/investigations/r6/verification/rebased-delete-recheck.log b/docs/investigations/r6/verification/rebased-delete-recheck.log new file mode 100644 index 000000000..394a1b61c --- /dev/null +++ b/docs/investigations/r6/verification/rebased-delete-recheck.log @@ -0,0 +1,30 @@ +=== RUN TestDeleteObjectConditional +=== RUN TestDeleteObjectConditional/wrong-etag-precondition-failed +=== RUN TestDeleteObjectConditional/missing-object-not-found +=== RUN TestDeleteObjectConditional/correct-etag-succeeds +--- PASS: TestDeleteObjectConditional (0.08s) + --- PASS: TestDeleteObjectConditional/wrong-etag-precondition-failed (0.00s) + --- PASS: TestDeleteObjectConditional/missing-object-not-found (0.00s) + --- PASS: TestDeleteObjectConditional/correct-etag-succeeds (0.00s) +=== RUN TestDeleteObjectConditionalWithReadQuorumFailure +--- PASS: TestDeleteObjectConditionalWithReadQuorumFailure (0.07s) +=== RUN TestDeleteObjectConditionalVersioned +=== RUN TestDeleteObjectConditionalVersioned/wildcard-on-delete-marker-latest +=== RUN TestDeleteObjectConditionalVersioned/explicit-version-selection +=== RUN TestDeleteObjectConditionalVersioned/missing-version +=== RUN TestDeleteObjectConditionalVersioned/missing-version-absent-key +=== RUN TestDeleteObjectConditionalVersioned/wildcard-on-explicit-delete-marker-version +--- PASS: TestDeleteObjectConditionalVersioned (0.13s) + --- PASS: TestDeleteObjectConditionalVersioned/wildcard-on-delete-marker-latest (0.01s) + --- PASS: TestDeleteObjectConditionalVersioned/explicit-version-selection (0.02s) + --- PASS: TestDeleteObjectConditionalVersioned/missing-version (0.01s) + --- PASS: TestDeleteObjectConditionalVersioned/missing-version-absent-key (0.00s) + --- PASS: TestDeleteObjectConditionalVersioned/wildcard-on-explicit-delete-marker-version (0.01s) +=== RUN TestDeleteObjectsVersioned +--- PASS: TestDeleteObjectsVersioned (0.08s) +=== RUN TestDeleteObject +--- PASS: TestDeleteObject (0.45s) +=== RUN TestDeleteObjectVersionMarker +--- PASS: TestDeleteObjectVersionMarker (0.25s) +PASS +ok github.com/minio/minio/cmd 3.066s diff --git a/docs/investigations/r6/verification/rebased-delete-verification.json b/docs/investigations/r6/verification/rebased-delete-verification.json new file mode 100644 index 000000000..bb4e81abc --- /dev/null +++ b/docs/investigations/r6/verification/rebased-delete-verification.json @@ -0,0 +1,32 @@ +{ + "baseline": "cf381a7151ef25fc95ace5fedcd767fa19410de2", + "source_sha256": { + "cmd/bucket-replication.go": "999c2818a8980cbeb55cfcbc244840069fb44e042b4b4d6ef7668c2660ce31e0", + "cmd/bucket-replication-utils.go": "365641c760901641e8320cc5123697ab92a46f1add612048b991ed2d1cfad43b", + "cmd/replication-delete-marker_test.go": "d967787804d558ac6266b113228fdf4a4f9fcb7cab39138a4fb07558814ccca4", + "cmd/replication-delete-operation_test.go": "2888a04a543324776041316de2821f388d28c3c1a6f5d0031e5ac9d34f58d504", + "cmd/replication-delete-mrf_test.go": "5e160f7e19cbbb8cd5fa4e7ffd9cff9e09361b3fc4c5ee5ae61458f99c777781" + }, + "checks": [ + { + "name": "delete-recheck", + "command": [ + "go", + "test", + "-p", + "2", + "./cmd", + "-run", + "^(TestDeleteObjectConditional|TestDeleteObjectConditionalWithReadQuorumFailure|TestDeleteObjectConditionalVersioned|TestDeleteObjectsVersioned|TestDeleteObject|TestDeleteObjectVersionMarker)$", + "-count=1", + "-v" + ], + "exit_code": 0, + "seconds": 11.058, + "log": "/Users/vonng/tmp/silo-r6-20260915-aa3f/rebased-delete-recheck.log", + "log_sha256": "657a04bc3f5143f3355471f46a034ca234648374ca876927e58e5a755613c674", + "finished_at_utc": "2026-09-15T16:30:06.615423+00:00" + } + ], + "source_unchanged": true +} diff --git a/docs/investigations/r6/verification/rebased-lint.log b/docs/investigations/r6/verification/rebased-lint.log new file mode 100644 index 000000000..6a3ebaa7e --- /dev/null +++ b/docs/investigations/r6/verification/rebased-lint.log @@ -0,0 +1 @@ +0 issues. diff --git a/docs/investigations/r6/verification/rebased-race.log b/docs/investigations/r6/verification/rebased-race.log new file mode 100644 index 000000000..b91a048d5 --- /dev/null +++ b/docs/investigations/r6/verification/rebased-race.log @@ -0,0 +1,276 @@ +=== RUN TestReplicateDeleteMarkerPurge +=== RUN TestReplicateDeleteMarkerPurge/recover_legacy_false +=== RUN TestReplicateDeleteMarkerPurge/recover_legacy_true +--- PASS: TestReplicateDeleteMarkerPurge (0.56s) + --- PASS: TestReplicateDeleteMarkerPurge/recover_legacy_false (0.29s) + --- PASS: TestReplicateDeleteMarkerPurge/recover_legacy_true (0.27s) +=== RUN TestReplicateDeleteMarkerTargetSemantics +=== RUN TestReplicateDeleteMarkerTargetSemantics/existing_marker_is_idempotent +=== RUN TestReplicateDeleteMarkerTargetSemantics/purge_removes_an_existing_marker +=== RUN TestReplicateDeleteMarkerTargetSemantics/purge_forbidden +=== RUN TestReplicateDeleteMarkerTargetSemantics/purge_method_rejected +=== RUN TestReplicateDeleteMarkerTargetSemantics/purge_unavailable +--- PASS: TestReplicateDeleteMarkerTargetSemantics (0.09s) + --- PASS: TestReplicateDeleteMarkerTargetSemantics/existing_marker_is_idempotent (0.00s) + --- PASS: TestReplicateDeleteMarkerTargetSemantics/purge_removes_an_existing_marker (0.00s) + --- PASS: TestReplicateDeleteMarkerTargetSemantics/purge_forbidden (0.00s) + --- PASS: TestReplicateDeleteMarkerTargetSemantics/purge_method_rejected (0.00s) + --- PASS: TestReplicateDeleteMarkerTargetSemantics/purge_unavailable (0.09s) +=== RUN TestReplicationMRFMarkerRecovery +=== RUN TestReplicationMRFMarkerRecovery/canonical + replication-delete-mrf_test.go:485: ErasureSD: canonical recovered; 1 target(s), persisted MRF, source/target metadata checked +goroutine 5732 [running]: +runtime/debug.Stack() + /opt/homebrew/Cellar/go/1.27.1/libexec/src/runtime/debug/stack.go:26 +0x6c +runtime/debug.PrintStack() + /opt/homebrew/Cellar/go/1.27.1/libexec/src/runtime/debug/stack.go:18 +0x24 +github.com/minio/minio/internal/ioutil.SafeClose[...](...) + /Users/vonng/.codex/worktrees/aa3f/silo/internal/ioutil/ioutil.go:470 +github.com/minio/minio/internal/logger/target/http.(*Target).Cancel(0xc001231860) + /Users/vonng/.codex/worktrees/aa3f/silo/internal/logger/target/http/http.go:630 +0x98 +github.com/minio/minio/cmd.replicationTestAudit.func2() + /Users/vonng/.codex/worktrees/aa3f/silo/cmd/replication-delete-mrf_test.go:593 +0xa4 +github.com/minio/minio/cmd.testReplicationMRFMarkerRecovery(0xc00137a248, {0x10a44f2c0, _}, {_, _}, {_, _}, {_, _}, {{0x10687dafa, ...}, ...}, ...) + /Users/vonng/.codex/worktrees/aa3f/silo/cmd/replication-delete-mrf_test.go:486 +0x5cb8 +github.com/minio/minio/cmd.TestReplicationMRFMarkerRecovery.func1.1({0x10a44f2c0, 0xc002e19930}, {0x106879eea, 0x9}, {0xc002bf60c0, 0x3c}, {0x10a3f8e30, _}, {{0x10687dafa, 0xa}, ...}, ...) + /Users/vonng/.codex/worktrees/aa3f/silo/cmd/replication-delete-mrf_test.go:53 +0x124 +github.com/minio/minio/cmd.ExecObjectLayerAPITest({0xc00137a248, 0xc002e65e90, {0xc002bf5cd0, 0x1, 0x1}, 0x0, {0x0, 0x0, 0x0, {0x0, ...}, ...}}) + /Users/vonng/.codex/worktrees/aa3f/silo/cmd/test-utils_test.go:1789 +0x32c +github.com/minio/minio/cmd.TestReplicationMRFMarkerRecovery.func1(0xc00137a248) + /Users/vonng/.codex/worktrees/aa3f/silo/cmd/replication-delete-mrf_test.go:52 +0x15c +testing.tRunner(0xc00137a248, 0xc002e65d70) + /opt/homebrew/Cellar/go/1.27.1/libexec/src/testing/testing.go:2193 +0x168 +created by testing.(*T).Run in goroutine 5731 + /opt/homebrew/Cellar/go/1.27.1/libexec/src/testing/testing.go:2258 +0x7c0 + replication-delete-mrf_test.go:485: Erasure: canonical recovered; 1 target(s), persisted MRF, source/target metadata checked +goroutine 9170 [running]: +runtime/debug.Stack() + /opt/homebrew/Cellar/go/1.27.1/libexec/src/runtime/debug/stack.go:26 +0x6c +runtime/debug.PrintStack() + /opt/homebrew/Cellar/go/1.27.1/libexec/src/runtime/debug/stack.go:18 +0x24 +github.com/minio/minio/internal/ioutil.SafeClose[...](...) + /Users/vonng/.codex/worktrees/aa3f/silo/internal/ioutil/ioutil.go:470 +github.com/minio/minio/internal/logger/target/http.(*Target).Cancel(0xc000cce000) + /Users/vonng/.codex/worktrees/aa3f/silo/internal/logger/target/http/http.go:630 +0x98 +created by github.com/minio/minio/internal/logger.cancelTargets in goroutine 5732 + /Users/vonng/.codex/worktrees/aa3f/silo/internal/logger/targets.go:213 +0x3c +=== RUN TestReplicationMRFMarkerRecovery/lock-failure + replication-delete-mrf_test.go:485: ErasureSD: lock-failure recovered; 1 target(s), persisted MRF, source/target metadata checked + replication-delete-mrf_test.go:485: Erasure: lock-failure recovered; 1 target(s), persisted MRF, source/target metadata checked +=== RUN TestReplicationMRFMarkerRecovery/invalid-MRF-metadata +=== RUN TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/empty-info +=== RUN TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/not-a-marker +=== RUN TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/wrong-version +=== RUN TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/wrong-bucket +=== RUN TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/wrong-object +=== RUN TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/zero-modtime +=== RUN TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/missing +=== RUN TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/read-error +=== RUN TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/empty-info#01 +=== RUN TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/not-a-marker#01 +=== RUN TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/wrong-version#01 +=== RUN TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/wrong-bucket#01 +=== RUN TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/wrong-object#01 +=== RUN TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/zero-modtime#01 +=== RUN TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/missing#01 +=== RUN TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/read-error#01 +=== RUN TestReplicationMRFMarkerRecovery/legacy + replication-delete-mrf_test.go:485: ErasureSD: legacy recovered; 1 target(s), persisted MRF, source/target metadata checked + replication-delete-mrf_test.go:485: Erasure: legacy recovered; 1 target(s), persisted MRF, source/target metadata checked +=== RUN TestReplicationMRFMarkerRecovery/unrecorded-purge + replication-delete-mrf_test.go:485: ErasureSD: unrecorded-purge recovered; 2 target(s), persisted MRF, source/target metadata checked + replication-delete-mrf_test.go:485: Erasure: unrecorded-purge recovered; 2 target(s), persisted MRF, source/target metadata checked +=== RUN TestReplicationMRFMarkerRecovery/two-targets + replication-delete-mrf_test.go:485: ErasureSD: two-targets recovered; 2 target(s), persisted MRF, source/target metadata checked + replication-delete-mrf_test.go:485: Erasure: two-targets recovered; 2 target(s), persisted MRF, source/target metadata checked +=== RUN TestReplicationMRFMarkerRecovery/two-targets-offline + replication-delete-mrf_test.go:485: ErasureSD: two-targets-offline recovered; 2 target(s), persisted MRF, source/target metadata checked + replication-delete-mrf_test.go:485: Erasure: two-targets-offline recovered; 2 target(s), persisted MRF, source/target metadata checked +=== RUN TestReplicationMRFMarkerRecovery/replica-source + replication-delete-mrf_test.go:485: ErasureSD: replica-source recovered; 1 target(s), persisted MRF, source/target metadata checked + replication-delete-mrf_test.go:485: Erasure: replica-source recovered; 1 target(s), persisted MRF, source/target metadata checked +=== RUN TestReplicationMRFMarkerRecovery/lost-reply-canonical + replication-delete-mrf_test.go:485: ErasureSD: lost-reply-canonical recovered; 1 target(s), persisted MRF, source/target metadata checked + replication-delete-mrf_test.go:485: Erasure: lost-reply-canonical recovered; 1 target(s), persisted MRF, source/target metadata checked +=== RUN TestReplicationMRFMarkerRecovery/lost-reply-legacy + replication-delete-mrf_test.go:485: ErasureSD: lost-reply-legacy recovered; 1 target(s), persisted MRF, source/target metadata checked + replication-delete-mrf_test.go:485: Erasure: lost-reply-legacy recovered; 1 target(s), persisted MRF, source/target metadata checked +=== RUN TestReplicationMRFMarkerRecovery/creation + replication-delete-mrf_test.go:485: ErasureSD: creation recovered; 1 target(s), persisted MRF, source/target metadata checked + replication-delete-mrf_test.go:485: Erasure: creation recovered; 1 target(s), persisted MRF, source/target metadata checked +=== RUN TestReplicationMRFMarkerRecovery/partial-creation-block +=== RUN TestReplicationMRFMarkerRecovery/retry-budget-and-scanner + replication-delete-mrf_test.go:485: ErasureSD: retry-budget-and-scanner recovered; 1 target(s), persisted MRF, source/target metadata checked + replication-delete-mrf_test.go:485: Erasure: retry-budget-and-scanner recovered; 1 target(s), persisted MRF, source/target metadata checked +--- PASS: TestReplicationMRFMarkerRecovery (6.77s) + --- PASS: TestReplicationMRFMarkerRecovery/canonical (0.31s) + --- PASS: TestReplicationMRFMarkerRecovery/lock-failure (0.29s) + --- PASS: TestReplicationMRFMarkerRecovery/invalid-MRF-metadata (1.88s) + --- PASS: TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/empty-info (0.10s) + --- PASS: TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/not-a-marker (0.10s) + --- PASS: TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/wrong-version (0.10s) + --- PASS: TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/wrong-bucket (0.10s) + --- PASS: TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/wrong-object (0.10s) + --- PASS: TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/zero-modtime (0.10s) + --- PASS: TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/missing (0.10s) + --- PASS: TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/read-error (0.10s) + --- PASS: TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/empty-info#01 (0.10s) + --- PASS: TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/not-a-marker#01 (0.10s) + --- PASS: TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/wrong-version#01 (0.11s) + --- PASS: TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/wrong-bucket#01 (0.10s) + --- PASS: TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/wrong-object#01 (0.10s) + --- PASS: TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/zero-modtime#01 (0.10s) + --- PASS: TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/missing#01 (0.10s) + --- PASS: TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/read-error#01 (0.10s) + --- PASS: TestReplicationMRFMarkerRecovery/legacy (0.27s) + --- PASS: TestReplicationMRFMarkerRecovery/unrecorded-purge (0.35s) + --- PASS: TestReplicationMRFMarkerRecovery/two-targets (0.41s) + --- PASS: TestReplicationMRFMarkerRecovery/two-targets-offline (0.43s) + --- PASS: TestReplicationMRFMarkerRecovery/replica-source (0.40s) + --- PASS: TestReplicationMRFMarkerRecovery/lost-reply-canonical (0.54s) + --- PASS: TestReplicationMRFMarkerRecovery/lost-reply-legacy (0.75s) + --- PASS: TestReplicationMRFMarkerRecovery/creation (0.39s) + --- PASS: TestReplicationMRFMarkerRecovery/partial-creation-block (0.33s) + --- PASS: TestReplicationMRFMarkerRecovery/retry-budget-and-scanner (0.41s) +=== RUN TestReplicationDeleteQueueFullRetryBudget +--- PASS: TestReplicationDeleteQueueFullRetryBudget (0.00s) +=== RUN TestReplicateDeleteOperationExits +=== RUN TestReplicateDeleteOperationExits/marker-creation/pending-success +=== RUN TestReplicateDeleteOperationExits/marker-creation/completed-creation +=== RUN TestReplicateDeleteOperationExits/marker-creation/existing-marker +=== RUN TestReplicateDeleteOperationExits/marker-creation/head-read-quorum +=== RUN TestReplicateDeleteOperationExits/marker-creation/replica-creation-status +=== RUN TestReplicateDeleteOperationExits/marker-creation/head-unavailable +=== RUN TestReplicateDeleteOperationExits/marker-creation/head-forbidden +=== RUN TestReplicateDeleteOperationExits/marker-creation/delete-forbidden +=== RUN TestReplicateDeleteOperationExits/marker-creation/delete-method-rejected +=== RUN TestReplicateDeleteOperationExits/marker-creation/delete-unavailable +=== RUN TestReplicateDeleteOperationExits/marker-creation/offline +=== RUN TestReplicateDeleteOperationExits/marker-creation/retry-failed +=== RUN TestReplicateDeleteOperationExits/marker-creation/purge-complete +=== RUN TestReplicateDeleteOperationExits/marker-creation/resync-success +=== RUN TestReplicateDeleteOperationExits/marker-creation/resync-already-purged +=== RUN TestReplicateDeleteOperationExits/marker-creation/resync-failure +=== RUN TestReplicateDeleteOperationExits/legacy-marker-purge/pending-success +=== RUN TestReplicateDeleteOperationExits/legacy-marker-purge/completed-creation +=== RUN TestReplicateDeleteOperationExits/legacy-marker-purge/existing-marker +=== RUN TestReplicateDeleteOperationExits/legacy-marker-purge/head-read-quorum +=== RUN TestReplicateDeleteOperationExits/legacy-marker-purge/replica-creation-status +=== RUN TestReplicateDeleteOperationExits/legacy-marker-purge/head-unavailable +=== RUN TestReplicateDeleteOperationExits/legacy-marker-purge/head-forbidden +=== RUN TestReplicateDeleteOperationExits/legacy-marker-purge/delete-forbidden +=== RUN TestReplicateDeleteOperationExits/legacy-marker-purge/delete-method-rejected +=== RUN TestReplicateDeleteOperationExits/legacy-marker-purge/delete-unavailable +=== RUN TestReplicateDeleteOperationExits/legacy-marker-purge/offline +=== RUN TestReplicateDeleteOperationExits/legacy-marker-purge/retry-failed +=== RUN TestReplicateDeleteOperationExits/legacy-marker-purge/purge-complete +=== RUN TestReplicateDeleteOperationExits/legacy-marker-purge/resync-success +=== RUN TestReplicateDeleteOperationExits/legacy-marker-purge/resync-already-purged +=== RUN TestReplicateDeleteOperationExits/legacy-marker-purge/resync-failure +=== RUN TestReplicateDeleteOperationExits/marker-purge/pending-success +=== RUN TestReplicateDeleteOperationExits/marker-purge/completed-creation +=== RUN TestReplicateDeleteOperationExits/marker-purge/existing-marker +=== RUN TestReplicateDeleteOperationExits/marker-purge/head-read-quorum +=== RUN TestReplicateDeleteOperationExits/marker-purge/replica-creation-status +=== RUN TestReplicateDeleteOperationExits/marker-purge/head-unavailable +=== RUN TestReplicateDeleteOperationExits/marker-purge/head-forbidden +=== RUN TestReplicateDeleteOperationExits/marker-purge/delete-forbidden +=== RUN TestReplicateDeleteOperationExits/marker-purge/delete-method-rejected +=== RUN TestReplicateDeleteOperationExits/marker-purge/delete-unavailable +=== RUN TestReplicateDeleteOperationExits/marker-purge/offline +=== RUN TestReplicateDeleteOperationExits/marker-purge/retry-failed +=== RUN TestReplicateDeleteOperationExits/marker-purge/purge-complete +=== RUN TestReplicateDeleteOperationExits/marker-purge/resync-success +=== RUN TestReplicateDeleteOperationExits/marker-purge/resync-already-purged +=== RUN TestReplicateDeleteOperationExits/marker-purge/resync-failure +=== RUN TestReplicateDeleteOperationExits/object-purge/pending-success +=== RUN TestReplicateDeleteOperationExits/object-purge/completed-creation +=== RUN TestReplicateDeleteOperationExits/object-purge/existing-marker +=== RUN TestReplicateDeleteOperationExits/object-purge/head-read-quorum +=== RUN TestReplicateDeleteOperationExits/object-purge/replica-creation-status +=== RUN TestReplicateDeleteOperationExits/object-purge/head-unavailable +=== RUN TestReplicateDeleteOperationExits/object-purge/head-forbidden +=== RUN TestReplicateDeleteOperationExits/object-purge/delete-forbidden +=== RUN TestReplicateDeleteOperationExits/object-purge/delete-method-rejected +=== RUN TestReplicateDeleteOperationExits/object-purge/delete-unavailable +=== RUN TestReplicateDeleteOperationExits/object-purge/offline +=== RUN TestReplicateDeleteOperationExits/object-purge/retry-failed +=== RUN TestReplicateDeleteOperationExits/object-purge/purge-complete +=== RUN TestReplicateDeleteOperationExits/object-purge/resync-success +=== RUN TestReplicateDeleteOperationExits/object-purge/resync-already-purged +=== RUN TestReplicateDeleteOperationExits/object-purge/resync-failure +--- PASS: TestReplicateDeleteOperationExits (0.43s) + --- PASS: TestReplicateDeleteOperationExits/marker-creation/pending-success (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-creation/completed-creation (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-creation/existing-marker (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-creation/head-read-quorum (0.08s) + --- PASS: TestReplicateDeleteOperationExits/marker-creation/replica-creation-status (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-creation/head-unavailable (0.02s) + --- PASS: TestReplicateDeleteOperationExits/marker-creation/head-forbidden (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-creation/delete-forbidden (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-creation/delete-method-rejected (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-creation/delete-unavailable (0.02s) + --- PASS: TestReplicateDeleteOperationExits/marker-creation/offline (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-creation/retry-failed (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-creation/purge-complete (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-creation/resync-success (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-creation/resync-already-purged (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-creation/resync-failure (0.00s) + --- PASS: TestReplicateDeleteOperationExits/legacy-marker-purge/pending-success (0.00s) + --- PASS: TestReplicateDeleteOperationExits/legacy-marker-purge/completed-creation (0.00s) + --- PASS: TestReplicateDeleteOperationExits/legacy-marker-purge/existing-marker (0.00s) + --- PASS: TestReplicateDeleteOperationExits/legacy-marker-purge/head-read-quorum (0.00s) + --- PASS: TestReplicateDeleteOperationExits/legacy-marker-purge/replica-creation-status (0.00s) + --- PASS: TestReplicateDeleteOperationExits/legacy-marker-purge/head-unavailable (0.00s) + --- PASS: TestReplicateDeleteOperationExits/legacy-marker-purge/head-forbidden (0.00s) + --- PASS: TestReplicateDeleteOperationExits/legacy-marker-purge/delete-forbidden (0.00s) + --- PASS: TestReplicateDeleteOperationExits/legacy-marker-purge/delete-method-rejected (0.00s) + --- PASS: TestReplicateDeleteOperationExits/legacy-marker-purge/delete-unavailable (0.08s) + --- PASS: TestReplicateDeleteOperationExits/legacy-marker-purge/offline (0.00s) + --- PASS: TestReplicateDeleteOperationExits/legacy-marker-purge/retry-failed (0.00s) + --- PASS: TestReplicateDeleteOperationExits/legacy-marker-purge/purge-complete (0.00s) + --- PASS: TestReplicateDeleteOperationExits/legacy-marker-purge/resync-success (0.00s) + --- PASS: TestReplicateDeleteOperationExits/legacy-marker-purge/resync-already-purged (0.00s) + --- PASS: TestReplicateDeleteOperationExits/legacy-marker-purge/resync-failure (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-purge/pending-success (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-purge/completed-creation (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-purge/existing-marker (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-purge/head-read-quorum (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-purge/replica-creation-status (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-purge/head-unavailable (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-purge/head-forbidden (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-purge/delete-forbidden (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-purge/delete-method-rejected (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-purge/delete-unavailable (0.12s) + --- PASS: TestReplicateDeleteOperationExits/marker-purge/offline (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-purge/retry-failed (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-purge/purge-complete (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-purge/resync-success (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-purge/resync-already-purged (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-purge/resync-failure (0.00s) + --- PASS: TestReplicateDeleteOperationExits/object-purge/pending-success (0.00s) + --- PASS: TestReplicateDeleteOperationExits/object-purge/completed-creation (0.00s) + --- PASS: TestReplicateDeleteOperationExits/object-purge/existing-marker (0.00s) + --- PASS: TestReplicateDeleteOperationExits/object-purge/head-read-quorum (0.00s) + --- PASS: TestReplicateDeleteOperationExits/object-purge/replica-creation-status (0.00s) + --- PASS: TestReplicateDeleteOperationExits/object-purge/head-unavailable (0.00s) + --- PASS: TestReplicateDeleteOperationExits/object-purge/head-forbidden (0.00s) + --- PASS: TestReplicateDeleteOperationExits/object-purge/delete-forbidden (0.00s) + --- PASS: TestReplicateDeleteOperationExits/object-purge/delete-method-rejected (0.00s) + --- PASS: TestReplicateDeleteOperationExits/object-purge/delete-unavailable (0.07s) + --- PASS: TestReplicateDeleteOperationExits/object-purge/offline (0.00s) + --- PASS: TestReplicateDeleteOperationExits/object-purge/retry-failed (0.00s) + --- PASS: TestReplicateDeleteOperationExits/object-purge/purge-complete (0.00s) + --- PASS: TestReplicateDeleteOperationExits/object-purge/resync-success (0.00s) + --- PASS: TestReplicateDeleteOperationExits/object-purge/resync-already-purged (0.00s) + --- PASS: TestReplicateDeleteOperationExits/object-purge/resync-failure (0.00s) +=== RUN TestReplicateDeletePurgeMissingTargetState +--- PASS: TestReplicateDeletePurgeMissingTargetState (0.00s) +=== RUN TestReplicationMRFDropsVisible +=== RUN TestReplicationMRFDropsVisible/node +=== RUN TestReplicationMRFDropsVisible/bucket +--- PASS: TestReplicationMRFDropsVisible (0.01s) + --- PASS: TestReplicationMRFDropsVisible/node (0.00s) + --- PASS: TestReplicationMRFDropsVisible/bucket (0.00s) +PASS +ok github.com/minio/minio/cmd 11.683s diff --git a/docs/investigations/r6/verification/rebased-scope.log b/docs/investigations/r6/verification/rebased-scope.log new file mode 100644 index 000000000..32cfe5b38 --- /dev/null +++ b/docs/investigations/r6/verification/rebased-scope.log @@ -0,0 +1,444 @@ +=== RUN TestReplicatedInfos +--- PASS: TestReplicatedInfos (0.00s) +=== RUN TestReplicationResync +--- PASS: TestReplicationResync (0.00s) +=== RUN TestReplicationResyncwrapper +--- PASS: TestReplicationResyncwrapper (0.00s) +=== RUN TestReplicationValidationObjectUsesRulePrefix +=== RUN TestReplicationValidationObjectUsesRulePrefix/empty_prefix +=== RUN TestReplicationValidationObjectUsesRulePrefix/filter_prefix +=== RUN TestReplicationValidationObjectUsesRulePrefix/and_prefix +--- PASS: TestReplicationValidationObjectUsesRulePrefix (0.00s) + --- PASS: TestReplicationValidationObjectUsesRulePrefix/empty_prefix (0.00s) + --- PASS: TestReplicationValidationObjectUsesRulePrefix/filter_prefix (0.00s) + --- PASS: TestReplicationValidationObjectUsesRulePrefix/and_prefix (0.00s) +=== RUN TestResyncBucketFinalize +=== RUN TestResyncBucketFinalize/persists_complete_counts +=== RUN TestResyncBucketFinalize/parent_cancel_during_drain_downgrades_to_failed +=== RUN TestResyncBucketFinalize/user_cancel_persists_canceled +--- PASS: TestResyncBucketFinalize (0.13s) + --- PASS: TestResyncBucketFinalize/persists_complete_counts (0.01s) + --- PASS: TestResyncBucketFinalize/parent_cancel_during_drain_downgrades_to_failed (0.01s) + --- PASS: TestResyncBucketFinalize/user_cancel_persists_canceled (0.01s) +=== RUN TestResyncFinishDrainsResults +--- PASS: TestResyncFinishDrainsResults (0.00s) +=== RUN TestResyncFinishWaitsForInflightWorker +--- PASS: TestResyncFinishWaitsForInflightWorker (0.00s) +=== RUN TestResyncResultFor +=== RUN TestResyncResultFor/completed_update +=== RUN TestResyncResultFor/failed_update_over_existing_version +=== RUN TestResyncResultFor/completed_but_errored_is_a_failure +=== RUN TestResyncResultFor/delete_failed +=== RUN TestResyncResultFor/delete_marker_replicated_counts_zero_bytes +=== RUN TestResyncResultFor/arn_not_attempted_is_a_failure +=== RUN TestResyncResultFor/completed_with_zero_size_falls_back_to_object_size +=== RUN TestResyncResultFor/version_purge_complete_is_a_success +=== RUN TestResyncResultFor/version_purge_failed_is_a_failure +=== RUN TestResyncResultFor/benign_duplicate_412_is_a_success +--- PASS: TestResyncResultFor (0.00s) + --- PASS: TestResyncResultFor/completed_update (0.00s) + --- PASS: TestResyncResultFor/failed_update_over_existing_version (0.00s) + --- PASS: TestResyncResultFor/completed_but_errored_is_a_failure (0.00s) + --- PASS: TestResyncResultFor/delete_failed (0.00s) + --- PASS: TestResyncResultFor/delete_marker_replicated_counts_zero_bytes (0.00s) + --- PASS: TestResyncResultFor/arn_not_attempted_is_a_failure (0.00s) + --- PASS: TestResyncResultFor/completed_with_zero_size_falls_back_to_object_size (0.00s) + --- PASS: TestResyncResultFor/version_purge_complete_is_a_success (0.00s) + --- PASS: TestResyncResultFor/version_purge_failed_is_a_failure (0.00s) + --- PASS: TestResyncResultFor/benign_duplicate_412_is_a_success (0.00s) +=== RUN TestReplicationActionForTargetRetentionRemoval +=== RUN TestReplicationActionForTargetRetentionRemoval/removal_confirmed_by_destination +=== RUN TestReplicationActionForTargetRetentionRemoval/destination_still_holds_the_retention_hidden_from_HEAD +=== RUN TestReplicationActionForTargetRetentionRemoval/retention_hidden_from_HEAD_by_permissions +=== RUN TestReplicationActionForTargetRetentionRemoval/destination_reports_no_object_lock_configuration +=== RUN TestReplicationActionForTargetRetentionRemoval/version_never_had_retention_is_not_confirmed +--- PASS: TestReplicationActionForTargetRetentionRemoval (0.00s) + --- PASS: TestReplicationActionForTargetRetentionRemoval/removal_confirmed_by_destination (0.00s) + --- PASS: TestReplicationActionForTargetRetentionRemoval/destination_still_holds_the_retention_hidden_from_HEAD (0.00s) + --- PASS: TestReplicationActionForTargetRetentionRemoval/retention_hidden_from_HEAD_by_permissions (0.00s) + --- PASS: TestReplicationActionForTargetRetentionRemoval/destination_reports_no_object_lock_configuration (0.00s) + --- PASS: TestReplicationActionForTargetRetentionRemoval/version_never_had_retention_is_not_confirmed (0.00s) +=== RUN TestReplicationActionForTargetNullVersionResync +=== RUN TestReplicationActionForTargetNullVersionResync/destination_holds_retention +=== RUN TestReplicationActionForTargetNullVersionResync/retention_read_denied +--- PASS: TestReplicationActionForTargetNullVersionResync (0.00s) + --- PASS: TestReplicationActionForTargetNullVersionResync/destination_holds_retention (0.00s) + --- PASS: TestReplicationActionForTargetNullVersionResync/retention_read_denied (0.00s) +=== RUN TestReplicationActionForTargetTimestampOnlyRemoval +=== RUN TestReplicationActionForTargetTimestampOnlyRemoval/retention_hidden_from_HEAD_by_permissions +=== RUN TestReplicationActionForTargetTimestampOnlyRemoval/destination_still_holds_the_retention +=== RUN TestReplicationActionForTargetTimestampOnlyRemoval/removal_confirmed_by_destination +--- PASS: TestReplicationActionForTargetTimestampOnlyRemoval (0.00s) + --- PASS: TestReplicationActionForTargetTimestampOnlyRemoval/retention_hidden_from_HEAD_by_permissions (0.00s) + --- PASS: TestReplicationActionForTargetTimestampOnlyRemoval/destination_still_holds_the_retention (0.00s) + --- PASS: TestReplicationActionForTargetTimestampOnlyRemoval/removal_confirmed_by_destination (0.00s) +=== RUN TestReplicateDeleteMarkerPurge +=== RUN TestReplicateDeleteMarkerPurge/recover_legacy_false +=== RUN TestReplicateDeleteMarkerPurge/recover_legacy_true +--- PASS: TestReplicateDeleteMarkerPurge (0.61s) + --- PASS: TestReplicateDeleteMarkerPurge/recover_legacy_false (0.31s) + --- PASS: TestReplicateDeleteMarkerPurge/recover_legacy_true (0.30s) +=== RUN TestReplicateDeleteMarkerTargetSemantics +=== RUN TestReplicateDeleteMarkerTargetSemantics/existing_marker_is_idempotent +=== RUN TestReplicateDeleteMarkerTargetSemantics/purge_removes_an_existing_marker +=== RUN TestReplicateDeleteMarkerTargetSemantics/purge_forbidden +=== RUN TestReplicateDeleteMarkerTargetSemantics/purge_method_rejected +=== RUN TestReplicateDeleteMarkerTargetSemantics/purge_unavailable +--- PASS: TestReplicateDeleteMarkerTargetSemantics (0.15s) + --- PASS: TestReplicateDeleteMarkerTargetSemantics/existing_marker_is_idempotent (0.00s) + --- PASS: TestReplicateDeleteMarkerTargetSemantics/purge_removes_an_existing_marker (0.00s) + --- PASS: TestReplicateDeleteMarkerTargetSemantics/purge_forbidden (0.00s) + --- PASS: TestReplicateDeleteMarkerTargetSemantics/purge_method_rejected (0.00s) + --- PASS: TestReplicateDeleteMarkerTargetSemantics/purge_unavailable (0.15s) +=== RUN TestReplicationMRFMarkerRecovery +=== RUN TestReplicationMRFMarkerRecovery/canonical + replication-delete-mrf_test.go:485: ErasureSD: canonical recovered; 1 target(s), persisted MRF, source/target metadata checked +goroutine 6994 [running]: +runtime/debug.Stack() + /opt/homebrew/Cellar/go/1.27.1/libexec/src/runtime/debug/stack.go:26 +0x64 +runtime/debug.PrintStack() + /opt/homebrew/Cellar/go/1.27.1/libexec/src/runtime/debug/stack.go:18 +0x1c +github.com/minio/minio/internal/ioutil.SafeClose[...](...) + /Users/vonng/.codex/worktrees/aa3f/silo/internal/ioutil/ioutil.go:470 +github.com/minio/minio/internal/logger/target/http.(*Target).Cancel(0x132a6cb8d520) + /Users/vonng/.codex/worktrees/aa3f/silo/internal/logger/target/http/http.go:630 +0x74 +created by github.com/minio/minio/internal/logger.cancelTargets in goroutine 6639 + /Users/vonng/.codex/worktrees/aa3f/silo/internal/logger/targets.go:213 +0x28 + replication-delete-mrf_test.go:485: Erasure: canonical recovered; 1 target(s), persisted MRF, source/target metadata checked +goroutine 10069 [running]: +runtime/debug.Stack() + /opt/homebrew/Cellar/go/1.27.1/libexec/src/runtime/debug/stack.go:26 +0x64 +runtime/debug.PrintStack() + /opt/homebrew/Cellar/go/1.27.1/libexec/src/runtime/debug/stack.go:18 +0x1c +github.com/minio/minio/internal/ioutil.SafeClose[...](...) + /Users/vonng/.codex/worktrees/aa3f/silo/internal/ioutil/ioutil.go:470 +github.com/minio/minio/internal/logger/target/http.(*Target).Cancel(0x132a6bf75a00) + /Users/vonng/.codex/worktrees/aa3f/silo/internal/logger/target/http/http.go:630 +0x74 +created by github.com/minio/minio/internal/logger.cancelTargets in goroutine 6639 + /Users/vonng/.codex/worktrees/aa3f/silo/internal/logger/targets.go:213 +0x28 +=== RUN TestReplicationMRFMarkerRecovery/lock-failure + replication-delete-mrf_test.go:485: ErasureSD: lock-failure recovered; 1 target(s), persisted MRF, source/target metadata checked + replication-delete-mrf_test.go:485: Erasure: lock-failure recovered; 1 target(s), persisted MRF, source/target metadata checked +=== RUN TestReplicationMRFMarkerRecovery/invalid-MRF-metadata +=== RUN TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/empty-info +=== RUN TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/not-a-marker +=== RUN TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/wrong-version +=== RUN TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/wrong-bucket +=== RUN TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/wrong-object +=== RUN TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/zero-modtime +=== RUN TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/missing +=== RUN TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/read-error +=== RUN TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/empty-info#01 +=== RUN TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/not-a-marker#01 +=== RUN TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/wrong-version#01 +=== RUN TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/wrong-bucket#01 +=== RUN TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/wrong-object#01 +=== RUN TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/zero-modtime#01 +=== RUN TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/missing#01 +=== RUN TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/read-error#01 +=== RUN TestReplicationMRFMarkerRecovery/legacy + replication-delete-mrf_test.go:485: ErasureSD: legacy recovered; 1 target(s), persisted MRF, source/target metadata checked + replication-delete-mrf_test.go:485: Erasure: legacy recovered; 1 target(s), persisted MRF, source/target metadata checked +=== RUN TestReplicationMRFMarkerRecovery/unrecorded-purge + replication-delete-mrf_test.go:485: ErasureSD: unrecorded-purge recovered; 2 target(s), persisted MRF, source/target metadata checked + replication-delete-mrf_test.go:485: Erasure: unrecorded-purge recovered; 2 target(s), persisted MRF, source/target metadata checked +=== RUN TestReplicationMRFMarkerRecovery/two-targets + replication-delete-mrf_test.go:485: ErasureSD: two-targets recovered; 2 target(s), persisted MRF, source/target metadata checked + replication-delete-mrf_test.go:485: Erasure: two-targets recovered; 2 target(s), persisted MRF, source/target metadata checked +=== RUN TestReplicationMRFMarkerRecovery/two-targets-offline + replication-delete-mrf_test.go:485: ErasureSD: two-targets-offline recovered; 2 target(s), persisted MRF, source/target metadata checked + replication-delete-mrf_test.go:485: Erasure: two-targets-offline recovered; 2 target(s), persisted MRF, source/target metadata checked +=== RUN TestReplicationMRFMarkerRecovery/replica-source + replication-delete-mrf_test.go:485: ErasureSD: replica-source recovered; 1 target(s), persisted MRF, source/target metadata checked + replication-delete-mrf_test.go:485: Erasure: replica-source recovered; 1 target(s), persisted MRF, source/target metadata checked +=== RUN TestReplicationMRFMarkerRecovery/lost-reply-canonical + replication-delete-mrf_test.go:485: ErasureSD: lost-reply-canonical recovered; 1 target(s), persisted MRF, source/target metadata checked + replication-delete-mrf_test.go:485: Erasure: lost-reply-canonical recovered; 1 target(s), persisted MRF, source/target metadata checked +=== RUN TestReplicationMRFMarkerRecovery/lost-reply-legacy + replication-delete-mrf_test.go:485: ErasureSD: lost-reply-legacy recovered; 1 target(s), persisted MRF, source/target metadata checked + replication-delete-mrf_test.go:485: Erasure: lost-reply-legacy recovered; 1 target(s), persisted MRF, source/target metadata checked +=== RUN TestReplicationMRFMarkerRecovery/creation + replication-delete-mrf_test.go:485: ErasureSD: creation recovered; 1 target(s), persisted MRF, source/target metadata checked + replication-delete-mrf_test.go:485: Erasure: creation recovered; 1 target(s), persisted MRF, source/target metadata checked +=== RUN TestReplicationMRFMarkerRecovery/partial-creation-block +=== RUN TestReplicationMRFMarkerRecovery/retry-budget-and-scanner + replication-delete-mrf_test.go:485: ErasureSD: retry-budget-and-scanner recovered; 1 target(s), persisted MRF, source/target metadata checked + replication-delete-mrf_test.go:485: Erasure: retry-budget-and-scanner recovered; 1 target(s), persisted MRF, source/target metadata checked +--- PASS: TestReplicationMRFMarkerRecovery (8.35s) + --- PASS: TestReplicationMRFMarkerRecovery/canonical (0.36s) + --- PASS: TestReplicationMRFMarkerRecovery/lock-failure (0.38s) + --- PASS: TestReplicationMRFMarkerRecovery/invalid-MRF-metadata (1.89s) + --- PASS: TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/empty-info (0.10s) + --- PASS: TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/not-a-marker (0.10s) + --- PASS: TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/wrong-version (0.10s) + --- PASS: TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/wrong-bucket (0.10s) + --- PASS: TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/wrong-object (0.10s) + --- PASS: TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/zero-modtime (0.10s) + --- PASS: TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/missing (0.10s) + --- PASS: TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/read-error (0.10s) + --- PASS: TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/empty-info#01 (0.10s) + --- PASS: TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/not-a-marker#01 (0.10s) + --- PASS: TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/wrong-version#01 (0.10s) + --- PASS: TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/wrong-bucket#01 (0.10s) + --- PASS: TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/wrong-object#01 (0.10s) + --- PASS: TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/zero-modtime#01 (0.10s) + --- PASS: TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/missing#01 (0.11s) + --- PASS: TestReplicationMRFMarkerRecovery/invalid-MRF-metadata/read-error#01 (0.10s) + --- PASS: TestReplicationMRFMarkerRecovery/legacy (0.31s) + --- PASS: TestReplicationMRFMarkerRecovery/unrecorded-purge (0.50s) + --- PASS: TestReplicationMRFMarkerRecovery/two-targets (0.64s) + --- PASS: TestReplicationMRFMarkerRecovery/two-targets-offline (0.48s) + --- PASS: TestReplicationMRFMarkerRecovery/replica-source (0.48s) + --- PASS: TestReplicationMRFMarkerRecovery/lost-reply-canonical (0.99s) + --- PASS: TestReplicationMRFMarkerRecovery/lost-reply-legacy (0.72s) + --- PASS: TestReplicationMRFMarkerRecovery/creation (0.48s) + --- PASS: TestReplicationMRFMarkerRecovery/partial-creation-block (0.56s) + --- PASS: TestReplicationMRFMarkerRecovery/retry-budget-and-scanner (0.55s) +=== RUN TestReplicationDeleteQueueFullRetryBudget +--- PASS: TestReplicationDeleteQueueFullRetryBudget (0.00s) +=== RUN TestReplicateDeleteOperationExits +=== RUN TestReplicateDeleteOperationExits/marker-creation/pending-success +=== RUN TestReplicateDeleteOperationExits/marker-creation/completed-creation +=== RUN TestReplicateDeleteOperationExits/marker-creation/existing-marker +=== RUN TestReplicateDeleteOperationExits/marker-creation/head-read-quorum +=== RUN TestReplicateDeleteOperationExits/marker-creation/replica-creation-status +=== RUN TestReplicateDeleteOperationExits/marker-creation/head-unavailable +=== RUN TestReplicateDeleteOperationExits/marker-creation/head-forbidden +=== RUN TestReplicateDeleteOperationExits/marker-creation/delete-forbidden +=== RUN TestReplicateDeleteOperationExits/marker-creation/delete-method-rejected +=== RUN TestReplicateDeleteOperationExits/marker-creation/delete-unavailable +=== RUN TestReplicateDeleteOperationExits/marker-creation/offline +=== RUN TestReplicateDeleteOperationExits/marker-creation/retry-failed +=== RUN TestReplicateDeleteOperationExits/marker-creation/purge-complete +=== RUN TestReplicateDeleteOperationExits/marker-creation/resync-success +=== RUN TestReplicateDeleteOperationExits/marker-creation/resync-already-purged +=== RUN TestReplicateDeleteOperationExits/marker-creation/resync-failure +=== RUN TestReplicateDeleteOperationExits/legacy-marker-purge/pending-success +=== RUN TestReplicateDeleteOperationExits/legacy-marker-purge/completed-creation +=== RUN TestReplicateDeleteOperationExits/legacy-marker-purge/existing-marker +=== RUN TestReplicateDeleteOperationExits/legacy-marker-purge/head-read-quorum +=== RUN TestReplicateDeleteOperationExits/legacy-marker-purge/replica-creation-status +=== RUN TestReplicateDeleteOperationExits/legacy-marker-purge/head-unavailable +=== RUN TestReplicateDeleteOperationExits/legacy-marker-purge/head-forbidden +=== RUN TestReplicateDeleteOperationExits/legacy-marker-purge/delete-forbidden +=== RUN TestReplicateDeleteOperationExits/legacy-marker-purge/delete-method-rejected +=== RUN TestReplicateDeleteOperationExits/legacy-marker-purge/delete-unavailable +=== RUN TestReplicateDeleteOperationExits/legacy-marker-purge/offline +=== RUN TestReplicateDeleteOperationExits/legacy-marker-purge/retry-failed +=== RUN TestReplicateDeleteOperationExits/legacy-marker-purge/purge-complete +=== RUN TestReplicateDeleteOperationExits/legacy-marker-purge/resync-success +=== RUN TestReplicateDeleteOperationExits/legacy-marker-purge/resync-already-purged +=== RUN TestReplicateDeleteOperationExits/legacy-marker-purge/resync-failure +=== RUN TestReplicateDeleteOperationExits/marker-purge/pending-success +=== RUN TestReplicateDeleteOperationExits/marker-purge/completed-creation +=== RUN TestReplicateDeleteOperationExits/marker-purge/existing-marker +=== RUN TestReplicateDeleteOperationExits/marker-purge/head-read-quorum +=== RUN TestReplicateDeleteOperationExits/marker-purge/replica-creation-status +=== RUN TestReplicateDeleteOperationExits/marker-purge/head-unavailable +=== RUN TestReplicateDeleteOperationExits/marker-purge/head-forbidden +=== RUN TestReplicateDeleteOperationExits/marker-purge/delete-forbidden +=== RUN TestReplicateDeleteOperationExits/marker-purge/delete-method-rejected +=== RUN TestReplicateDeleteOperationExits/marker-purge/delete-unavailable +=== RUN TestReplicateDeleteOperationExits/marker-purge/offline +=== RUN TestReplicateDeleteOperationExits/marker-purge/retry-failed +=== RUN TestReplicateDeleteOperationExits/marker-purge/purge-complete +=== RUN TestReplicateDeleteOperationExits/marker-purge/resync-success +=== RUN TestReplicateDeleteOperationExits/marker-purge/resync-already-purged +=== RUN TestReplicateDeleteOperationExits/marker-purge/resync-failure +=== RUN TestReplicateDeleteOperationExits/object-purge/pending-success +=== RUN TestReplicateDeleteOperationExits/object-purge/completed-creation +=== RUN TestReplicateDeleteOperationExits/object-purge/existing-marker +=== RUN TestReplicateDeleteOperationExits/object-purge/head-read-quorum +=== RUN TestReplicateDeleteOperationExits/object-purge/replica-creation-status +=== RUN TestReplicateDeleteOperationExits/object-purge/head-unavailable +=== RUN TestReplicateDeleteOperationExits/object-purge/head-forbidden +=== RUN TestReplicateDeleteOperationExits/object-purge/delete-forbidden +=== RUN TestReplicateDeleteOperationExits/object-purge/delete-method-rejected +=== RUN TestReplicateDeleteOperationExits/object-purge/delete-unavailable +=== RUN TestReplicateDeleteOperationExits/object-purge/offline +=== RUN TestReplicateDeleteOperationExits/object-purge/retry-failed +=== RUN TestReplicateDeleteOperationExits/object-purge/purge-complete +=== RUN TestReplicateDeleteOperationExits/object-purge/resync-success +=== RUN TestReplicateDeleteOperationExits/object-purge/resync-already-purged +=== RUN TestReplicateDeleteOperationExits/object-purge/resync-failure +--- PASS: TestReplicateDeleteOperationExits (0.70s) + --- PASS: TestReplicateDeleteOperationExits/marker-creation/pending-success (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-creation/completed-creation (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-creation/existing-marker (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-creation/head-read-quorum (0.09s) + --- PASS: TestReplicateDeleteOperationExits/marker-creation/replica-creation-status (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-creation/head-unavailable (0.19s) + --- PASS: TestReplicateDeleteOperationExits/marker-creation/head-forbidden (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-creation/delete-forbidden (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-creation/delete-method-rejected (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-creation/delete-unavailable (0.15s) + --- PASS: TestReplicateDeleteOperationExits/marker-creation/offline (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-creation/retry-failed (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-creation/purge-complete (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-creation/resync-success (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-creation/resync-already-purged (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-creation/resync-failure (0.00s) + --- PASS: TestReplicateDeleteOperationExits/legacy-marker-purge/pending-success (0.00s) + --- PASS: TestReplicateDeleteOperationExits/legacy-marker-purge/completed-creation (0.00s) + --- PASS: TestReplicateDeleteOperationExits/legacy-marker-purge/existing-marker (0.00s) + --- PASS: TestReplicateDeleteOperationExits/legacy-marker-purge/head-read-quorum (0.00s) + --- PASS: TestReplicateDeleteOperationExits/legacy-marker-purge/replica-creation-status (0.00s) + --- PASS: TestReplicateDeleteOperationExits/legacy-marker-purge/head-unavailable (0.00s) + --- PASS: TestReplicateDeleteOperationExits/legacy-marker-purge/head-forbidden (0.00s) + --- PASS: TestReplicateDeleteOperationExits/legacy-marker-purge/delete-forbidden (0.00s) + --- PASS: TestReplicateDeleteOperationExits/legacy-marker-purge/delete-method-rejected (0.00s) + --- PASS: TestReplicateDeleteOperationExits/legacy-marker-purge/delete-unavailable (0.07s) + --- PASS: TestReplicateDeleteOperationExits/legacy-marker-purge/offline (0.00s) + --- PASS: TestReplicateDeleteOperationExits/legacy-marker-purge/retry-failed (0.00s) + --- PASS: TestReplicateDeleteOperationExits/legacy-marker-purge/purge-complete (0.00s) + --- PASS: TestReplicateDeleteOperationExits/legacy-marker-purge/resync-success (0.00s) + --- PASS: TestReplicateDeleteOperationExits/legacy-marker-purge/resync-already-purged (0.00s) + --- PASS: TestReplicateDeleteOperationExits/legacy-marker-purge/resync-failure (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-purge/pending-success (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-purge/completed-creation (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-purge/existing-marker (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-purge/head-read-quorum (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-purge/replica-creation-status (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-purge/head-unavailable (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-purge/head-forbidden (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-purge/delete-forbidden (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-purge/delete-method-rejected (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-purge/delete-unavailable (0.04s) + --- PASS: TestReplicateDeleteOperationExits/marker-purge/offline (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-purge/retry-failed (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-purge/purge-complete (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-purge/resync-success (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-purge/resync-already-purged (0.00s) + --- PASS: TestReplicateDeleteOperationExits/marker-purge/resync-failure (0.00s) + --- PASS: TestReplicateDeleteOperationExits/object-purge/pending-success (0.00s) + --- PASS: TestReplicateDeleteOperationExits/object-purge/completed-creation (0.00s) + --- PASS: TestReplicateDeleteOperationExits/object-purge/existing-marker (0.00s) + --- PASS: TestReplicateDeleteOperationExits/object-purge/head-read-quorum (0.00s) + --- PASS: TestReplicateDeleteOperationExits/object-purge/replica-creation-status (0.00s) + --- PASS: TestReplicateDeleteOperationExits/object-purge/head-unavailable (0.00s) + --- PASS: TestReplicateDeleteOperationExits/object-purge/head-forbidden (0.00s) + --- PASS: TestReplicateDeleteOperationExits/object-purge/delete-forbidden (0.00s) + --- PASS: TestReplicateDeleteOperationExits/object-purge/delete-method-rejected (0.00s) + --- PASS: TestReplicateDeleteOperationExits/object-purge/delete-unavailable (0.11s) + --- PASS: TestReplicateDeleteOperationExits/object-purge/offline (0.00s) + --- PASS: TestReplicateDeleteOperationExits/object-purge/retry-failed (0.00s) + --- PASS: TestReplicateDeleteOperationExits/object-purge/purge-complete (0.00s) + --- PASS: TestReplicateDeleteOperationExits/object-purge/resync-success (0.00s) + --- PASS: TestReplicateDeleteOperationExits/object-purge/resync-already-purged (0.00s) + --- PASS: TestReplicateDeleteOperationExits/object-purge/resync-failure (0.00s) +=== RUN TestReplicateDeletePurgeMissingTargetState +--- PASS: TestReplicateDeletePurgeMissingTargetState (0.00s) +=== RUN TestReplicationMRFDropsVisible +=== RUN TestReplicationMRFDropsVisible/bucket +=== RUN TestReplicationMRFDropsVisible/node +--- PASS: TestReplicationMRFDropsVisible (0.00s) + --- PASS: TestReplicationMRFDropsVisible/bucket (0.00s) + --- PASS: TestReplicationMRFDropsVisible/node (0.00s) +=== RUN TestReplicationObjectDeleteWorkerAffinity +--- PASS: TestReplicationObjectDeleteWorkerAffinity (0.00s) +=== RUN TestSiteResyncCancelState +--- PASS: TestSiteResyncCancelState (0.00s) +=== RUN TestResyncRecoveryOwnsLeaderContext +=== RUN TestResyncRecoveryOwnsLeaderContext/lose_leader_false +=== RUN TestResyncRecoveryOwnsLeaderContext/lose_leader_true +--- PASS: TestResyncRecoveryOwnsLeaderContext (0.00s) + --- PASS: TestResyncRecoveryOwnsLeaderContext/lose_leader_false (0.00s) + --- PASS: TestResyncRecoveryOwnsLeaderContext/lose_leader_true (0.00s) +=== RUN TestResyncCancelRouting +--- PASS: TestResyncCancelRouting (0.00s) +=== RUN TestResyncCancellationWinsFinalization +--- PASS: TestResyncCancellationWinsFinalization (0.00s) +=== RUN TestResyncCancelFullWorkerQueue +--- PASS: TestResyncCancelFullWorkerQueue (0.00s) +=== RUN TestResyncCancelBlockedWalkReceive +--- PASS: TestResyncCancelBlockedWalkReceive (0.00s) +=== RUN TestResyncCancelsOwnedWalkOnError +--- PASS: TestResyncCancelsOwnedWalkOnError (0.00s) +=== RUN TestReplicationTrustControlsInternalOptionsAndEvents +--- PASS: TestReplicationTrustControlsInternalOptionsAndEvents (0.00s) +PASS +ok github.com/minio/minio/cmd 12.620s +=== RUN TestReplicate +=== RUN TestReplicate/#00 +=== RUN TestReplicate/c1test +=== RUN TestReplicate/c1test#01 +=== RUN TestReplicate/c1test#02 +=== RUN TestReplicate/c1test#03 +=== RUN TestReplicate/c1test#04 +=== RUN TestReplicate/c1test#05 +=== RUN TestReplicate/c1test#06 +=== RUN TestReplicate/c1test#07 +=== RUN TestReplicate/c2test +=== RUN TestReplicate/c2test#01 +=== RUN TestReplicate/c2test#02 +=== RUN TestReplicate/c2test#03 +=== RUN TestReplicate/c2test#04 +=== RUN TestReplicate/c2test#05 +=== RUN TestReplicate/c2test#06 +=== RUN TestReplicate/xy/c3test +=== RUN TestReplicate/xyz/c3test +=== RUN TestReplicate/xyz/c3test#01 +=== RUN TestReplicate/xyz/c3test#02 +=== RUN TestReplicate/xyz/c3test#03 +=== RUN TestReplicate/xyz/c3test#04 +=== RUN TestReplicate/xy/c3test#01 +=== RUN TestReplicate/xyz/c3test#05 +=== RUN TestReplicate/xyz/c3test#06 +=== RUN TestReplicate/xyz/c3test#07 +=== RUN TestReplicate/abc/c3test +=== RUN TestReplicate/xy/c4test +=== RUN TestReplicate/xa/c4test +=== RUN TestReplicate/xyz/c4test +=== RUN TestReplicate/xyz/c4test#01 +=== RUN TestReplicate/xyz/c4test#02 +=== RUN TestReplicate/xyz/c4test#03 +=== RUN TestReplicate/abc/c4test +=== RUN TestReplicate/abc/c4test#01 +=== RUN TestReplicate/abc/c4test#02 +=== RUN TestReplicate/abc/c4test#03 +=== RUN TestReplicate/abc/c4test#04 +=== RUN TestReplicate/xy/c5test +=== RUN TestReplicate/xa/c5test +--- PASS: TestReplicate (0.00s) + --- PASS: TestReplicate/#00 (0.00s) + --- PASS: TestReplicate/c1test (0.00s) + --- PASS: TestReplicate/c1test#01 (0.00s) + --- PASS: TestReplicate/c1test#02 (0.00s) + --- PASS: TestReplicate/c1test#03 (0.00s) + --- PASS: TestReplicate/c1test#04 (0.00s) + --- PASS: TestReplicate/c1test#05 (0.00s) + --- PASS: TestReplicate/c1test#06 (0.00s) + --- PASS: TestReplicate/c1test#07 (0.00s) + --- PASS: TestReplicate/c2test (0.00s) + --- PASS: TestReplicate/c2test#01 (0.00s) + --- PASS: TestReplicate/c2test#02 (0.00s) + --- PASS: TestReplicate/c2test#03 (0.00s) + --- PASS: TestReplicate/c2test#04 (0.00s) + --- PASS: TestReplicate/c2test#05 (0.00s) + --- PASS: TestReplicate/c2test#06 (0.00s) + --- PASS: TestReplicate/xy/c3test (0.00s) + --- PASS: TestReplicate/xyz/c3test (0.00s) + --- PASS: TestReplicate/xyz/c3test#01 (0.00s) + --- PASS: TestReplicate/xyz/c3test#02 (0.00s) + --- PASS: TestReplicate/xyz/c3test#03 (0.00s) + --- PASS: TestReplicate/xyz/c3test#04 (0.00s) + --- PASS: TestReplicate/xy/c3test#01 (0.00s) + --- PASS: TestReplicate/xyz/c3test#05 (0.00s) + --- PASS: TestReplicate/xyz/c3test#06 (0.00s) + --- PASS: TestReplicate/xyz/c3test#07 (0.00s) + --- PASS: TestReplicate/abc/c3test (0.00s) + --- PASS: TestReplicate/xy/c4test (0.00s) + --- PASS: TestReplicate/xa/c4test (0.00s) + --- PASS: TestReplicate/xyz/c4test (0.00s) + --- PASS: TestReplicate/xyz/c4test#01 (0.00s) + --- PASS: TestReplicate/xyz/c4test#02 (0.00s) + --- PASS: TestReplicate/xyz/c4test#03 (0.00s) + --- PASS: TestReplicate/abc/c4test (0.00s) + --- PASS: TestReplicate/abc/c4test#01 (0.00s) + --- PASS: TestReplicate/abc/c4test#02 (0.00s) + --- PASS: TestReplicate/abc/c4test#03 (0.00s) + --- PASS: TestReplicate/abc/c4test#04 (0.00s) + --- PASS: TestReplicate/xy/c5test (0.00s) + --- PASS: TestReplicate/xa/c5test (0.00s) +PASS +ok github.com/minio/minio/internal/bucket/replication 0.497s diff --git a/docs/investigations/r6/verification/rebased-verification.json b/docs/investigations/r6/verification/rebased-verification.json new file mode 100644 index 000000000..b96ab40d0 --- /dev/null +++ b/docs/investigations/r6/verification/rebased-verification.json @@ -0,0 +1,101 @@ +{ + "baseline": "cf381a7151ef25fc95ace5fedcd767fa19410de2", + "source_sha256": { + "cmd/bucket-replication.go": "999c2818a8980cbeb55cfcbc244840069fb44e042b4b4d6ef7668c2660ce31e0", + "cmd/bucket-replication-utils.go": "365641c760901641e8320cc5123697ab92a46f1add612048b991ed2d1cfad43b", + "cmd/replication-delete-marker_test.go": "d967787804d558ac6266b113228fdf4a4f9fcb7cab39138a4fb07558814ccca4", + "cmd/replication-delete-operation_test.go": "2888a04a543324776041316de2821f388d28c3c1a6f5d0031e5ac9d34f58d504", + "cmd/replication-delete-mrf_test.go": "5e160f7e19cbbb8cd5fa4e7ffd9cff9e09361b3fc4c5ee5ae61458f99c777781" + }, + "checks": [ + { + "name": "scope", + "command": [ + "go", + "test", + "-p", + "2", + "./cmd", + "./internal/bucket/replication", + "-run", + "TestReplication|TestReplicate|TestMRF|TestResync|TestSiteResync", + "-count=1", + "-v" + ], + "exit_code": 0, + "seconds": 38.805, + "log": "/Users/vonng/tmp/silo-r6-20260915-aa3f/rebased-scope.log", + "log_sha256": "5a038acd48f99ccac00646e490a51270a2e426d8b8b3c7841330d739d773f06d", + "finished_at_utc": "2026-09-15T16:26:02.042157+00:00" + }, + { + "name": "race", + "command": [ + "go", + "test", + "-race", + "-p", + "2", + "./cmd", + "-run", + "TestReplicateDelete|TestReplicationMRF|TestReplicationDeleteQueueFull", + "-count=1", + "-v" + ], + "exit_code": 0, + "seconds": 67.636, + "log": "/Users/vonng/tmp/silo-r6-20260915-aa3f/rebased-race.log", + "log_sha256": "1d4016ab2e69d795c9cab377aed980127a823b38d9a530101f8eb5fc470d920b", + "finished_at_utc": "2026-09-15T16:27:09.680875+00:00" + }, + { + "name": "build", + "command": [ + "go", + "build", + "-p", + "2", + "./..." + ], + "exit_code": 0, + "seconds": 25.522, + "log": "/Users/vonng/tmp/silo-r6-20260915-aa3f/rebased-build.log", + "log_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "finished_at_utc": "2026-09-15T16:27:35.203431+00:00" + }, + { + "name": "vet", + "command": [ + "go", + "vet", + "-p", + "2", + "./cmd", + "./internal/bucket/replication" + ], + "exit_code": 0, + "seconds": 3.399, + "log": "/Users/vonng/tmp/silo-r6-20260915-aa3f/rebased-vet.log", + "log_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "finished_at_utc": "2026-09-15T16:27:38.603261+00:00" + }, + { + "name": "lint", + "command": [ + "/Users/vonng/pgsty/silo/.bin/golangci/v2.13.1/golangci-lint", + "run", + "--build-tags", + "kqueue", + "--timeout=10m", + "--config", + "./.golangci.yml" + ], + "exit_code": 0, + "seconds": 114.749, + "log": "/Users/vonng/tmp/silo-r6-20260915-aa3f/rebased-lint.log", + "log_sha256": "e92606b0bf483111dff0a120c315ea165821348f31365020e2468a0059095c47", + "finished_at_utc": "2026-09-15T16:29:33.354263+00:00" + } + ], + "source_unchanged": true +} diff --git a/docs/investigations/r6/verification/rebased-vet.log b/docs/investigations/r6/verification/rebased-vet.log new file mode 100644 index 000000000..e69de29bb diff --git a/docs/investigations/r8/.gitattributes b/docs/investigations/r8/.gitattributes new file mode 100644 index 000000000..d4a2e2072 --- /dev/null +++ b/docs/investigations/r8/.gitattributes @@ -0,0 +1,3 @@ +# Keep captured output and unified-diff context verbatim. +*.log -whitespace +*.patch -whitespace diff --git a/docs/investigations/r8/README.md b/docs/investigations/r8/README.md new file mode 100644 index 000000000..3907155e3 --- /dev/null +++ b/docs/investigations/r8/README.md @@ -0,0 +1,100 @@ +# R8:HTTP 请求头绝对超时 + +## 当前状态 + +两条缺陷链均已修复。完整 v2 已与真实 Opus 5.0(max)明确达成同版共识,零阻断分歧;配置与连接两条直接验证链均已闭合。额外 cmd race 曾因主机 ENOSPC 在链接阶段中止,补充旧 S3 脚本受存储最小空闲阈值阻挡;父任务已确认二者为环境未完成的补充检查,不阻断本地交付。交付保存在本地分支提交中,未推送、合并、发布或部署。 + +- 基线:`9ebe81c1b3611f9cc73e676b5b741c2be62c467a`(开工时本地及实时 origin/main 一致)。 +- 分支:`codex/r8-http-header-deadline`。 +- 方案:[v1](plan-v1.md)、[完整 v2](plan-v2.md)。 +- 最终源码绑定:[final-source-manifest.json](final-source-manifest.json),包含 5 个生产文件、4 个测试文件、计划与生产 diff 的 SHA256;[最终生产补丁](review/final-production.patch)。go.mod/go.sum 与基线一致。 +- 共识与意见处置:[consensus.md](consensus.md)。实际模型、显式 effort、计划/原始输出哈希保存在 [review](review/)。 + +## 两条缺陷链 + +### 1. 连接层覆盖绝对截止 + +`DeadlineConn.Read` 在读之前把 socket 截止更新为“现在 + idle + 250ms”,覆盖 Go 设置的绝对读头截止。直接 TCP 对照中,头部限制 100ms、idle=2s、400ms 才完成请求头,标准 Go 拒绝而旧 SILO listener 返回 204。 + +修复让 HTTP/1 请求头、keep-alive 等待和 TLS 握手读取阶段保留显式绝对上限;读头结束进入 `StateActive` 后恢复原有滚动读取,避免把 `ReadTimeout=IdleTimeout` 变成上传总时长上限。显式零值仍关闭超时,过去时间仍取消读取。写侧与默认 DeadlineConn 调用者保持原行为。现有 ConnState 回调得到保留。 + +### 2. CLI/环境配置没有传入服务器 + +`buildServerCtxt` 复制了 IdleTimeout,遗漏 ReadHeaderTimeout。真实 CLI 已读到默认 30s / 参数 100ms / 环境变量 170ms,context 仍为零。因此,即使连接层已经修复,实际 SILO 进程仍回退到 ReadTimeout=idle。 + +v2 已在相邻位置补一行赋值,未增加新选项或 YAML 字段。相同进程探针显示:v1 参数和环境变量各设置 100ms 时,400ms 慢头均返回 200;v2 两种入口均拒绝该请求,随后的健康检查仍返回 200。 + +## 已完成的连接层验证 + +| 验证 | 直接证据 | +|---|---| +| 原始 TCP/HTTP 100ms/400ms 对照,修复前失败、修复后两者均拒绝 | [baseline.log](evidence/baseline.log)、[original-reproducer-fixed.log](evidence/original-reproducer-fixed.log) | +| 头部持续滴入超过多个 250ms 更新周期、首个/后续请求,明文及 TLS | [darwin-race-final.log](evidence/darwin-race-final.log) | +| Content-Length/chunked/100-continue 持续上传、空闲 body、提前关闭及下一请求、读完 body 后长期处理 | [darwin-race-final.log](evidence/darwin-race-final.log) | +| keep-alive、pipelined 缓冲请求、用户 ConnState、hijack/unwrap、TLS 握手读取 | [darwin-race-final.log](evidence/darwin-race-final.log) | +| 强制仅协商 h2,断言 HTTP/2.0;原生流超时、并发健康流及连接复用 | [darwin-race-final.log](evidence/darwin-race-final.log) | +| macOS/arm64 Go1.27.1:完整两个修改包的 race | [darwin-race-final.log](evidence/darwin-race-final.log) | +| Linux/arm64 Docker Go1.27.1:完整两个修改包的 race,包含可选 DriveOPTimeout 拨号路径 | [linux-race-final.log](evidence/linux-race-final.log) | +| 默认 idle=30s,明文/TLS 的持续上传、下载均用时约 33s 并完成 | [default-30s-transfers.log](evidence/default-30s-transfers.log) | +| grid 实际 roundtrip/disconnect,go vet | [grid.log](evidence/grid.log)、[vet-final.log](evidence/vet-final.log) | + +Linux 使用已有本地 `golang:1.27.1-bookworm` arm64 镜像 `sha256:648f440f42a0958804efb24df176f806f9d353b41f1c0627f666428e40310f6b`。临时容器仅只读挂载本工作区与 Go module cache;没有发布端口,结束即删除。 + +## 配置与真实进程验证 + +- [config-baseline.log](evidence/config-baseline.log):实际 CLI → buildServerCtxt;默认、参数、环境、参数优先级、YAML 合并、零、负值。 +- [config-fixed.log](evidence/config-fixed.log):10 个常驻配置回归全部通过,既有 YAML 配置测试也通过。包含 idle=0/负值时默认 header=30s、fmt-gen 未注册该选项时仍安全返回零。 +- [runtime-v1.json](evidence/runtime-v1.json):v1 真实编译进程在参数/环境两种配置下均错误接受 400ms 慢头;包含二进制 SHA256。 +- [runtime-v2.json](evidence/runtime-v2.json):修复后同一探针,两种入口均拒绝慢头且服务健康。最终测试二进制 SHA256:`6b982de3262c25e326280c739b4275444cf80c3eacae48f0942aa18fbb7cd654`。 +- [quality-checks.json](evidence/quality-checks.json):执行命令、退出码、平台与日志哈希。`go mod tidy -diff`、`go vet`、scoped golangci-lint 与 diff 空白检查均通过,go.mod/go.sum 未改变。 +- [runtime_probe.py](evidence/runtime_probe.py):可复现的进程探针。只启动回环地址上的临时单盘服务器,使用临时测试凭据和数据,结束时终止自己的子进程。 + +## 补充验收的环境限制 + +- 额外 `go test -race ./cmd -run TestServerReadHeaderTimeoutConfig` 未进入测试:Darwin 链接器报 `errno=28 (No space left on device)`。这与已经通过的两个网络包 macOS/Linux race、普通 cmd 配置测试不同,不能混为通过。 +- 为检验已有 `buildscripts/test-timeout.sh`,制作了临时隔离副本:将全局 pkill 换为只终止自己的 PID、监听地址限于回环、测试末尾清理;使用单独 mcli 配置目录与 BSD nc 的私有 netcat 名称,原三组请求/断言未改。脚本在正常 PUT 阶段未形成对象,整体退出 255,**未通过**。 +- 后续立即上传 30 字节的独立诊断明确返回 **HTTP 507 / XMinioStorageFull**,消息为已达到最小空闲空间阈值:[s3-capacity-probe.json](evidence/s3-capacity-probe.json)。这条补充脚本不能在当前宿主容量条件下用于证明 S3 持久化验收,也不是 deadline 回归证据。 +- 原脚本与隔离改动、命令、客户端版本、运行时长和退出码见 [isolation patch](evidence/legacy-timeout-isolation.patch)、[metadata](evidence/legacy-timeout.metadata.json)、[log](evidence/legacy-timeout.log)。v2 编译进程的健康端点慢头验证已独立通过。 +- 按父任务确认,无需为这两项非阻断补充检查继续大编译或等待;已清理本任务已结束的 v1 旧二进制约 126MiB;保留原哈希、源码方案、日志与 v2 二进制。未清理共享缓存或其他任务数据:[cleanup.json](evidence/cleanup.json)。 + +## 配置语义与兼容边界 + +| 配置 | 完整修复后的含义 | +|---|---| +| 默认 idle=30s / header=30s | 有效时限仍为 30s,持续滴入头部现在也受绝对上限约束 | +| 只设 idle=2s | header 独立采用其默认 30s;相对只做 v1 的 2s 回退上限有所放宽,相对原先无限续期则建立了正确上限 | +| header 正值 | 按该值限制 HTTP/1 头部;该值还参与 Go 的最小正 TLS 握手时限,包括 h2 握手 | +| header=0 | 按 Go 规则回退到 ReadTimeout(此服务设为 idle) | +| header<0 | 显式取消 Go 的 header 绝对上限;首个明文请求仍保留原有 socket idle 续期,TLS/keep-alive 因 Go 显式清零而无 header 上限 | +| idle=0/负值,header 未设置 | 独立的默认 header=30s 现在正确生效 | + +## 交付说明 + +- 请求头和 TLS 握手的读取现在遵守绝对上限,即使连接持续有少量字节到达也会到期;这是修复后的预期可见变化。[Go Server 定义](https://pkg.go.dev/net/http#Server) +- HTTP/1 长上传、长下载保留滚动 idle;idle 仍有原有最多约 250ms 的更新时间余量,绝对 header 上限没有这项余量。 +- HTTP/2 保留现有原生 per-stream 超时行为;其原有总时长限制不属于本次修复。 +- TLS 握手**写**侧仍沿用滚动截止,这个既有边界单列保留,不能将本报告称为完整 TLS 握手资源限制修复。 +- 未改磁盘格式、对象元数据、存量状态、依赖或协议。回滚为撤销本次源代码改动并重新构建;本地证据不是生产部署验收。 +- 大范围仓库 CI、主干合并、版本发布、镜像和部署是后续独立交付步骤。本任务只做本地修复与针对性验证。 + +## 已纠正的测试夹具问题 + +初始 H2 客户端按服务端 ALPN 顺序落回 H1,协议断言正确使测试失败;改用仅 h2 的 TLS 拨号。之后 H2 同时启动同期限读写定时器,谁先到期会改变 body 错误包装;最终夹具在 PUT 内清除写定时器以独立验证读超时,并验证其他流与同一连接存活。无数据竞争报告。 + +配置夹具最初用了错误的变量名,随后发现“环境变量不存在”与“存在但为空”的 CLI 语义不同;修正夹具后才把非零 context 丢失作为缺陷证据。相关初始失败日志保留,不作为产品回归或通过证据。 + +新增 H2 测试曾直接引用 x/net/http2,Opus 实现评审因此提出 tidy 门禁阻断项。最终改用标准库 HTTP/2-only Protocols,不引入新直接依赖;v2 Opus 明确认定该阻断项已经解决,实际 tidy 检查也通过。重复 clamp 与 h2 宽裕 keep-alive 的改进在两平台最终 race 中通过。 + +## 最终交付状态 + +| 环节 | 状态 | +|---|---| +| 研究与最小兼容方案 | 完成;同时确认连接续期覆盖和配置传递遗漏 | +| 真实 Opus 5.0 max 共识 | 完成;完整 v2 同一 SHA256,零阻断分歧 | +| 实现评审链 | v1 REQUEST_CHANGES 的测试依赖 B1 已修复,v2 实际评审明确认定已解决 | +| 本地实现 | 完成,绑定此提交内源文件哈希 | +| 必要验证 | 配置、真实进程 CLI/env、TCP/TLS、长传输、keep-alive、h2、共享调用方及两平台网络包 race 完成 | +| 补充 cmd race / 旧 S3 脚本 | 环境未完成,分别为链接 ENOSPC 与 HTTP 507 最小空闲阈值 | +| 远端推送 / PR / 合并 / 发布 / 部署 | 未执行 | + +工作量估算仍为原计划的 1–3 工程师日级别;本轮实际完成方案、两次方案共识、一次实现复核和上述本地验证。未宣称全仓 CI 或生产验收通过。 diff --git a/docs/investigations/r8/consensus.md b/docs/investigations/r8/consensus.md new file mode 100644 index 000000000..5f78a8d20 --- /dev/null +++ b/docs/investigations/r8/consensus.md @@ -0,0 +1,51 @@ +# R8 consensus and dispositions + +Recorded UTC: 2026-09-15T15:50:55.911352+00:00 + +## Same-version agreement, before implementation + +- Baseline: `9ebe81c1b3611f9cc73e676b5b741c2be62c467a`. +- Accepted plan: `plan-v1.md`, SHA256 `7cb609e37e3199ecd93c992f08d123968ba8082682a108f20490e0644d735366`. The plan is retained verbatim. +- Codex recommends option 4. Real Claude Code assistant messages identify `claude-opus-5`; invocation explicitly supplied `--effort max`. +- Opus verdict: **CONSENSUS**, explicitly names v1 and this SHA256; zero blocking disagreements. Successful result, no rate limit or substitute reviewer. See `review/opus-v1.md` and metadata. Opus independently read code, did not run tests or recalculate the supplied hash. Codex independently recalculated the hash here and ran the baseline. +- Codex accepts the conclusion and the following dispositions. This record authorizes the local implementation stage under the user's WORKFLOW.md. No production files have been edited at the time this record is created. +- Raw session: `/Users/vonng/tmp/silo-r8-01a0a5b9/opus-v1.jsonl`; stderr adjacent. The reviewer tried an unavailable Write tool and then delivered its review in text; no file edit was granted or used as evidence. + +## Nonblocking opinions, individually resolved + +| ID | Codex disposition | +|---|---| +| N1 | Accept precise Go source ordering: header deadlines are set in `serve` at 2038/2177; whole-request deadline at 1103 is unconditional. v1 already describes header reading separately from the latter; no algorithm change needed. | +| N2 | Accept: add pipelined requests. StateActive follows successful parsing even when all bytes were buffered, because readRequest resets the read limit. | +| N3 | Retain explicit negotiated-h2 skip as v1 permits. StateNew still enables strict before the handshake; h2 detection only governs later hooks. No connection-level stream timeout added. | +| N4 | Retain listener initial strictness and Init's phase hook, as specified. Document that the private listener and Server.Init cooperate; direct raw-listener users without the hook get strict behavior for the entire request. There is no such production caller today. | +| N5 | Accept, independently reproduced: the initial H2 smoke fell back to HTTP/1.1 and failed its protocol assertion. Fix the fixture to dial TLS advertising only h2; assert negotiated h2 and response HTTP/2.0. This fixture failure is retained in baseline-additional.log and is not a product defect. | +| N6 | Accept: add >30s continuously progressing downloads through plaintext and TLS H1; retain write logic. Unit tests assert strict reads do not change rolling writes. | +| N7 | Accept: delivery notes will state that trickling headers and read-side TLS handshakes now stop at the configured absolute cap, even when socket idle is not exceeded. | +| N8 | Accept: current globalTCPOptions leaves DriveOPTimeout commented out; the optional Linux dial path remains a shared API caller and will be tested explicitly in Linux, without changing production config. | +| N9 | Accept as a known remaining boundary: TLS handshake writes retain rolling behavior. Fixing this requires separate write/handshake phase design and is outside the R8 read-header defect. | + +No substantive plan revision is required. Added tests are acceptance refinements consistent with v1, not changes to the agreed production behavior. + +## Complete v2 consensus — before configuration implementation + +Recorded UTC: 2026-09-15T16:07:48.799416+00:00 + +- Accepted **complete plan v2**, SHA256 `426127ed9fb08aeddf8259ebdc4b1c24ebec8cda751a970ed99338a44b065f4c`, independently recalculated here. V1 remains historical agreement only. +- Actual assistant model: `claude-opus-5`; CLI explicitly `--effort max`; successful response, **CONSENSUS**, zero blocking disagreements. Opus explicitly named the complete v2 hash and the new assignment. Codex agrees with the complete v2 plan and the dispositions below. Configuration production code is still unmodified at this record's creation. +- Opus explicitly confirms the implementation-review B1 dependency blocker is resolved by the standard-library-only HTTP/2 fixture. `go mod tidy -diff` independently returned exit 0. +- Reviewer source inspection is separate from execution; all actual tests and binary hashes are Codex-produced evidence. See `review/opus-v2.md` and metadata for the original opinion and raw log identity. + +| V2 note | Codex disposition | +|---|---| +| N1 | Accept: document that a positive ReadHeaderTimeout also participates in the standard-library minimum TLS handshake window, including h2's handshake. | +| N2 | Accept: with only idle=2s customized, v1's fallback cap was 2s; complete v2 honors default header=30s. The independent header knob is intentional. Socket inactivity is still constrained under the retained rules. | +| N3 | Accept: explicitly document negative header values and the inherited first-plaintext vs TLS/keep-alive zero-deadline distinction. This is an explicit opt-out of the header cap; no unagreed policy change. | +| N4 | Accept: add parser cases for disabled idle with independent default header=30s, and include disabled-idle coverage in process probing if practical. | +| N5 | Accept evaluation: inspect the existing buildscripts/test-timeout.sh for safe isolated execution. It complements, but does not replace, the new trickle and renewal cases. Record whether run and its exact scope. | +| N6 | Accept: land configuration assertions as a permanent cmd test; zero case remains a compatibility control, not defect-discriminating evidence. | +| N7 | Accept: fmt-gen's absent duration flag returns zero and does not launch HTTP; no extra flag or production change. | +| N8 | Accept: leave the duplicate existing UserTimeout assignment untouched. | +| N9 | Accept: final quality records include actual commands/exit codes; add repaired compiled-process output with binary SHA256. The old runtime-v1 output remains explicitly pre-binding evidence. | + +Implementation and verification now continue within v2. Merge, release and deployment remain outside automatic delivery. diff --git a/docs/investigations/r8/evidence/baseline-additional.log b/docs/investigations/r8/evidence/baseline-additional.log new file mode 100644 index 000000000..5586d820a --- /dev/null +++ b/docs/investigations/r8/evidence/baseline-additional.log @@ -0,0 +1,23 @@ +=== RUN TestServerConnStateHook +=== RUN TestServerConnStateHook/tls=false +=== RUN TestServerConnStateHook/tls=true +--- PASS: TestServerConnStateHook (0.00s) + --- PASS: TestServerConnStateHook/tls=false (0.00s) + --- PASS: TestServerConnStateHook/tls=true (0.00s) +=== RUN TestServerHTTP2Deadlines + r8_compatibility_test.go:278: expected HTTP/2, got HTTP/1.1 + r8_compatibility_test.go:293: unexpected HTTP/1.1 204 No Content +--- FAIL: TestServerHTTP2Deadlines (0.00s) +=== RUN TestServerEarlyBodyClose +=== RUN TestServerEarlyBodyClose/tls=false +=== PAUSE TestServerEarlyBodyClose/tls=false +=== RUN TestServerEarlyBodyClose/tls=true +=== PAUSE TestServerEarlyBodyClose/tls=true +=== CONT TestServerEarlyBodyClose/tls=false +=== CONT TestServerEarlyBodyClose/tls=true +--- PASS: TestServerEarlyBodyClose (0.00s) + --- PASS: TestServerEarlyBodyClose/tls=false (0.30s) + --- PASS: TestServerEarlyBodyClose/tls=true (0.30s) +FAIL +FAIL github.com/minio/minio/internal/http 0.959s +FAIL diff --git a/docs/investigations/r8/evidence/baseline-compatibility.log b/docs/investigations/r8/evidence/baseline-compatibility.log new file mode 100644 index 000000000..08fd2fa2c --- /dev/null +++ b/docs/investigations/r8/evidence/baseline-compatibility.log @@ -0,0 +1,80 @@ +=== RUN TestServerContinuousUpload +=== RUN TestServerContinuousUpload/tls=false/chunked=false/expect=false +=== PAUSE TestServerContinuousUpload/tls=false/chunked=false/expect=false +=== RUN TestServerContinuousUpload/tls=false/chunked=false/expect=true +=== PAUSE TestServerContinuousUpload/tls=false/chunked=false/expect=true +=== RUN TestServerContinuousUpload/tls=false/chunked=true/expect=false +=== PAUSE TestServerContinuousUpload/tls=false/chunked=true/expect=false +=== RUN TestServerContinuousUpload/tls=false/chunked=true/expect=true +=== PAUSE TestServerContinuousUpload/tls=false/chunked=true/expect=true +=== RUN TestServerContinuousUpload/tls=true/chunked=false/expect=false +=== PAUSE TestServerContinuousUpload/tls=true/chunked=false/expect=false +=== RUN TestServerContinuousUpload/tls=true/chunked=false/expect=true +=== PAUSE TestServerContinuousUpload/tls=true/chunked=false/expect=true +=== RUN TestServerContinuousUpload/tls=true/chunked=true/expect=false +=== PAUSE TestServerContinuousUpload/tls=true/chunked=true/expect=false +=== RUN TestServerContinuousUpload/tls=true/chunked=true/expect=true +=== PAUSE TestServerContinuousUpload/tls=true/chunked=true/expect=true +=== CONT TestServerContinuousUpload/tls=false/chunked=false/expect=false +=== CONT TestServerContinuousUpload/tls=true/chunked=false/expect=false +=== CONT TestServerContinuousUpload/tls=false/chunked=true/expect=false +=== CONT TestServerContinuousUpload/tls=false/chunked=true/expect=true +=== CONT TestServerContinuousUpload/tls=true/chunked=true/expect=true +=== CONT TestServerContinuousUpload/tls=true/chunked=false/expect=true +=== CONT TestServerContinuousUpload/tls=true/chunked=true/expect=false +=== CONT TestServerContinuousUpload/tls=false/chunked=false/expect=true +=== NAME TestServerContinuousUpload/tls=true/chunked=true/expect=false + r8_compatibility_test.go:181: continuous upload 1.293813708s > idle 300ms +=== NAME TestServerContinuousUpload/tls=true/chunked=false/expect=false + r8_compatibility_test.go:181: continuous upload 1.293742875s > idle 300ms +=== NAME TestServerContinuousUpload/tls=false/chunked=true/expect=false + r8_compatibility_test.go:181: continuous upload 1.297742459s > idle 300ms +=== NAME TestServerContinuousUpload/tls=true/chunked=false/expect=true + r8_compatibility_test.go:181: continuous upload 1.293691583s > idle 300ms +=== NAME TestServerContinuousUpload/tls=false/chunked=false/expect=true + r8_compatibility_test.go:181: continuous upload 1.296975334s > idle 300ms +=== NAME TestServerContinuousUpload/tls=false/chunked=true/expect=true + r8_compatibility_test.go:181: continuous upload 1.297091375s > idle 300ms +=== NAME TestServerContinuousUpload/tls=true/chunked=true/expect=true + r8_compatibility_test.go:181: continuous upload 1.293960709s > idle 300ms +=== NAME TestServerContinuousUpload/tls=false/chunked=false/expect=false + r8_compatibility_test.go:181: continuous upload 1.297880292s > idle 300ms +--- PASS: TestServerContinuousUpload (0.00s) + --- PASS: TestServerContinuousUpload/tls=false/chunked=true/expect=true (1.30s) + --- PASS: TestServerContinuousUpload/tls=false/chunked=false/expect=true (1.30s) + --- PASS: TestServerContinuousUpload/tls=true/chunked=true/expect=true (1.30s) + --- PASS: TestServerContinuousUpload/tls=true/chunked=false/expect=true (1.30s) + --- PASS: TestServerContinuousUpload/tls=true/chunked=true/expect=false (1.30s) + --- PASS: TestServerContinuousUpload/tls=false/chunked=false/expect=false (1.30s) + --- PASS: TestServerContinuousUpload/tls=true/chunked=false/expect=false (1.30s) + --- PASS: TestServerContinuousUpload/tls=false/chunked=true/expect=false (1.30s) +=== RUN TestServerIdleBodyDeadline +=== RUN TestServerIdleBodyDeadline/tls=false +=== PAUSE TestServerIdleBodyDeadline/tls=false +=== RUN TestServerIdleBodyDeadline/tls=true +=== PAUSE TestServerIdleBodyDeadline/tls=true +=== CONT TestServerIdleBodyDeadline/tls=false +=== CONT TestServerIdleBodyDeadline/tls=true +--- PASS: TestServerIdleBodyDeadline (0.00s) + --- PASS: TestServerIdleBodyDeadline/tls=false (0.45s) + --- PASS: TestServerIdleBodyDeadline/tls=true (0.45s) +=== RUN TestServerBackgroundReadNoDeadline +=== RUN TestServerBackgroundReadNoDeadline/tls=false/body=false +=== PAUSE TestServerBackgroundReadNoDeadline/tls=false/body=false +=== RUN TestServerBackgroundReadNoDeadline/tls=false/body=true +=== PAUSE TestServerBackgroundReadNoDeadline/tls=false/body=true +=== RUN TestServerBackgroundReadNoDeadline/tls=true/body=false +=== PAUSE TestServerBackgroundReadNoDeadline/tls=true/body=false +=== RUN TestServerBackgroundReadNoDeadline/tls=true/body=true +=== PAUSE TestServerBackgroundReadNoDeadline/tls=true/body=true +=== CONT TestServerBackgroundReadNoDeadline/tls=false/body=false +=== CONT TestServerBackgroundReadNoDeadline/tls=true/body=false +=== CONT TestServerBackgroundReadNoDeadline/tls=false/body=true +=== CONT TestServerBackgroundReadNoDeadline/tls=true/body=true +--- PASS: TestServerBackgroundReadNoDeadline (0.00s) + --- PASS: TestServerBackgroundReadNoDeadline/tls=false/body=false (0.70s) + --- PASS: TestServerBackgroundReadNoDeadline/tls=false/body=true (0.70s) + --- PASS: TestServerBackgroundReadNoDeadline/tls=true/body=false (0.70s) + --- PASS: TestServerBackgroundReadNoDeadline/tls=true/body=true (0.70s) +PASS +ok github.com/minio/minio/internal/http 2.983s diff --git a/docs/investigations/r8/evidence/baseline.log b/docs/investigations/r8/evidence/baseline.log new file mode 100644 index 000000000..96af0f017 --- /dev/null +++ b/docs/investigations/r8/evidence/baseline.log @@ -0,0 +1,11 @@ +=== RUN TestReviewR8AbsoluteHeaderTimeout +=== RUN TestReviewR8AbsoluteHeaderTimeout/standard-net-http + r8_baseline_test.go:56: request rejected after header timeout: unexpected EOF +=== RUN TestReviewR8AbsoluteHeaderTimeout/silo-listener + r8_baseline_test.go:60: 100ms request-header deadline accepted header completed after 400ms: HTTP 204 +--- FAIL: TestReviewR8AbsoluteHeaderTimeout (0.80s) + --- PASS: TestReviewR8AbsoluteHeaderTimeout/standard-net-http (0.40s) + --- FAIL: TestReviewR8AbsoluteHeaderTimeout/silo-listener (0.40s) +FAIL +FAIL github.com/minio/minio/internal/http 1.477s +FAIL diff --git a/docs/investigations/r8/evidence/baseline_test.go.txt b/docs/investigations/r8/evidence/baseline_test.go.txt new file mode 100644 index 000000000..67b65a7dd --- /dev/null +++ b/docs/investigations/r8/evidence/baseline_test.go.txt @@ -0,0 +1,63 @@ +package http + +import ( + "bufio" + "context" + "io" + "net" + stdhttp "net/http" + "testing" + "time" +) + +func TestReviewR8AbsoluteHeaderTimeout(t *testing.T) { + for _, wrapped := range []bool{false, true} { + name := "standard-net-http" + if wrapped { + name = "silo-listener" + } + t.Run(name, func(t *testing.T) { + handler := stdhttp.HandlerFunc(func(w stdhttp.ResponseWriter, r *stdhttp.Request) { w.WriteHeader(204) }) + var addr string + if wrapped { + srv := NewServer([]string{"127.0.0.1:0"}).UseHandler(handler). + UseTCPOptions(TCPOptions{IdleTimeout: 2 * time.Second}). + UseReadHeaderTimeout(100 * time.Millisecond).UseReadTimeout(2 * time.Second).UseWriteTimeout(2 * time.Second) + serve, err := srv.Init(context.Background(), func(_ string, err error) { t.Error(err) }) + if err != nil { + t.Fatal(err) + } + addr = srv.listener.Addr().String() + go serve() + defer srv.Server.Close() + } else { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + srv := &stdhttp.Server{Handler: handler, ReadHeaderTimeout: 100 * time.Millisecond, ReadTimeout: 2 * time.Second, WriteTimeout: 2 * time.Second} + addr = ln.Addr().String() + go srv.Serve(ln) + defer srv.Close() + } + conn, err := net.DialTimeout("tcp", addr, time.Second) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + conn.SetDeadline(time.Now().Add(3 * time.Second)) + if _, err := io.WriteString(conn, "GET / HTTP/1.1\r\nHost: localhost\r\nX-Slow: "); err != nil { + t.Fatal(err) + } + time.Sleep(400 * time.Millisecond) + io.WriteString(conn, "done\r\nConnection: close\r\n\r\n") + resp, err := stdhttp.ReadResponse(bufio.NewReader(conn), nil) + if err != nil { + t.Logf("request rejected after header timeout: %v", err) + return + } + defer resp.Body.Close() + t.Errorf("100ms request-header deadline accepted header completed after 400ms: HTTP %d", resp.StatusCode) + }) + } +} diff --git a/docs/investigations/r8/evidence/build-v1.log b/docs/investigations/r8/evidence/build-v1.log new file mode 100644 index 000000000..e69de29bb diff --git a/docs/investigations/r8/evidence/build-v2.log b/docs/investigations/r8/evidence/build-v2.log new file mode 100644 index 000000000..e69de29bb diff --git a/docs/investigations/r8/evidence/cleanup.json b/docs/investigations/r8/evidence/cleanup.json new file mode 100644 index 000000000..4e9a2db84 --- /dev/null +++ b/docs/investigations/r8/evidence/cleanup.json @@ -0,0 +1,11 @@ +{ + "timestamp_utc": "2026-09-15T16:14:25.042607+00:00", + "reason": "parent requested cleanup of completed task-owned disposable artifacts; baseline runtime evidence and hash retained", + "deleted": [ + { + "path": "/Users/vonng/tmp/silo-r8-01a0a5b9/silo-v1", + "size": 132390258, + "sha256": "ed8d30cb40832f854bd82b083b9d1ec3ee16a35598416a54ef2d48bde8384ffc" + } + ] +} diff --git a/docs/investigations/r8/evidence/config-baseline-env-fixture-failure.log b/docs/investigations/r8/evidence/config-baseline-env-fixture-failure.log new file mode 100644 index 000000000..dba96eb2f --- /dev/null +++ b/docs/investigations/r8/evidence/config-baseline-env-fixture-failure.log @@ -0,0 +1,28 @@ +=== RUN TestServerReadHeaderTimeoutConfig +=== RUN TestServerReadHeaderTimeoutConfig/default + r8_config_test.go:31: could not parse as duration for flag read-header-timeout: time: invalid duration "" +=== RUN TestServerReadHeaderTimeoutConfig/flag + r8_config_test.go:31: could not parse as duration for flag read-header-timeout: time: invalid duration "" +=== RUN TestServerReadHeaderTimeoutConfig/environment + r8_config_test.go:28: CLI read-header-timeout=170ms, expected=170ms + r8_config_test.go:33: parsed ReadHeaderTimeout = 0s, want 170ms +=== RUN TestServerReadHeaderTimeoutConfig/flag-over-environment + r8_config_test.go:28: CLI read-header-timeout=80ms, expected=80ms + r8_config_test.go:33: parsed ReadHeaderTimeout = 0s, want 80ms +=== RUN TestServerReadHeaderTimeoutConfig/yaml-retains-flag + r8_config_test.go:31: could not parse as duration for flag read-header-timeout: time: invalid duration "" +=== RUN TestServerReadHeaderTimeoutConfig/zero-fallback + r8_config_test.go:31: could not parse as duration for flag read-header-timeout: time: invalid duration "" +=== RUN TestServerReadHeaderTimeoutConfig/negative-disabled + r8_config_test.go:31: could not parse as duration for flag read-header-timeout: time: invalid duration "" +--- FAIL: TestServerReadHeaderTimeoutConfig (0.00s) + --- FAIL: TestServerReadHeaderTimeoutConfig/default (0.00s) + --- FAIL: TestServerReadHeaderTimeoutConfig/flag (0.00s) + --- FAIL: TestServerReadHeaderTimeoutConfig/environment (0.00s) + --- FAIL: TestServerReadHeaderTimeoutConfig/flag-over-environment (0.00s) + --- FAIL: TestServerReadHeaderTimeoutConfig/yaml-retains-flag (0.00s) + --- FAIL: TestServerReadHeaderTimeoutConfig/zero-fallback (0.00s) + --- FAIL: TestServerReadHeaderTimeoutConfig/negative-disabled (0.00s) +FAIL +FAIL github.com/minio/minio/cmd 1.629s +FAIL diff --git a/docs/investigations/r8/evidence/config-baseline-initial-fixture-failure.log b/docs/investigations/r8/evidence/config-baseline-initial-fixture-failure.log new file mode 100644 index 000000000..f9b8f2cb5 --- /dev/null +++ b/docs/investigations/r8/evidence/config-baseline-initial-fixture-failure.log @@ -0,0 +1,4 @@ +# github.com/minio/minio/cmd [github.com/minio/minio/cmd.test] +../../../../tmp/silo-r8-01a0a5b9/config_deadline_test.go:26:52: undefined: serverFlags +FAIL github.com/minio/minio/cmd [build failed] +FAIL diff --git a/docs/investigations/r8/evidence/config-baseline.log b/docs/investigations/r8/evidence/config-baseline.log new file mode 100644 index 000000000..5348bc1c7 --- /dev/null +++ b/docs/investigations/r8/evidence/config-baseline.log @@ -0,0 +1,32 @@ +=== RUN TestServerReadHeaderTimeoutConfig +=== RUN TestServerReadHeaderTimeoutConfig/default + r8_config_test.go:30: CLI read-header-timeout=30s, expected=30s + r8_config_test.go:35: parsed ReadHeaderTimeout = 0s, want 30s +=== RUN TestServerReadHeaderTimeoutConfig/flag + r8_config_test.go:30: CLI read-header-timeout=100ms, expected=100ms + r8_config_test.go:35: parsed ReadHeaderTimeout = 0s, want 100ms +=== RUN TestServerReadHeaderTimeoutConfig/environment + r8_config_test.go:30: CLI read-header-timeout=170ms, expected=170ms + r8_config_test.go:35: parsed ReadHeaderTimeout = 0s, want 170ms +=== RUN TestServerReadHeaderTimeoutConfig/flag-over-environment + r8_config_test.go:30: CLI read-header-timeout=80ms, expected=80ms + r8_config_test.go:35: parsed ReadHeaderTimeout = 0s, want 80ms +=== RUN TestServerReadHeaderTimeoutConfig/yaml-retains-flag + r8_config_test.go:30: CLI read-header-timeout=100ms, expected=100ms + r8_config_test.go:35: parsed ReadHeaderTimeout = 0s, want 100ms +=== RUN TestServerReadHeaderTimeoutConfig/zero-fallback + r8_config_test.go:30: CLI read-header-timeout=0s, expected=0s +=== RUN TestServerReadHeaderTimeoutConfig/negative-disabled + r8_config_test.go:30: CLI read-header-timeout=-1s, expected=-1s + r8_config_test.go:35: parsed ReadHeaderTimeout = 0s, want -1s +--- FAIL: TestServerReadHeaderTimeoutConfig (0.00s) + --- FAIL: TestServerReadHeaderTimeoutConfig/default (0.00s) + --- FAIL: TestServerReadHeaderTimeoutConfig/flag (0.00s) + --- FAIL: TestServerReadHeaderTimeoutConfig/environment (0.00s) + --- FAIL: TestServerReadHeaderTimeoutConfig/flag-over-environment (0.00s) + --- FAIL: TestServerReadHeaderTimeoutConfig/yaml-retains-flag (0.00s) + --- PASS: TestServerReadHeaderTimeoutConfig/zero-fallback (0.00s) + --- FAIL: TestServerReadHeaderTimeoutConfig/negative-disabled (0.00s) +FAIL +FAIL github.com/minio/minio/cmd 1.500s +FAIL diff --git a/docs/investigations/r8/evidence/config-fixed.log b/docs/investigations/r8/evidence/config-fixed.log new file mode 100644 index 000000000..b224d59b8 --- /dev/null +++ b/docs/investigations/r8/evidence/config-fixed.log @@ -0,0 +1,36 @@ +=== RUN TestServerConfigFile +=== RUN TestServerConfigFile/testdata/config/1.yaml +=== RUN TestServerConfigFile/testdata/config/2.yaml +=== RUN TestServerConfigFile/testdata/config/invalid.yaml +=== RUN TestServerConfigFile/testdata/config/invalid-types.yaml +=== RUN TestServerConfigFile/testdata/config/invalid-disks.yaml +--- PASS: TestServerConfigFile (0.00s) + --- PASS: TestServerConfigFile/testdata/config/1.yaml (0.00s) + --- PASS: TestServerConfigFile/testdata/config/2.yaml (0.00s) + --- PASS: TestServerConfigFile/testdata/config/invalid.yaml (0.00s) + --- PASS: TestServerConfigFile/testdata/config/invalid-types.yaml (0.00s) + --- PASS: TestServerConfigFile/testdata/config/invalid-disks.yaml (0.00s) +=== RUN TestServerReadHeaderTimeoutConfig +=== RUN TestServerReadHeaderTimeoutConfig/default +=== RUN TestServerReadHeaderTimeoutConfig/flag +=== RUN TestServerReadHeaderTimeoutConfig/environment +=== RUN TestServerReadHeaderTimeoutConfig/flag-over-environment +=== RUN TestServerReadHeaderTimeoutConfig/yaml-retains-flag +=== RUN TestServerReadHeaderTimeoutConfig/zero-fallback +=== RUN TestServerReadHeaderTimeoutConfig/zero-idle-default-header +=== RUN TestServerReadHeaderTimeoutConfig/negative-idle-default-header +=== RUN TestServerReadHeaderTimeoutConfig/fmt-gen-unregistered-duration +=== RUN TestServerReadHeaderTimeoutConfig/negative-disabled +--- PASS: TestServerReadHeaderTimeoutConfig (0.06s) + --- PASS: TestServerReadHeaderTimeoutConfig/default (0.01s) + --- PASS: TestServerReadHeaderTimeoutConfig/flag (0.00s) + --- PASS: TestServerReadHeaderTimeoutConfig/environment (0.00s) + --- PASS: TestServerReadHeaderTimeoutConfig/flag-over-environment (0.01s) + --- PASS: TestServerReadHeaderTimeoutConfig/yaml-retains-flag (0.00s) + --- PASS: TestServerReadHeaderTimeoutConfig/zero-fallback (0.04s) + --- PASS: TestServerReadHeaderTimeoutConfig/zero-idle-default-header (0.00s) + --- PASS: TestServerReadHeaderTimeoutConfig/negative-idle-default-header (0.00s) + --- PASS: TestServerReadHeaderTimeoutConfig/fmt-gen-unregistered-duration (0.00s) + --- PASS: TestServerReadHeaderTimeoutConfig/negative-disabled (0.00s) +PASS +ok github.com/minio/minio/cmd 2.462s diff --git a/docs/investigations/r8/evidence/config-race.log b/docs/investigations/r8/evidence/config-race.log new file mode 100644 index 000000000..d1264b4b4 --- /dev/null +++ b/docs/investigations/r8/evidence/config-race.log @@ -0,0 +1,8 @@ +# github.com/minio/minio/cmd.test +/opt/homebrew/Cellar/go/1.27.1/libexec/pkg/tool/darwin_arm64/link: running cc failed: exit status 1 +/usr/bin/cc -arch arm64 -Wl,-S -Wl,-x -Wl,-U,__dyld_get_dyld_header -o $WORK/b001/cmd.test -Qunused-arguments /var/folders/df/bfm8q07d7bv3kpjf1fjchq4m0000gn/T/go-link-2408312831/go.o /var/folders/df/bfm8q07d7bv3kpjf1fjchq4m0000gn/T/go-link-2408312831/000000.o /var/folders/df/bfm8q07d7bv3kpjf1fjchq4m0000gn/T/go-link-2408312831/000001.o /var/folders/df/bfm8q07d7bv3kpjf1fjchq4m0000gn/T/go-link-2408312831/000002.o /var/folders/df/bfm8q07d7bv3kpjf1fjchq4m0000gn/T/go-link-2408312831/000003.o /var/folders/df/bfm8q07d7bv3kpjf1fjchq4m0000gn/T/go-link-2408312831/000004.o /var/folders/df/bfm8q07d7bv3kpjf1fjchq4m0000gn/T/go-link-2408312831/000005.o /var/folders/df/bfm8q07d7bv3kpjf1fjchq4m0000gn/T/go-link-2408312831/000006.o /var/folders/df/bfm8q07d7bv3kpjf1fjchq4m0000gn/T/go-link-2408312831/000007.o /var/folders/df/bfm8q07d7bv3kpjf1fjchq4m0000gn/T/go-link-2408312831/000008.o /var/folders/df/bfm8q07d7bv3kpjf1fjchq4m0000gn/T/go-link-2408312831/000009.o /var/folders/df/bfm8q07d7bv3kpjf1fjchq4m0000gn/T/go-link-2408312831/000010.o /var/folders/df/bfm8q07d7bv3kpjf1fjchq4m0000gn/T/go-link-2408312831/000011.o /var/folders/df/bfm8q07d7bv3kpjf1fjchq4m0000gn/T/go-link-2408312831/000012.o /var/folders/df/bfm8q07d7bv3kpjf1fjchq4m0000gn/T/go-link-2408312831/000013.o /var/folders/df/bfm8q07d7bv3kpjf1fjchq4m0000gn/T/go-link-2408312831/000014.o /var/folders/df/bfm8q07d7bv3kpjf1fjchq4m0000gn/T/go-link-2408312831/000015.o /var/folders/df/bfm8q07d7bv3kpjf1fjchq4m0000gn/T/go-link-2408312831/000016.o /var/folders/df/bfm8q07d7bv3kpjf1fjchq4m0000gn/T/go-link-2408312831/000017.o /var/folders/df/bfm8q07d7bv3kpjf1fjchq4m0000gn/T/go-link-2408312831/000018.o /var/folders/df/bfm8q07d7bv3kpjf1fjchq4m0000gn/T/go-link-2408312831/000019.o /var/folders/df/bfm8q07d7bv3kpjf1fjchq4m0000gn/T/go-link-2408312831/000020.o /var/folders/df/bfm8q07d7bv3kpjf1fjchq4m0000gn/T/go-link-2408312831/000021.o /var/folders/df/bfm8q07d7bv3kpjf1fjchq4m0000gn/T/go-link-2408312831/000022.o /var/folders/df/bfm8q07d7bv3kpjf1fjchq4m0000gn/T/go-link-2408312831/000023.o /var/folders/df/bfm8q07d7bv3kpjf1fjchq4m0000gn/T/go-link-2408312831/000024.o /var/folders/df/bfm8q07d7bv3kpjf1fjchq4m0000gn/T/go-link-2408312831/000025.o /var/folders/df/bfm8q07d7bv3kpjf1fjchq4m0000gn/T/go-link-2408312831/000026.o /var/folders/df/bfm8q07d7bv3kpjf1fjchq4m0000gn/T/go-link-2408312831/000027.o /var/folders/df/bfm8q07d7bv3kpjf1fjchq4m0000gn/T/go-link-2408312831/000028.o /var/folders/df/bfm8q07d7bv3kpjf1fjchq4m0000gn/T/go-link-2408312831/000029.o /var/folders/df/bfm8q07d7bv3kpjf1fjchq4m0000gn/T/go-link-2408312831/000030.o /var/folders/df/bfm8q07d7bv3kpjf1fjchq4m0000gn/T/go-link-2408312831/000031.o /var/folders/df/bfm8q07d7bv3kpjf1fjchq4m0000gn/T/go-link-2408312831/000032.o /var/folders/df/bfm8q07d7bv3kpjf1fjchq4m0000gn/T/go-link-2408312831/000033.o -lresolv -O2 -g -O2 -g -O2 -g -O2 -g -framework CoreFoundation -framework IOKit -O2 -g -framework IOKit -O2 -g -framework CoreFoundation -framework Security -O2 -g -framework CoreServices -O2 -g -O2 -g -framework CoreFoundation -framework IOKit -O2 -g -framework CoreFoundation -framework CFNetwork -framework CoreFoundation -framework CFNetwork +ld: write() failed, errno=28 (No space left on device) +clang: error: linker command failed with exit code 1 (use -v to see invocation) + +FAIL github.com/minio/minio/cmd [build failed] +FAIL diff --git a/docs/investigations/r8/evidence/config_baseline_test.go.txt b/docs/investigations/r8/evidence/config_baseline_test.go.txt new file mode 100644 index 000000000..6d8aa847e --- /dev/null +++ b/docs/investigations/r8/evidence/config_baseline_test.go.txt @@ -0,0 +1,39 @@ +package cmd + +import ( + "os" + "testing" + "time" + + "github.com/minio/cli" + xhttp "github.com/minio/minio/internal/http" +) + +func TestServerReadHeaderTimeoutConfig(t *testing.T) { + for _,tc:=range []struct{name,env string;args []string;want time.Duration}{ + {name:"default",want:xhttp.DefaultReadHeaderTimeout}, + {name:"flag",args:[]string{"--read-header-timeout=100ms"},want:100*time.Millisecond}, + {name:"environment",env:"170ms",want:170*time.Millisecond}, + {name:"flag-over-environment",env:"170ms",args:[]string{"--read-header-timeout=80ms"},want:80*time.Millisecond}, + {name:"yaml-retains-flag",args:[]string{"--config=testdata/config/1.yaml","--read-header-timeout=100ms"},want:100*time.Millisecond}, + {name:"zero-fallback",args:[]string{"--read-header-timeout=0s"}}, + {name:"negative-disabled",args:[]string{"--read-header-timeout=-1s"},want:-time.Second}, + }{ + t.Run(tc.name,func(t *testing.T){ + for _,key:=range []string{"MINIO_ARGS","MINIO_VOLUMES","MINIO_ENDPOINTS","MINIO_CONFIG","MINIO_ERASURE_SET_DRIVE_COUNT"}{t.Setenv(key,"")} + t.Setenv("MINIO_READ_HEADER_TIMEOUT",tc.env) + if tc.env==""{if err:=os.Unsetenv("MINIO_READ_HEADER_TIMEOUT");err!=nil{t.Fatal(err)}} + t.Setenv("MINIO_IDLE_TIMEOUT","2s") + var got serverCtxt + called:=false + app:=cli.NewApp() + app.Commands=[]cli.Command{{Name:"server",Flags:serverCmd.Flags,Action:func(ctx *cli.Context)error{called=true;t.Logf("CLI read-header-timeout=%s, expected=%s",ctx.Duration("read-header-timeout"),tc.want);return buildServerCtxt(ctx,&got)}}} + args:=append([]string{"silo","server"},tc.args...) + args=append(args,t.TempDir()) + if err:=app.Run(args);err!=nil{t.Fatal(err)} + if !called{t.Fatal("server action did not run")} + if got.ReadHeaderTimeout!=tc.want{t.Errorf("parsed ReadHeaderTimeout = %s, want %s",got.ReadHeaderTimeout,tc.want)} + if got.IdleTimeout!=2*time.Second{t.Errorf("parsed IdleTimeout = %s, want 2s",got.IdleTimeout)} + }) + } +} diff --git a/docs/investigations/r8/evidence/darwin-race-final.log b/docs/investigations/r8/evidence/darwin-race-final.log new file mode 100644 index 000000000..0bf4196f6 --- /dev/null +++ b/docs/investigations/r8/evidence/darwin-race-final.log @@ -0,0 +1,213 @@ +=== RUN TestStrictReadDeadline +=== RUN TestStrictReadDeadline/SetReadDeadline +=== RUN TestStrictReadDeadline/SetDeadline +--- PASS: TestStrictReadDeadline (0.00s) + --- PASS: TestStrictReadDeadline/SetReadDeadline (0.00s) + --- PASS: TestStrictReadDeadline/SetDeadline (0.00s) +=== RUN TestDefaultReadDeadlineStillRenews +--- PASS: TestDefaultReadDeadlineStillRenews (0.00s) +=== RUN TestStrictExpiredFutureReadDeadline +--- PASS: TestStrictExpiredFutureReadDeadline (0.06s) +=== RUN TestConcurrentStrictReadDeadline +--- PASS: TestConcurrentStrictReadDeadline (0.00s) +=== RUN TestStrictReadDeadlineRepeatedRenewal +--- PASS: TestStrictReadDeadlineRepeatedRenewal (0.91s) +=== RUN TestBuffConnReadTimeout +--- PASS: TestBuffConnReadTimeout (3.04s) +=== RUN TestBuffConnReadCheckTimeout +--- PASS: TestBuffConnReadCheckTimeout (0.50s) +PASS +ok github.com/minio/minio/internal/deadlineconn 5.927s +=== RUN TestCheckPortAvailability + check_port_test.go:31: +--- SKIP: TestCheckPortAvailability (0.00s) +=== RUN TestNewHTTPListener +--- PASS: TestNewHTTPListener (0.04s) +=== RUN TestHTTPListenerStartClose +--- PASS: TestHTTPListenerStartClose (0.03s) +=== RUN TestHTTPListenerAddr +--- PASS: TestHTTPListenerAddr (0.01s) +=== RUN TestHTTPListenerAddrs +--- PASS: TestHTTPListenerAddrs (0.02s) +=== RUN TestServerReadHeaderDeadline +=== RUN TestServerReadHeaderDeadline/tls=false/second=false/trickle=false +=== PAUSE TestServerReadHeaderDeadline/tls=false/second=false/trickle=false +=== RUN TestServerReadHeaderDeadline/tls=false/second=false/trickle=true +=== PAUSE TestServerReadHeaderDeadline/tls=false/second=false/trickle=true +=== RUN TestServerReadHeaderDeadline/tls=false/second=true/trickle=false +=== PAUSE TestServerReadHeaderDeadline/tls=false/second=true/trickle=false +=== RUN TestServerReadHeaderDeadline/tls=false/second=true/trickle=true +=== PAUSE TestServerReadHeaderDeadline/tls=false/second=true/trickle=true +=== RUN TestServerReadHeaderDeadline/tls=true/second=false/trickle=false +=== PAUSE TestServerReadHeaderDeadline/tls=true/second=false/trickle=false +=== RUN TestServerReadHeaderDeadline/tls=true/second=false/trickle=true +=== PAUSE TestServerReadHeaderDeadline/tls=true/second=false/trickle=true +=== RUN TestServerReadHeaderDeadline/tls=true/second=true/trickle=false +=== PAUSE TestServerReadHeaderDeadline/tls=true/second=true/trickle=false +=== RUN TestServerReadHeaderDeadline/tls=true/second=true/trickle=true +=== PAUSE TestServerReadHeaderDeadline/tls=true/second=true/trickle=true +=== CONT TestServerReadHeaderDeadline/tls=false/second=false/trickle=false +=== CONT TestServerReadHeaderDeadline/tls=true/second=false/trickle=false +=== CONT TestServerReadHeaderDeadline/tls=true/second=true/trickle=false +=== CONT TestServerReadHeaderDeadline/tls=false/second=false/trickle=true +=== CONT TestServerReadHeaderDeadline/tls=false/second=true/trickle=false +=== CONT TestServerReadHeaderDeadline/tls=false/second=true/trickle=true +=== CONT TestServerReadHeaderDeadline/tls=true/second=false/trickle=true +=== CONT TestServerReadHeaderDeadline/tls=true/second=true/trickle=true +--- PASS: TestServerReadHeaderDeadline (0.00s) + --- PASS: TestServerReadHeaderDeadline/tls=false/second=false/trickle=true (0.82s) + --- PASS: TestServerReadHeaderDeadline/tls=true/second=false/trickle=true (0.85s) + --- PASS: TestServerReadHeaderDeadline/tls=false/second=false/trickle=false (0.96s) + --- PASS: TestServerReadHeaderDeadline/tls=true/second=false/trickle=false (0.98s) + --- PASS: TestServerReadHeaderDeadline/tls=false/second=true/trickle=true (1.57s) + --- PASS: TestServerReadHeaderDeadline/tls=true/second=true/trickle=true (1.59s) + --- PASS: TestServerReadHeaderDeadline/tls=false/second=true/trickle=false (1.71s) + --- PASS: TestServerReadHeaderDeadline/tls=true/second=true/trickle=false (1.73s) +=== RUN TestServerKeepAliveDeadline +=== RUN TestServerKeepAliveDeadline/tls=false +=== PAUSE TestServerKeepAliveDeadline/tls=false +=== RUN TestServerKeepAliveDeadline/tls=true +=== PAUSE TestServerKeepAliveDeadline/tls=true +=== CONT TestServerKeepAliveDeadline/tls=false +=== CONT TestServerKeepAliveDeadline/tls=true +--- PASS: TestServerKeepAliveDeadline (0.00s) + --- PASS: TestServerKeepAliveDeadline/tls=false (1.52s) + --- PASS: TestServerKeepAliveDeadline/tls=true (1.53s) +=== RUN TestServerContinuousUpload +=== RUN TestServerContinuousUpload/tls=false/chunked=false/expect=false +=== PAUSE TestServerContinuousUpload/tls=false/chunked=false/expect=false +=== RUN TestServerContinuousUpload/tls=false/chunked=false/expect=true +=== PAUSE TestServerContinuousUpload/tls=false/chunked=false/expect=true +=== RUN TestServerContinuousUpload/tls=false/chunked=true/expect=false +=== PAUSE TestServerContinuousUpload/tls=false/chunked=true/expect=false +=== RUN TestServerContinuousUpload/tls=false/chunked=true/expect=true +=== PAUSE TestServerContinuousUpload/tls=false/chunked=true/expect=true +=== RUN TestServerContinuousUpload/tls=true/chunked=false/expect=false +=== PAUSE TestServerContinuousUpload/tls=true/chunked=false/expect=false +=== RUN TestServerContinuousUpload/tls=true/chunked=false/expect=true +=== PAUSE TestServerContinuousUpload/tls=true/chunked=false/expect=true +=== RUN TestServerContinuousUpload/tls=true/chunked=true/expect=false +=== PAUSE TestServerContinuousUpload/tls=true/chunked=true/expect=false +=== RUN TestServerContinuousUpload/tls=true/chunked=true/expect=true +=== PAUSE TestServerContinuousUpload/tls=true/chunked=true/expect=true +=== CONT TestServerContinuousUpload/tls=false/chunked=false/expect=false +=== CONT TestServerContinuousUpload/tls=true/chunked=true/expect=true +=== CONT TestServerContinuousUpload/tls=false/chunked=true/expect=true +=== CONT TestServerContinuousUpload/tls=false/chunked=true/expect=false +=== CONT TestServerContinuousUpload/tls=false/chunked=false/expect=true +=== CONT TestServerContinuousUpload/tls=true/chunked=false/expect=true +=== CONT TestServerContinuousUpload/tls=true/chunked=true/expect=false +=== CONT TestServerContinuousUpload/tls=true/chunked=false/expect=false +=== NAME TestServerContinuousUpload/tls=false/chunked=true/expect=false + server_deadline_test.go:261: continuous upload 1.312965334s > idle 300ms +=== NAME TestServerContinuousUpload/tls=false/chunked=true/expect=true + server_deadline_test.go:261: continuous upload 1.30790825s > idle 300ms +=== NAME TestServerContinuousUpload/tls=false/chunked=false/expect=false + server_deadline_test.go:261: continuous upload 1.32285075s > idle 300ms +=== NAME TestServerContinuousUpload/tls=false/chunked=false/expect=true + server_deadline_test.go:261: continuous upload 1.323004708s > idle 300ms +=== NAME TestServerContinuousUpload/tls=true/chunked=true/expect=true + server_deadline_test.go:261: continuous upload 1.322070417s > idle 300ms +=== NAME TestServerContinuousUpload/tls=true/chunked=false/expect=false + server_deadline_test.go:261: continuous upload 1.323585625s > idle 300ms +=== NAME TestServerContinuousUpload/tls=true/chunked=true/expect=false + server_deadline_test.go:261: continuous upload 1.323022875s > idle 300ms +=== NAME TestServerContinuousUpload/tls=true/chunked=false/expect=true + server_deadline_test.go:261: continuous upload 1.323094875s > idle 300ms +--- PASS: TestServerContinuousUpload (0.00s) + --- PASS: TestServerContinuousUpload/tls=false/chunked=true/expect=false (1.31s) + --- PASS: TestServerContinuousUpload/tls=false/chunked=true/expect=true (1.31s) + --- PASS: TestServerContinuousUpload/tls=false/chunked=false/expect=true (1.33s) + --- PASS: TestServerContinuousUpload/tls=false/chunked=false/expect=false (1.33s) + --- PASS: TestServerContinuousUpload/tls=true/chunked=true/expect=true (1.34s) + --- PASS: TestServerContinuousUpload/tls=true/chunked=false/expect=false (1.34s) + --- PASS: TestServerContinuousUpload/tls=true/chunked=true/expect=false (1.34s) + --- PASS: TestServerContinuousUpload/tls=true/chunked=false/expect=true (1.34s) +=== RUN TestServerDefaultIdleLongUpload + server_deadline_test.go:270: set SILO_TEST_LONG_UPLOAD=1 for >30s default-idle regression +--- SKIP: TestServerDefaultIdleLongUpload (0.00s) +=== RUN TestServerIdleBodyDeadline +=== RUN TestServerIdleBodyDeadline/tls=false +=== PAUSE TestServerIdleBodyDeadline/tls=false +=== RUN TestServerIdleBodyDeadline/tls=true +=== PAUSE TestServerIdleBodyDeadline/tls=true +=== CONT TestServerIdleBodyDeadline/tls=false +=== CONT TestServerIdleBodyDeadline/tls=true +--- PASS: TestServerIdleBodyDeadline (0.00s) + --- PASS: TestServerIdleBodyDeadline/tls=false (0.45s) + --- PASS: TestServerIdleBodyDeadline/tls=true (0.47s) +=== RUN TestServerBackgroundReadNoDeadline +=== RUN TestServerBackgroundReadNoDeadline/tls=false/body=false +=== PAUSE TestServerBackgroundReadNoDeadline/tls=false/body=false +=== RUN TestServerBackgroundReadNoDeadline/tls=false/body=true +=== PAUSE TestServerBackgroundReadNoDeadline/tls=false/body=true +=== RUN TestServerBackgroundReadNoDeadline/tls=true/body=false +=== PAUSE TestServerBackgroundReadNoDeadline/tls=true/body=false +=== RUN TestServerBackgroundReadNoDeadline/tls=true/body=true +=== PAUSE TestServerBackgroundReadNoDeadline/tls=true/body=true +=== CONT TestServerBackgroundReadNoDeadline/tls=true/body=false +=== CONT TestServerBackgroundReadNoDeadline/tls=false/body=true +=== CONT TestServerBackgroundReadNoDeadline/tls=true/body=true +=== CONT TestServerBackgroundReadNoDeadline/tls=false/body=false +--- PASS: TestServerBackgroundReadNoDeadline (0.00s) + --- PASS: TestServerBackgroundReadNoDeadline/tls=false/body=true (0.71s) + --- PASS: TestServerBackgroundReadNoDeadline/tls=true/body=false (0.71s) + --- PASS: TestServerBackgroundReadNoDeadline/tls=false/body=false (0.71s) + --- PASS: TestServerBackgroundReadNoDeadline/tls=true/body=true (0.72s) +=== RUN TestServerTLSHandshakeReadDeadline +2026/09/16 00:03:51 http: TLS handshake error from 127.0.0.1:57963: read tcp 127.0.0.1:57962->127.0.0.1:57963: i/o timeout +--- PASS: TestServerTLSHandshakeReadDeadline (0.20s) +=== RUN TestServerConnStateHook +=== RUN TestServerConnStateHook/tls=false +=== RUN TestServerConnStateHook/tls=true +--- PASS: TestServerConnStateHook (0.02s) + --- PASS: TestServerConnStateHook/tls=false (0.00s) + --- PASS: TestServerConnStateHook/tls=true (0.02s) +=== RUN TestServerHTTP2Deadlines +--- PASS: TestServerHTTP2Deadlines (0.43s) +=== RUN TestServerEarlyBodyClose +=== RUN TestServerEarlyBodyClose/tls=false +=== PAUSE TestServerEarlyBodyClose/tls=false +=== RUN TestServerEarlyBodyClose/tls=true +=== PAUSE TestServerEarlyBodyClose/tls=true +=== CONT TestServerEarlyBodyClose/tls=false +=== CONT TestServerEarlyBodyClose/tls=true +--- PASS: TestServerEarlyBodyClose (0.00s) + --- PASS: TestServerEarlyBodyClose/tls=false (0.30s) + --- PASS: TestServerEarlyBodyClose/tls=true (0.31s) +=== RUN TestServerPipelinedDeadline +=== RUN TestServerPipelinedDeadline/tls=false +=== RUN TestServerPipelinedDeadline/tls=true +--- PASS: TestServerPipelinedDeadline (1.31s) + --- PASS: TestServerPipelinedDeadline/tls=false (0.65s) + --- PASS: TestServerPipelinedDeadline/tls=true (0.66s) +=== RUN TestServerHijackedDeadline +=== RUN TestServerHijackedDeadline/tls=false +=== PAUSE TestServerHijackedDeadline/tls=false +=== RUN TestServerHijackedDeadline/tls=true +=== PAUSE TestServerHijackedDeadline/tls=true +=== CONT TestServerHijackedDeadline/tls=true +=== CONT TestServerHijackedDeadline/tls=false +--- PASS: TestServerHijackedDeadline (0.00s) + --- PASS: TestServerHijackedDeadline/tls=false (0.70s) + --- PASS: TestServerHijackedDeadline/tls=true (0.71s) +=== RUN TestServerContinuousDownload +=== RUN TestServerContinuousDownload/tls=false +=== PAUSE TestServerContinuousDownload/tls=false +=== RUN TestServerContinuousDownload/tls=true +=== PAUSE TestServerContinuousDownload/tls=true +=== CONT TestServerContinuousDownload/tls=true +=== CONT TestServerContinuousDownload/tls=false + server_deadline_test.go:619: continuous download 1.2963195s > idle 300ms +=== NAME TestServerContinuousDownload/tls=true + server_deadline_test.go:619: continuous download 1.295400042s > idle 300ms +--- PASS: TestServerContinuousDownload (0.00s) + --- PASS: TestServerContinuousDownload/tls=false (1.30s) + --- PASS: TestServerContinuousDownload/tls=true (1.31s) +=== RUN TestServerDefaultIdleLongDownload + server_deadline_test.go:626: set SILO_TEST_LONG_UPLOAD=1 for >30s default-idle transfer regressions +--- SKIP: TestServerDefaultIdleLongDownload (0.00s) +=== RUN TestNewServer +--- PASS: TestNewServer (0.00s) +PASS +ok github.com/minio/minio/internal/http 11.654s diff --git a/docs/investigations/r8/evidence/darwin-race-initial-fixture-failure.log b/docs/investigations/r8/evidence/darwin-race-initial-fixture-failure.log new file mode 100644 index 000000000..0ecdc3200 --- /dev/null +++ b/docs/investigations/r8/evidence/darwin-race-initial-fixture-failure.log @@ -0,0 +1,213 @@ +=== RUN TestStrictReadDeadline +=== RUN TestStrictReadDeadline/SetReadDeadline +=== RUN TestStrictReadDeadline/SetDeadline +--- PASS: TestStrictReadDeadline (0.00s) + --- PASS: TestStrictReadDeadline/SetReadDeadline (0.00s) + --- PASS: TestStrictReadDeadline/SetDeadline (0.00s) +=== RUN TestDefaultReadDeadlineStillRenews +--- PASS: TestDefaultReadDeadlineStillRenews (0.00s) +=== RUN TestStrictExpiredFutureReadDeadline +--- PASS: TestStrictExpiredFutureReadDeadline (0.06s) +=== RUN TestConcurrentStrictReadDeadline +--- PASS: TestConcurrentStrictReadDeadline (0.00s) +=== RUN TestBuffConnReadTimeout +--- PASS: TestBuffConnReadTimeout (3.02s) +=== RUN TestBuffConnReadCheckTimeout +--- PASS: TestBuffConnReadCheckTimeout (0.50s) +PASS +ok github.com/minio/minio/internal/deadlineconn 5.330s +=== RUN TestCheckPortAvailability + check_port_test.go:31: +--- SKIP: TestCheckPortAvailability (0.00s) +=== RUN TestNewHTTPListener +--- PASS: TestNewHTTPListener (0.06s) +=== RUN TestHTTPListenerStartClose +--- PASS: TestHTTPListenerStartClose (0.05s) +=== RUN TestHTTPListenerAddr +--- PASS: TestHTTPListenerAddr (0.01s) +=== RUN TestHTTPListenerAddrs +--- PASS: TestHTTPListenerAddrs (0.00s) +=== RUN TestServerReadHeaderDeadline +=== RUN TestServerReadHeaderDeadline/tls=false/second=false/trickle=false +=== PAUSE TestServerReadHeaderDeadline/tls=false/second=false/trickle=false +=== RUN TestServerReadHeaderDeadline/tls=false/second=false/trickle=true +=== PAUSE TestServerReadHeaderDeadline/tls=false/second=false/trickle=true +=== RUN TestServerReadHeaderDeadline/tls=false/second=true/trickle=false +=== PAUSE TestServerReadHeaderDeadline/tls=false/second=true/trickle=false +=== RUN TestServerReadHeaderDeadline/tls=false/second=true/trickle=true +=== PAUSE TestServerReadHeaderDeadline/tls=false/second=true/trickle=true +=== RUN TestServerReadHeaderDeadline/tls=true/second=false/trickle=false +=== PAUSE TestServerReadHeaderDeadline/tls=true/second=false/trickle=false +=== RUN TestServerReadHeaderDeadline/tls=true/second=false/trickle=true +=== PAUSE TestServerReadHeaderDeadline/tls=true/second=false/trickle=true +=== RUN TestServerReadHeaderDeadline/tls=true/second=true/trickle=false +=== PAUSE TestServerReadHeaderDeadline/tls=true/second=true/trickle=false +=== RUN TestServerReadHeaderDeadline/tls=true/second=true/trickle=true +=== PAUSE TestServerReadHeaderDeadline/tls=true/second=true/trickle=true +=== CONT TestServerReadHeaderDeadline/tls=false/second=false/trickle=false +=== CONT TestServerReadHeaderDeadline/tls=false/second=true/trickle=true +=== CONT TestServerReadHeaderDeadline/tls=false/second=false/trickle=true +=== CONT TestServerReadHeaderDeadline/tls=false/second=true/trickle=false +=== CONT TestServerReadHeaderDeadline/tls=true/second=true/trickle=false +=== CONT TestServerReadHeaderDeadline/tls=true/second=true/trickle=true +=== CONT TestServerReadHeaderDeadline/tls=true/second=false/trickle=true +=== CONT TestServerReadHeaderDeadline/tls=true/second=false/trickle=false +--- PASS: TestServerReadHeaderDeadline (0.00s) + --- PASS: TestServerReadHeaderDeadline/tls=false/second=false/trickle=true (0.41s) + --- PASS: TestServerReadHeaderDeadline/tls=true/second=false/trickle=true (0.43s) + --- PASS: TestServerReadHeaderDeadline/tls=false/second=false/trickle=false (0.60s) + --- PASS: TestServerReadHeaderDeadline/tls=true/second=false/trickle=false (0.62s) + --- PASS: TestServerReadHeaderDeadline/tls=false/second=true/trickle=true (0.82s) + --- PASS: TestServerReadHeaderDeadline/tls=true/second=true/trickle=true (0.82s) + --- PASS: TestServerReadHeaderDeadline/tls=false/second=true/trickle=false (1.01s) + --- PASS: TestServerReadHeaderDeadline/tls=true/second=true/trickle=false (1.02s) +=== RUN TestServerKeepAliveDeadline +=== RUN TestServerKeepAliveDeadline/tls=false +=== PAUSE TestServerKeepAliveDeadline/tls=false +=== RUN TestServerKeepAliveDeadline/tls=true +=== PAUSE TestServerKeepAliveDeadline/tls=true +=== CONT TestServerKeepAliveDeadline/tls=true +=== CONT TestServerKeepAliveDeadline/tls=false +--- PASS: TestServerKeepAliveDeadline (0.00s) + --- PASS: TestServerKeepAliveDeadline/tls=false (1.51s) + --- PASS: TestServerKeepAliveDeadline/tls=true (1.52s) +=== RUN TestServerContinuousUpload +=== RUN TestServerContinuousUpload/tls=false/chunked=false/expect=false +=== PAUSE TestServerContinuousUpload/tls=false/chunked=false/expect=false +=== RUN TestServerContinuousUpload/tls=false/chunked=false/expect=true +=== PAUSE TestServerContinuousUpload/tls=false/chunked=false/expect=true +=== RUN TestServerContinuousUpload/tls=false/chunked=true/expect=false +=== PAUSE TestServerContinuousUpload/tls=false/chunked=true/expect=false +=== RUN TestServerContinuousUpload/tls=false/chunked=true/expect=true +=== PAUSE TestServerContinuousUpload/tls=false/chunked=true/expect=true +=== RUN TestServerContinuousUpload/tls=true/chunked=false/expect=false +=== PAUSE TestServerContinuousUpload/tls=true/chunked=false/expect=false +=== RUN TestServerContinuousUpload/tls=true/chunked=false/expect=true +=== PAUSE TestServerContinuousUpload/tls=true/chunked=false/expect=true +=== RUN TestServerContinuousUpload/tls=true/chunked=true/expect=false +=== PAUSE TestServerContinuousUpload/tls=true/chunked=true/expect=false +=== RUN TestServerContinuousUpload/tls=true/chunked=true/expect=true +=== PAUSE TestServerContinuousUpload/tls=true/chunked=true/expect=true +=== CONT TestServerContinuousUpload/tls=false/chunked=false/expect=false +=== CONT TestServerContinuousUpload/tls=true/chunked=false/expect=false +=== CONT TestServerContinuousUpload/tls=true/chunked=true/expect=false +=== CONT TestServerContinuousUpload/tls=true/chunked=true/expect=true +=== CONT TestServerContinuousUpload/tls=true/chunked=false/expect=true +=== CONT TestServerContinuousUpload/tls=false/chunked=false/expect=true +=== CONT TestServerContinuousUpload/tls=false/chunked=true/expect=false +=== CONT TestServerContinuousUpload/tls=false/chunked=true/expect=true +=== NAME TestServerContinuousUpload/tls=false/chunked=false/expect=false + server_deadline_test.go:259: continuous upload 1.299239125s > idle 300ms +=== NAME TestServerContinuousUpload/tls=false/chunked=true/expect=false + server_deadline_test.go:259: continuous upload 1.297586417s > idle 300ms +=== NAME TestServerContinuousUpload/tls=false/chunked=false/expect=true + server_deadline_test.go:259: continuous upload 1.308398125s > idle 300ms +=== NAME TestServerContinuousUpload/tls=true/chunked=false/expect=true + server_deadline_test.go:259: continuous upload 1.300855208s > idle 300ms +=== NAME TestServerContinuousUpload/tls=true/chunked=false/expect=false + server_deadline_test.go:259: continuous upload 1.30259925s > idle 300ms +=== NAME TestServerContinuousUpload/tls=true/chunked=true/expect=true + server_deadline_test.go:259: continuous upload 1.301167166s > idle 300ms +=== NAME TestServerContinuousUpload/tls=false/chunked=true/expect=true + server_deadline_test.go:259: continuous upload 1.309196541s > idle 300ms +=== NAME TestServerContinuousUpload/tls=true/chunked=true/expect=false + server_deadline_test.go:259: continuous upload 1.302837125s > idle 300ms +--- PASS: TestServerContinuousUpload (0.00s) + --- PASS: TestServerContinuousUpload/tls=false/chunked=false/expect=false (1.30s) + --- PASS: TestServerContinuousUpload/tls=false/chunked=true/expect=false (1.30s) + --- PASS: TestServerContinuousUpload/tls=false/chunked=false/expect=true (1.32s) + --- PASS: TestServerContinuousUpload/tls=true/chunked=false/expect=true (1.32s) + --- PASS: TestServerContinuousUpload/tls=true/chunked=false/expect=false (1.32s) + --- PASS: TestServerContinuousUpload/tls=true/chunked=true/expect=true (1.32s) + --- PASS: TestServerContinuousUpload/tls=false/chunked=true/expect=true (1.32s) + --- PASS: TestServerContinuousUpload/tls=true/chunked=true/expect=false (1.32s) +=== RUN TestServerDefaultIdleLongUpload + server_deadline_test.go:268: set SILO_TEST_LONG_UPLOAD=1 for >30s default-idle regression +--- SKIP: TestServerDefaultIdleLongUpload (0.00s) +=== RUN TestServerIdleBodyDeadline +=== RUN TestServerIdleBodyDeadline/tls=false +=== PAUSE TestServerIdleBodyDeadline/tls=false +=== RUN TestServerIdleBodyDeadline/tls=true +=== PAUSE TestServerIdleBodyDeadline/tls=true +=== CONT TestServerIdleBodyDeadline/tls=true +=== CONT TestServerIdleBodyDeadline/tls=false +--- PASS: TestServerIdleBodyDeadline (0.00s) + --- PASS: TestServerIdleBodyDeadline/tls=false (0.45s) + --- PASS: TestServerIdleBodyDeadline/tls=true (0.46s) +=== RUN TestServerBackgroundReadNoDeadline +=== RUN TestServerBackgroundReadNoDeadline/tls=false/body=false +=== PAUSE TestServerBackgroundReadNoDeadline/tls=false/body=false +=== RUN TestServerBackgroundReadNoDeadline/tls=false/body=true +=== PAUSE TestServerBackgroundReadNoDeadline/tls=false/body=true +=== RUN TestServerBackgroundReadNoDeadline/tls=true/body=false +=== PAUSE TestServerBackgroundReadNoDeadline/tls=true/body=false +=== RUN TestServerBackgroundReadNoDeadline/tls=true/body=true +=== PAUSE TestServerBackgroundReadNoDeadline/tls=true/body=true +=== CONT TestServerBackgroundReadNoDeadline/tls=false/body=false +=== CONT TestServerBackgroundReadNoDeadline/tls=false/body=true +=== CONT TestServerBackgroundReadNoDeadline/tls=true/body=false +=== CONT TestServerBackgroundReadNoDeadline/tls=true/body=true +--- PASS: TestServerBackgroundReadNoDeadline (0.00s) + --- PASS: TestServerBackgroundReadNoDeadline/tls=false/body=false (0.70s) + --- PASS: TestServerBackgroundReadNoDeadline/tls=false/body=true (0.70s) + --- PASS: TestServerBackgroundReadNoDeadline/tls=true/body=false (0.71s) + --- PASS: TestServerBackgroundReadNoDeadline/tls=true/body=true (0.71s) +=== RUN TestServerTLSHandshakeReadDeadline +2026/09/15 23:52:51 http: TLS handshake error from 127.0.0.1:54831: read tcp 127.0.0.1:54830->127.0.0.1:54831: i/o timeout +--- PASS: TestServerTLSHandshakeReadDeadline (0.20s) +=== RUN TestServerConnStateHook +=== RUN TestServerConnStateHook/tls=false +=== RUN TestServerConnStateHook/tls=true +--- PASS: TestServerConnStateHook (0.01s) + --- PASS: TestServerConnStateHook/tls=false (0.00s) + --- PASS: TestServerConnStateHook/tls=true (0.01s) +=== RUN TestServerHTTP2Deadlines + server_deadline_test.go:435: expected native HTTP/2 read timeout, got stream error: stream ID 5; INTERNAL_ERROR; i/o timeout +--- FAIL: TestServerHTTP2Deadlines (0.41s) +=== RUN TestServerEarlyBodyClose +=== RUN TestServerEarlyBodyClose/tls=false +=== PAUSE TestServerEarlyBodyClose/tls=false +=== RUN TestServerEarlyBodyClose/tls=true +=== PAUSE TestServerEarlyBodyClose/tls=true +=== CONT TestServerEarlyBodyClose/tls=true +=== CONT TestServerEarlyBodyClose/tls=false +--- PASS: TestServerEarlyBodyClose (0.00s) + --- PASS: TestServerEarlyBodyClose/tls=false (0.30s) + --- PASS: TestServerEarlyBodyClose/tls=true (0.31s) +=== RUN TestServerPipelinedDeadline +=== RUN TestServerPipelinedDeadline/tls=false +=== RUN TestServerPipelinedDeadline/tls=true +--- PASS: TestServerPipelinedDeadline (1.31s) + --- PASS: TestServerPipelinedDeadline/tls=false (0.65s) + --- PASS: TestServerPipelinedDeadline/tls=true (0.66s) +=== RUN TestServerHijackedDeadline +=== RUN TestServerHijackedDeadline/tls=false +=== PAUSE TestServerHijackedDeadline/tls=false +=== RUN TestServerHijackedDeadline/tls=true +=== PAUSE TestServerHijackedDeadline/tls=true +=== CONT TestServerHijackedDeadline/tls=false +=== CONT TestServerHijackedDeadline/tls=true +--- PASS: TestServerHijackedDeadline (0.00s) + --- PASS: TestServerHijackedDeadline/tls=false (0.70s) + --- PASS: TestServerHijackedDeadline/tls=true (0.71s) +=== RUN TestServerContinuousDownload +=== RUN TestServerContinuousDownload/tls=false +=== PAUSE TestServerContinuousDownload/tls=false +=== RUN TestServerContinuousDownload/tls=true +=== PAUSE TestServerContinuousDownload/tls=true +=== CONT TestServerContinuousDownload/tls=true +=== CONT TestServerContinuousDownload/tls=false + server_deadline_test.go:588: continuous download 1.298471834s > idle 300ms +=== NAME TestServerContinuousDownload/tls=true + server_deadline_test.go:588: continuous download 1.29483875s > idle 300ms +--- PASS: TestServerContinuousDownload (0.00s) + --- PASS: TestServerContinuousDownload/tls=false (1.30s) + --- PASS: TestServerContinuousDownload/tls=true (1.30s) +=== RUN TestServerDefaultIdleLongDownload + server_deadline_test.go:595: set SILO_TEST_LONG_UPLOAD=1 for >30s default-idle transfer regressions +--- SKIP: TestServerDefaultIdleLongDownload (0.00s) +=== RUN TestNewServer +--- PASS: TestNewServer (0.00s) +FAIL +FAIL github.com/minio/minio/internal/http 10.023s +FAIL diff --git a/docs/investigations/r8/evidence/darwin-race.log b/docs/investigations/r8/evidence/darwin-race.log new file mode 100644 index 000000000..489c08e78 --- /dev/null +++ b/docs/investigations/r8/evidence/darwin-race.log @@ -0,0 +1,211 @@ +=== RUN TestStrictReadDeadline +=== RUN TestStrictReadDeadline/SetReadDeadline +=== RUN TestStrictReadDeadline/SetDeadline +--- PASS: TestStrictReadDeadline (0.00s) + --- PASS: TestStrictReadDeadline/SetReadDeadline (0.00s) + --- PASS: TestStrictReadDeadline/SetDeadline (0.00s) +=== RUN TestDefaultReadDeadlineStillRenews +--- PASS: TestDefaultReadDeadlineStillRenews (0.00s) +=== RUN TestStrictExpiredFutureReadDeadline +--- PASS: TestStrictExpiredFutureReadDeadline (0.06s) +=== RUN TestConcurrentStrictReadDeadline +--- PASS: TestConcurrentStrictReadDeadline (0.00s) +=== RUN TestBuffConnReadTimeout +--- PASS: TestBuffConnReadTimeout (3.00s) +=== RUN TestBuffConnReadCheckTimeout +--- PASS: TestBuffConnReadCheckTimeout (0.50s) +PASS +ok github.com/minio/minio/internal/deadlineconn 5.069s +=== RUN TestCheckPortAvailability + check_port_test.go:31: +--- SKIP: TestCheckPortAvailability (0.00s) +=== RUN TestNewHTTPListener +--- PASS: TestNewHTTPListener (0.01s) +=== RUN TestHTTPListenerStartClose +--- PASS: TestHTTPListenerStartClose (0.00s) +=== RUN TestHTTPListenerAddr +--- PASS: TestHTTPListenerAddr (0.00s) +=== RUN TestHTTPListenerAddrs +--- PASS: TestHTTPListenerAddrs (0.00s) +=== RUN TestServerReadHeaderDeadline +=== RUN TestServerReadHeaderDeadline/tls=false/second=false/trickle=false +=== PAUSE TestServerReadHeaderDeadline/tls=false/second=false/trickle=false +=== RUN TestServerReadHeaderDeadline/tls=false/second=false/trickle=true +=== PAUSE TestServerReadHeaderDeadline/tls=false/second=false/trickle=true +=== RUN TestServerReadHeaderDeadline/tls=false/second=true/trickle=false +=== PAUSE TestServerReadHeaderDeadline/tls=false/second=true/trickle=false +=== RUN TestServerReadHeaderDeadline/tls=false/second=true/trickle=true +=== PAUSE TestServerReadHeaderDeadline/tls=false/second=true/trickle=true +=== RUN TestServerReadHeaderDeadline/tls=true/second=false/trickle=false +=== PAUSE TestServerReadHeaderDeadline/tls=true/second=false/trickle=false +=== RUN TestServerReadHeaderDeadline/tls=true/second=false/trickle=true +=== PAUSE TestServerReadHeaderDeadline/tls=true/second=false/trickle=true +=== RUN TestServerReadHeaderDeadline/tls=true/second=true/trickle=false +=== PAUSE TestServerReadHeaderDeadline/tls=true/second=true/trickle=false +=== RUN TestServerReadHeaderDeadline/tls=true/second=true/trickle=true +=== PAUSE TestServerReadHeaderDeadline/tls=true/second=true/trickle=true +=== CONT TestServerReadHeaderDeadline/tls=false/second=false/trickle=false +=== CONT TestServerReadHeaderDeadline/tls=true/second=false/trickle=false +=== CONT TestServerReadHeaderDeadline/tls=true/second=true/trickle=false +=== CONT TestServerReadHeaderDeadline/tls=false/second=true/trickle=true +=== CONT TestServerReadHeaderDeadline/tls=true/second=false/trickle=true +=== CONT TestServerReadHeaderDeadline/tls=false/second=false/trickle=true +=== CONT TestServerReadHeaderDeadline/tls=true/second=true/trickle=true +=== CONT TestServerReadHeaderDeadline/tls=false/second=true/trickle=false +--- PASS: TestServerReadHeaderDeadline (0.00s) + --- PASS: TestServerReadHeaderDeadline/tls=false/second=false/trickle=true (0.81s) + --- PASS: TestServerReadHeaderDeadline/tls=true/second=false/trickle=true (0.83s) + --- PASS: TestServerReadHeaderDeadline/tls=false/second=false/trickle=false (0.95s) + --- PASS: TestServerReadHeaderDeadline/tls=true/second=false/trickle=false (0.96s) + --- PASS: TestServerReadHeaderDeadline/tls=false/second=true/trickle=true (1.56s) + --- PASS: TestServerReadHeaderDeadline/tls=true/second=true/trickle=true (1.57s) + --- PASS: TestServerReadHeaderDeadline/tls=false/second=true/trickle=false (1.70s) + --- PASS: TestServerReadHeaderDeadline/tls=true/second=true/trickle=false (1.71s) +=== RUN TestServerKeepAliveDeadline +=== RUN TestServerKeepAliveDeadline/tls=false +=== PAUSE TestServerKeepAliveDeadline/tls=false +=== RUN TestServerKeepAliveDeadline/tls=true +=== PAUSE TestServerKeepAliveDeadline/tls=true +=== CONT TestServerKeepAliveDeadline/tls=true +=== CONT TestServerKeepAliveDeadline/tls=false +--- PASS: TestServerKeepAliveDeadline (0.00s) + --- PASS: TestServerKeepAliveDeadline/tls=false (1.50s) + --- PASS: TestServerKeepAliveDeadline/tls=true (1.51s) +=== RUN TestServerContinuousUpload +=== RUN TestServerContinuousUpload/tls=false/chunked=false/expect=false +=== PAUSE TestServerContinuousUpload/tls=false/chunked=false/expect=false +=== RUN TestServerContinuousUpload/tls=false/chunked=false/expect=true +=== PAUSE TestServerContinuousUpload/tls=false/chunked=false/expect=true +=== RUN TestServerContinuousUpload/tls=false/chunked=true/expect=false +=== PAUSE TestServerContinuousUpload/tls=false/chunked=true/expect=false +=== RUN TestServerContinuousUpload/tls=false/chunked=true/expect=true +=== PAUSE TestServerContinuousUpload/tls=false/chunked=true/expect=true +=== RUN TestServerContinuousUpload/tls=true/chunked=false/expect=false +=== PAUSE TestServerContinuousUpload/tls=true/chunked=false/expect=false +=== RUN TestServerContinuousUpload/tls=true/chunked=false/expect=true +=== PAUSE TestServerContinuousUpload/tls=true/chunked=false/expect=true +=== RUN TestServerContinuousUpload/tls=true/chunked=true/expect=false +=== PAUSE TestServerContinuousUpload/tls=true/chunked=true/expect=false +=== RUN TestServerContinuousUpload/tls=true/chunked=true/expect=true +=== PAUSE TestServerContinuousUpload/tls=true/chunked=true/expect=true +=== CONT TestServerContinuousUpload/tls=false/chunked=false/expect=false +=== CONT TestServerContinuousUpload/tls=false/chunked=true/expect=true +=== CONT TestServerContinuousUpload/tls=true/chunked=false/expect=true +=== CONT TestServerContinuousUpload/tls=false/chunked=true/expect=false +=== CONT TestServerContinuousUpload/tls=true/chunked=true/expect=true +=== CONT TestServerContinuousUpload/tls=true/chunked=true/expect=false +=== CONT TestServerContinuousUpload/tls=true/chunked=false/expect=false +=== CONT TestServerContinuousUpload/tls=false/chunked=false/expect=true +=== NAME TestServerContinuousUpload/tls=false/chunked=true/expect=false + server_deadline_test.go:259: continuous upload 1.293135s > idle 300ms +=== NAME TestServerContinuousUpload/tls=false/chunked=true/expect=true + server_deadline_test.go:259: continuous upload 1.286289708s > idle 300ms +=== NAME TestServerContinuousUpload/tls=false/chunked=false/expect=true + server_deadline_test.go:259: continuous upload 1.286156s > idle 300ms +=== NAME TestServerContinuousUpload/tls=false/chunked=false/expect=false + server_deadline_test.go:259: continuous upload 1.288825375s > idle 300ms +=== NAME TestServerContinuousUpload/tls=true/chunked=false/expect=false + server_deadline_test.go:259: continuous upload 1.293748959s > idle 300ms +=== NAME TestServerContinuousUpload/tls=true/chunked=true/expect=false + server_deadline_test.go:259: continuous upload 1.293017291s > idle 300ms +=== NAME TestServerContinuousUpload/tls=true/chunked=true/expect=true + server_deadline_test.go:259: continuous upload 1.293105875s > idle 300ms +=== NAME TestServerContinuousUpload/tls=true/chunked=false/expect=true + server_deadline_test.go:259: continuous upload 1.292306625s > idle 300ms +--- PASS: TestServerContinuousUpload (0.00s) + --- PASS: TestServerContinuousUpload/tls=false/chunked=true/expect=false (1.29s) + --- PASS: TestServerContinuousUpload/tls=false/chunked=true/expect=true (1.29s) + --- PASS: TestServerContinuousUpload/tls=false/chunked=false/expect=false (1.29s) + --- PASS: TestServerContinuousUpload/tls=false/chunked=false/expect=true (1.29s) + --- PASS: TestServerContinuousUpload/tls=true/chunked=true/expect=false (1.31s) + --- PASS: TestServerContinuousUpload/tls=true/chunked=false/expect=true (1.31s) + --- PASS: TestServerContinuousUpload/tls=true/chunked=false/expect=false (1.31s) + --- PASS: TestServerContinuousUpload/tls=true/chunked=true/expect=true (1.31s) +=== RUN TestServerDefaultIdleLongUpload + server_deadline_test.go:268: set SILO_TEST_LONG_UPLOAD=1 for >30s default-idle regression +--- SKIP: TestServerDefaultIdleLongUpload (0.00s) +=== RUN TestServerIdleBodyDeadline +=== RUN TestServerIdleBodyDeadline/tls=false +=== PAUSE TestServerIdleBodyDeadline/tls=false +=== RUN TestServerIdleBodyDeadline/tls=true +=== PAUSE TestServerIdleBodyDeadline/tls=true +=== CONT TestServerIdleBodyDeadline/tls=false +=== CONT TestServerIdleBodyDeadline/tls=true +--- PASS: TestServerIdleBodyDeadline (0.00s) + --- PASS: TestServerIdleBodyDeadline/tls=false (0.45s) + --- PASS: TestServerIdleBodyDeadline/tls=true (0.46s) +=== RUN TestServerBackgroundReadNoDeadline +=== RUN TestServerBackgroundReadNoDeadline/tls=false/body=false +=== PAUSE TestServerBackgroundReadNoDeadline/tls=false/body=false +=== RUN TestServerBackgroundReadNoDeadline/tls=false/body=true +=== PAUSE TestServerBackgroundReadNoDeadline/tls=false/body=true +=== RUN TestServerBackgroundReadNoDeadline/tls=true/body=false +=== PAUSE TestServerBackgroundReadNoDeadline/tls=true/body=false +=== RUN TestServerBackgroundReadNoDeadline/tls=true/body=true +=== PAUSE TestServerBackgroundReadNoDeadline/tls=true/body=true +=== CONT TestServerBackgroundReadNoDeadline/tls=false/body=false +=== CONT TestServerBackgroundReadNoDeadline/tls=true/body=false +=== CONT TestServerBackgroundReadNoDeadline/tls=false/body=true +=== CONT TestServerBackgroundReadNoDeadline/tls=true/body=true +--- PASS: TestServerBackgroundReadNoDeadline (0.00s) + --- PASS: TestServerBackgroundReadNoDeadline/tls=false/body=false (0.70s) + --- PASS: TestServerBackgroundReadNoDeadline/tls=false/body=true (0.70s) + --- PASS: TestServerBackgroundReadNoDeadline/tls=true/body=false (0.71s) + --- PASS: TestServerBackgroundReadNoDeadline/tls=true/body=true (0.71s) +=== RUN TestServerTLSHandshakeReadDeadline +2026/09/15 23:53:59 http: TLS handshake error from 127.0.0.1:55169: read tcp 127.0.0.1:55168->127.0.0.1:55169: i/o timeout +--- PASS: TestServerTLSHandshakeReadDeadline (0.20s) +=== RUN TestServerConnStateHook +=== RUN TestServerConnStateHook/tls=false +=== RUN TestServerConnStateHook/tls=true +--- PASS: TestServerConnStateHook (0.01s) + --- PASS: TestServerConnStateHook/tls=false (0.00s) + --- PASS: TestServerConnStateHook/tls=true (0.01s) +=== RUN TestServerHTTP2Deadlines +--- PASS: TestServerHTTP2Deadlines (0.41s) +=== RUN TestServerEarlyBodyClose +=== RUN TestServerEarlyBodyClose/tls=false +=== PAUSE TestServerEarlyBodyClose/tls=false +=== RUN TestServerEarlyBodyClose/tls=true +=== PAUSE TestServerEarlyBodyClose/tls=true +=== CONT TestServerEarlyBodyClose/tls=true +=== CONT TestServerEarlyBodyClose/tls=false +--- PASS: TestServerEarlyBodyClose (0.00s) + --- PASS: TestServerEarlyBodyClose/tls=false (0.30s) + --- PASS: TestServerEarlyBodyClose/tls=true (0.31s) +=== RUN TestServerPipelinedDeadline +=== RUN TestServerPipelinedDeadline/tls=false +=== RUN TestServerPipelinedDeadline/tls=true +--- PASS: TestServerPipelinedDeadline (1.31s) + --- PASS: TestServerPipelinedDeadline/tls=false (0.65s) + --- PASS: TestServerPipelinedDeadline/tls=true (0.66s) +=== RUN TestServerHijackedDeadline +=== RUN TestServerHijackedDeadline/tls=false +=== PAUSE TestServerHijackedDeadline/tls=false +=== RUN TestServerHijackedDeadline/tls=true +=== PAUSE TestServerHijackedDeadline/tls=true +=== CONT TestServerHijackedDeadline/tls=false +=== CONT TestServerHijackedDeadline/tls=true +--- PASS: TestServerHijackedDeadline (0.00s) + --- PASS: TestServerHijackedDeadline/tls=false (0.70s) + --- PASS: TestServerHijackedDeadline/tls=true (0.71s) +=== RUN TestServerContinuousDownload +=== RUN TestServerContinuousDownload/tls=false +=== PAUSE TestServerContinuousDownload/tls=false +=== RUN TestServerContinuousDownload/tls=true +=== PAUSE TestServerContinuousDownload/tls=true +=== CONT TestServerContinuousDownload/tls=true +=== CONT TestServerContinuousDownload/tls=false + server_deadline_test.go:624: continuous download 1.297514833s > idle 300ms +=== NAME TestServerContinuousDownload/tls=true + server_deadline_test.go:624: continuous download 1.29253225s > idle 300ms +--- PASS: TestServerContinuousDownload (0.00s) + --- PASS: TestServerContinuousDownload/tls=false (1.30s) + --- PASS: TestServerContinuousDownload/tls=true (1.30s) +=== RUN TestServerDefaultIdleLongDownload + server_deadline_test.go:631: set SILO_TEST_LONG_UPLOAD=1 for >30s default-idle transfer regressions +--- SKIP: TestServerDefaultIdleLongDownload (0.00s) +=== RUN TestNewServer +--- PASS: TestNewServer (0.00s) +PASS +ok github.com/minio/minio/internal/http 11.525s diff --git a/docs/investigations/r8/evidence/default-30s-transfers.log b/docs/investigations/r8/evidence/default-30s-transfers.log new file mode 100644 index 000000000..c1d6295ae --- /dev/null +++ b/docs/investigations/r8/evidence/default-30s-transfers.log @@ -0,0 +1,34 @@ +=== RUN TestServerDefaultIdleLongUpload +=== PAUSE TestServerDefaultIdleLongUpload +=== RUN TestServerDefaultIdleLongDownload +=== PAUSE TestServerDefaultIdleLongDownload +=== CONT TestServerDefaultIdleLongUpload +=== RUN TestServerDefaultIdleLongUpload/tls=false +=== PAUSE TestServerDefaultIdleLongUpload/tls=false +=== RUN TestServerDefaultIdleLongUpload/tls=true +=== PAUSE TestServerDefaultIdleLongUpload/tls=true +=== CONT TestServerDefaultIdleLongUpload/tls=false +=== CONT TestServerDefaultIdleLongDownload +=== RUN TestServerDefaultIdleLongDownload/tls=false +=== PAUSE TestServerDefaultIdleLongDownload/tls=false +=== RUN TestServerDefaultIdleLongDownload/tls=true +=== PAUSE TestServerDefaultIdleLongDownload/tls=true +=== CONT TestServerDefaultIdleLongDownload/tls=false +=== CONT TestServerDefaultIdleLongDownload/tls=true +=== CONT TestServerDefaultIdleLongUpload/tls=true +=== NAME TestServerDefaultIdleLongDownload/tls=true + server_deadline_test.go:601: continuous download 33.028469208s > idle 30s +=== NAME TestServerDefaultIdleLongDownload/tls=false + server_deadline_test.go:601: continuous download 33.033171416s > idle 30s +=== NAME TestServerDefaultIdleLongUpload/tls=true + server_deadline_test.go:274: continuous upload 33.028635792s > idle 30s +=== NAME TestServerDefaultIdleLongUpload/tls=false + server_deadline_test.go:274: continuous upload 33.033198583s > idle 30s +--- PASS: TestServerDefaultIdleLongDownload (0.00s) + --- PASS: TestServerDefaultIdleLongDownload/tls=true (33.03s) + --- PASS: TestServerDefaultIdleLongDownload/tls=false (33.03s) +--- PASS: TestServerDefaultIdleLongUpload (0.00s) + --- PASS: TestServerDefaultIdleLongUpload/tls=false (33.03s) + --- PASS: TestServerDefaultIdleLongUpload/tls=true (33.03s) +PASS +ok github.com/minio/minio/internal/http 34.193s diff --git a/docs/investigations/r8/evidence/delivery-whitespace.log b/docs/investigations/r8/evidence/delivery-whitespace.log new file mode 100644 index 000000000..6f28be029 --- /dev/null +++ b/docs/investigations/r8/evidence/delivery-whitespace.log @@ -0,0 +1,3 @@ +$ git diff --check 9ebe81c1b3611f9cc73e676b5b741c2be62c467a -- + +exit_code=0 diff --git a/docs/investigations/r8/evidence/grid.log b/docs/investigations/r8/evidence/grid.log new file mode 100644 index 000000000..46e5f7d0e --- /dev/null +++ b/docs/investigations/r8/evidence/grid.log @@ -0,0 +1,39 @@ +=== RUN TestDisconnect + connection_test.go:90: Started server on 127.0.0.1:54773 URL: http://127.0.0.1:54773 + connection_test.go:91: Started server on 127.0.0.1:54774 URL: http://127.0.0.1:54774 + connection_test.go:46: Got a GET request for: /minio/grid/v1 + connection_test.go:140: Roundtrip: sending request + connection_test.go:142: Roundtrip: 246.625µs [] remote disconnected + connection_test.go:46: Got a GET request for: /minio/grid/v1 + connection_test.go:157: Resp: {[] remote disconnected} + connection_test.go:46: Got a GET request for: /minio/grid/v1 +--- PASS: TestDisconnect (0.01s) +=== RUN TestSingleRoundtrip +=== RUN TestSingleRoundtrip/localToRemote +=== NAME TestSingleRoundtrip + grid_test.go:66: 1: server payload: 17 bytes. +=== NAME TestSingleRoundtrip/localToRemote + grid_test.go:90: Roundtrip: 141.625µs +=== RUN TestSingleRoundtrip/localToRemoteErr +=== NAME TestSingleRoundtrip + grid_test.go:71: 2: server payload: 17 bytes. +=== NAME TestSingleRoundtrip/localToRemoteErr + grid_test.go:97: Roundtrip: 187.625µs + grid_test.go:104: Roundtrip: 195.958µs +=== RUN TestSingleRoundtrip/localToRemoteHuge +=== NAME TestSingleRoundtrip + grid_test.go:66: 1: server payload: 1048576 bytes. +=== NAME TestSingleRoundtrip/localToRemoteHuge + grid_test.go:116: Roundtrip: 3.062792ms +=== RUN TestSingleRoundtrip/localToRemoteErrHuge +=== NAME TestSingleRoundtrip + grid_test.go:71: 2: server payload: 1024 bytes. +=== NAME TestSingleRoundtrip/localToRemoteErrHuge + grid_test.go:130: Roundtrip: 232.958µs +--- PASS: TestSingleRoundtrip (0.00s) + --- PASS: TestSingleRoundtrip/localToRemote (0.00s) + --- PASS: TestSingleRoundtrip/localToRemoteErr (0.00s) + --- PASS: TestSingleRoundtrip/localToRemoteHuge (0.00s) + --- PASS: TestSingleRoundtrip/localToRemoteErrHuge (0.00s) +PASS +ok github.com/minio/minio/internal/grid 0.622s diff --git a/docs/investigations/r8/evidence/implementation-focused.log b/docs/investigations/r8/evidence/implementation-focused.log new file mode 100644 index 000000000..6cd38adcd --- /dev/null +++ b/docs/investigations/r8/evidence/implementation-focused.log @@ -0,0 +1,195 @@ +=== RUN TestStrictReadDeadline +=== RUN TestStrictReadDeadline/SetReadDeadline +=== RUN TestStrictReadDeadline/SetDeadline +--- PASS: TestStrictReadDeadline (0.00s) + --- PASS: TestStrictReadDeadline/SetReadDeadline (0.00s) + --- PASS: TestStrictReadDeadline/SetDeadline (0.00s) +=== RUN TestDefaultReadDeadlineStillRenews +--- PASS: TestDefaultReadDeadlineStillRenews (0.00s) +=== RUN TestStrictExpiredFutureReadDeadline +--- PASS: TestStrictExpiredFutureReadDeadline (0.06s) +=== RUN TestConcurrentStrictReadDeadline +--- PASS: TestConcurrentStrictReadDeadline (0.00s) +PASS +ok github.com/minio/minio/internal/deadlineconn 0.652s +=== RUN TestServerReadHeaderDeadline +=== RUN TestServerReadHeaderDeadline/tls=false/second=false/trickle=false +=== PAUSE TestServerReadHeaderDeadline/tls=false/second=false/trickle=false +=== RUN TestServerReadHeaderDeadline/tls=false/second=false/trickle=true +=== PAUSE TestServerReadHeaderDeadline/tls=false/second=false/trickle=true +=== RUN TestServerReadHeaderDeadline/tls=false/second=true/trickle=false +=== PAUSE TestServerReadHeaderDeadline/tls=false/second=true/trickle=false +=== RUN TestServerReadHeaderDeadline/tls=false/second=true/trickle=true +=== PAUSE TestServerReadHeaderDeadline/tls=false/second=true/trickle=true +=== RUN TestServerReadHeaderDeadline/tls=true/second=false/trickle=false +=== PAUSE TestServerReadHeaderDeadline/tls=true/second=false/trickle=false +=== RUN TestServerReadHeaderDeadline/tls=true/second=false/trickle=true +=== PAUSE TestServerReadHeaderDeadline/tls=true/second=false/trickle=true +=== RUN TestServerReadHeaderDeadline/tls=true/second=true/trickle=false +=== PAUSE TestServerReadHeaderDeadline/tls=true/second=true/trickle=false +=== RUN TestServerReadHeaderDeadline/tls=true/second=true/trickle=true +=== PAUSE TestServerReadHeaderDeadline/tls=true/second=true/trickle=true +=== CONT TestServerReadHeaderDeadline/tls=false/second=false/trickle=false +=== CONT TestServerReadHeaderDeadline/tls=true/second=false/trickle=false +=== CONT TestServerReadHeaderDeadline/tls=true/second=true/trickle=false +=== CONT TestServerReadHeaderDeadline/tls=false/second=true/trickle=false +=== CONT TestServerReadHeaderDeadline/tls=false/second=false/trickle=true +=== CONT TestServerReadHeaderDeadline/tls=true/second=false/trickle=true +=== CONT TestServerReadHeaderDeadline/tls=false/second=true/trickle=true +=== CONT TestServerReadHeaderDeadline/tls=true/second=true/trickle=true +--- PASS: TestServerReadHeaderDeadline (0.00s) + --- PASS: TestServerReadHeaderDeadline/tls=false/second=false/trickle=true (0.40s) + --- PASS: TestServerReadHeaderDeadline/tls=true/second=false/trickle=true (0.41s) + --- PASS: TestServerReadHeaderDeadline/tls=false/second=false/trickle=false (0.60s) + --- PASS: TestServerReadHeaderDeadline/tls=true/second=false/trickle=false (0.61s) + --- PASS: TestServerReadHeaderDeadline/tls=false/second=true/trickle=true (0.80s) + --- PASS: TestServerReadHeaderDeadline/tls=true/second=true/trickle=true (0.81s) + --- PASS: TestServerReadHeaderDeadline/tls=false/second=true/trickle=false (1.00s) + --- PASS: TestServerReadHeaderDeadline/tls=true/second=true/trickle=false (1.01s) +=== RUN TestServerKeepAliveDeadline +=== RUN TestServerKeepAliveDeadline/tls=false +=== PAUSE TestServerKeepAliveDeadline/tls=false +=== RUN TestServerKeepAliveDeadline/tls=true +=== PAUSE TestServerKeepAliveDeadline/tls=true +=== CONT TestServerKeepAliveDeadline/tls=false +=== CONT TestServerKeepAliveDeadline/tls=true +--- PASS: TestServerKeepAliveDeadline (0.00s) + --- PASS: TestServerKeepAliveDeadline/tls=true (1.50s) + --- PASS: TestServerKeepAliveDeadline/tls=false (1.50s) +=== RUN TestServerContinuousUpload +=== RUN TestServerContinuousUpload/tls=false/chunked=false/expect=false +=== PAUSE TestServerContinuousUpload/tls=false/chunked=false/expect=false +=== RUN TestServerContinuousUpload/tls=false/chunked=false/expect=true +=== PAUSE TestServerContinuousUpload/tls=false/chunked=false/expect=true +=== RUN TestServerContinuousUpload/tls=false/chunked=true/expect=false +=== PAUSE TestServerContinuousUpload/tls=false/chunked=true/expect=false +=== RUN TestServerContinuousUpload/tls=false/chunked=true/expect=true +=== PAUSE TestServerContinuousUpload/tls=false/chunked=true/expect=true +=== RUN TestServerContinuousUpload/tls=true/chunked=false/expect=false +=== PAUSE TestServerContinuousUpload/tls=true/chunked=false/expect=false +=== RUN TestServerContinuousUpload/tls=true/chunked=false/expect=true +=== PAUSE TestServerContinuousUpload/tls=true/chunked=false/expect=true +=== RUN TestServerContinuousUpload/tls=true/chunked=true/expect=false +=== PAUSE TestServerContinuousUpload/tls=true/chunked=true/expect=false +=== RUN TestServerContinuousUpload/tls=true/chunked=true/expect=true +=== PAUSE TestServerContinuousUpload/tls=true/chunked=true/expect=true +=== CONT TestServerContinuousUpload/tls=false/chunked=false/expect=false +=== CONT TestServerContinuousUpload/tls=true/chunked=false/expect=false +=== CONT TestServerContinuousUpload/tls=false/chunked=true/expect=false +=== CONT TestServerContinuousUpload/tls=false/chunked=false/expect=true +=== CONT TestServerContinuousUpload/tls=true/chunked=true/expect=false +=== CONT TestServerContinuousUpload/tls=true/chunked=true/expect=true +=== CONT TestServerContinuousUpload/tls=true/chunked=false/expect=true +=== CONT TestServerContinuousUpload/tls=false/chunked=true/expect=true +=== NAME TestServerContinuousUpload/tls=false/chunked=false/expect=true + server_deadline_test.go:259: continuous upload 1.296547458s > idle 300ms +=== NAME TestServerContinuousUpload/tls=false/chunked=true/expect=false + server_deadline_test.go:259: continuous upload 1.296906542s > idle 300ms +=== NAME TestServerContinuousUpload/tls=true/chunked=true/expect=false + server_deadline_test.go:259: continuous upload 1.293365791s > idle 300ms +=== NAME TestServerContinuousUpload/tls=true/chunked=true/expect=true + server_deadline_test.go:259: continuous upload 1.293554459s > idle 300ms +=== NAME TestServerContinuousUpload/tls=false/chunked=false/expect=false + server_deadline_test.go:259: continuous upload 1.296912417s > idle 300ms +=== NAME TestServerContinuousUpload/tls=true/chunked=false/expect=true + server_deadline_test.go:259: continuous upload 1.293226958s > idle 300ms +=== NAME TestServerContinuousUpload/tls=false/chunked=true/expect=true + server_deadline_test.go:259: continuous upload 1.295960125s > idle 300ms +=== NAME TestServerContinuousUpload/tls=true/chunked=false/expect=false + server_deadline_test.go:259: continuous upload 1.29373425s > idle 300ms +--- PASS: TestServerContinuousUpload (0.00s) + --- PASS: TestServerContinuousUpload/tls=true/chunked=true/expect=false (1.30s) + --- PASS: TestServerContinuousUpload/tls=false/chunked=true/expect=true (1.30s) + --- PASS: TestServerContinuousUpload/tls=true/chunked=false/expect=false (1.30s) + --- PASS: TestServerContinuousUpload/tls=false/chunked=false/expect=false (1.30s) + --- PASS: TestServerContinuousUpload/tls=false/chunked=true/expect=false (1.30s) + --- PASS: TestServerContinuousUpload/tls=false/chunked=false/expect=true (1.30s) + --- PASS: TestServerContinuousUpload/tls=true/chunked=false/expect=true (1.30s) + --- PASS: TestServerContinuousUpload/tls=true/chunked=true/expect=true (1.30s) +=== RUN TestServerDefaultIdleLongUpload + server_deadline_test.go:268: set SILO_TEST_LONG_UPLOAD=1 for >30s default-idle regression +--- SKIP: TestServerDefaultIdleLongUpload (0.00s) +=== RUN TestServerIdleBodyDeadline +=== RUN TestServerIdleBodyDeadline/tls=false +=== PAUSE TestServerIdleBodyDeadline/tls=false +=== RUN TestServerIdleBodyDeadline/tls=true +=== PAUSE TestServerIdleBodyDeadline/tls=true +=== CONT TestServerIdleBodyDeadline/tls=false +=== CONT TestServerIdleBodyDeadline/tls=true +--- PASS: TestServerIdleBodyDeadline (0.00s) + --- PASS: TestServerIdleBodyDeadline/tls=false (0.45s) + --- PASS: TestServerIdleBodyDeadline/tls=true (0.45s) +=== RUN TestServerBackgroundReadNoDeadline +=== RUN TestServerBackgroundReadNoDeadline/tls=false/body=false +=== PAUSE TestServerBackgroundReadNoDeadline/tls=false/body=false +=== RUN TestServerBackgroundReadNoDeadline/tls=false/body=true +=== PAUSE TestServerBackgroundReadNoDeadline/tls=false/body=true +=== RUN TestServerBackgroundReadNoDeadline/tls=true/body=false +=== PAUSE TestServerBackgroundReadNoDeadline/tls=true/body=false +=== RUN TestServerBackgroundReadNoDeadline/tls=true/body=true +=== PAUSE TestServerBackgroundReadNoDeadline/tls=true/body=true +=== CONT TestServerBackgroundReadNoDeadline/tls=false/body=false +=== CONT TestServerBackgroundReadNoDeadline/tls=true/body=false +=== CONT TestServerBackgroundReadNoDeadline/tls=false/body=true +=== CONT TestServerBackgroundReadNoDeadline/tls=true/body=true +--- PASS: TestServerBackgroundReadNoDeadline (0.00s) + --- PASS: TestServerBackgroundReadNoDeadline/tls=false/body=true (0.70s) + --- PASS: TestServerBackgroundReadNoDeadline/tls=false/body=false (0.70s) + --- PASS: TestServerBackgroundReadNoDeadline/tls=true/body=true (0.70s) + --- PASS: TestServerBackgroundReadNoDeadline/tls=true/body=false (0.70s) +=== RUN TestServerTLSHandshakeReadDeadline +2026/09/15 23:51:45 http: TLS handshake error from 127.0.0.1:54459: read tcp 127.0.0.1:54458->127.0.0.1:54459: i/o timeout +--- PASS: TestServerTLSHandshakeReadDeadline (0.20s) +=== RUN TestServerConnStateHook +=== RUN TestServerConnStateHook/tls=false +=== RUN TestServerConnStateHook/tls=true +--- PASS: TestServerConnStateHook (0.00s) + --- PASS: TestServerConnStateHook/tls=false (0.00s) + --- PASS: TestServerConnStateHook/tls=true (0.00s) +=== RUN TestServerHTTP2Deadlines +--- PASS: TestServerHTTP2Deadlines (0.40s) +=== RUN TestServerEarlyBodyClose +=== RUN TestServerEarlyBodyClose/tls=false +=== PAUSE TestServerEarlyBodyClose/tls=false +=== RUN TestServerEarlyBodyClose/tls=true +=== PAUSE TestServerEarlyBodyClose/tls=true +=== CONT TestServerEarlyBodyClose/tls=false +=== CONT TestServerEarlyBodyClose/tls=true +--- PASS: TestServerEarlyBodyClose (0.00s) + --- PASS: TestServerEarlyBodyClose/tls=false (0.30s) + --- PASS: TestServerEarlyBodyClose/tls=true (0.30s) +=== RUN TestServerPipelinedDeadline +=== RUN TestServerPipelinedDeadline/tls=false +=== RUN TestServerPipelinedDeadline/tls=true +--- PASS: TestServerPipelinedDeadline (1.30s) + --- PASS: TestServerPipelinedDeadline/tls=false (0.65s) + --- PASS: TestServerPipelinedDeadline/tls=true (0.65s) +=== RUN TestServerHijackedDeadline +=== RUN TestServerHijackedDeadline/tls=false +=== PAUSE TestServerHijackedDeadline/tls=false +=== RUN TestServerHijackedDeadline/tls=true +=== PAUSE TestServerHijackedDeadline/tls=true +=== CONT TestServerHijackedDeadline/tls=false +=== CONT TestServerHijackedDeadline/tls=true +--- PASS: TestServerHijackedDeadline (0.00s) + --- PASS: TestServerHijackedDeadline/tls=false (0.70s) + --- PASS: TestServerHijackedDeadline/tls=true (0.70s) +=== RUN TestServerContinuousDownload +=== RUN TestServerContinuousDownload/tls=false +=== PAUSE TestServerContinuousDownload/tls=false +=== RUN TestServerContinuousDownload/tls=true +=== PAUSE TestServerContinuousDownload/tls=true +=== CONT TestServerContinuousDownload/tls=false +=== CONT TestServerContinuousDownload/tls=true +=== NAME TestServerContinuousDownload/tls=false + server_deadline_test.go:587: continuous download 1.296415709s > idle 300ms +=== NAME TestServerContinuousDownload/tls=true + server_deadline_test.go:587: continuous download 1.294823375s > idle 300ms +--- PASS: TestServerContinuousDownload (0.00s) + --- PASS: TestServerContinuousDownload/tls=false (1.30s) + --- PASS: TestServerContinuousDownload/tls=true (1.30s) +=== RUN TestServerDefaultIdleLongDownload + server_deadline_test.go:594: set SILO_TEST_LONG_UPLOAD=1 for >30s default-idle transfer regressions +--- SKIP: TestServerDefaultIdleLongDownload (0.00s) +PASS +ok github.com/minio/minio/internal/http 9.965s diff --git a/docs/investigations/r8/evidence/legacy-timeout-isolation.patch b/docs/investigations/r8/evidence/legacy-timeout-isolation.patch new file mode 100644 index 000000000..398c3e6c5 --- /dev/null +++ b/docs/investigations/r8/evidence/legacy-timeout-isolation.patch @@ -0,0 +1,31 @@ +--- buildscripts/test-timeout.sh ++++ temporary/test-timeout-isolated.sh +@@ -31,8 +31,11 @@ + fi + + echo "Cleaning up instances of Silo" +- pkill silo || true +- pkill -9 silo || true ++ if [ -n "${pid:-}" ]; then ++ kill "$pid" 2>/dev/null || true ++ wait "$pid" 2>/dev/null || true ++ fi ++ if [ -f "$WORK_DIR/server1.log" ]; then cp "$WORK_DIR/server1.log" "$PWD/timeout-server.log"; fi + purge "$WORK_DIR" + if [ $# -ne 0 ]; then + exit $# +@@ -83,7 +86,7 @@ + "$(git rev-parse --show-toplevel)/buildscripts/install-mcli.sh" "$PWD/mc" + fi + +- "${SILO[@]}" --address ":$start_port" --read-header-timeout ${srv_hdr_timeout}s --idle-timeout ${srv_idle_timeout}s "${WORK_DIR}/disk/" >"${WORK_DIR}/server1.log" 2>&1 & ++ "${SILO[@]}" --address "127.0.0.1:$start_port" --read-header-timeout ${srv_hdr_timeout}s --idle-timeout ${srv_idle_timeout}s "${WORK_DIR}/disk/" >"${WORK_DIR}/server1.log" 2>&1 & + pid=$! + disown $pid + sleep 1 +@@ -124,3 +127,5 @@ + } + + main "$@" ++ ++catch diff --git a/docs/investigations/r8/evidence/legacy-timeout.log b/docs/investigations/r8/evidence/legacy-timeout.log new file mode 100644 index 000000000..4ce8aaa8f --- /dev/null +++ b/docs/investigations/r8/evidence/legacy-timeout.log @@ -0,0 +1,6 @@ +Cleaning up instances of Silo +Bucket created successfully `silo/testbucket`. +Access permission for `silo/testbucket` is set to `public` +mcli: Unable to stat `silo/testbucket/testobject`. Object does not exist. +mcli: Unable to stat `silo/testbucket/testobject`. Object does not exist. +mcli: Unable to stat `silo/testbucket/testobject`. Object does not exist. diff --git a/docs/investigations/r8/evidence/legacy-timeout.metadata.json b/docs/investigations/r8/evidence/legacy-timeout.metadata.json new file mode 100644 index 000000000..8449bd601 --- /dev/null +++ b/docs/investigations/r8/evidence/legacy-timeout.metadata.json @@ -0,0 +1,14 @@ +{ + "original_script": "buildscripts/test-timeout.sh", + "isolation_patch": "legacy-timeout-isolation.patch", + "working_directory": "/Users/vonng/tmp/silo-r8-01a0a5b9/legacy-timeout", + "binary_sha256": "6b982de3262c25e326280c739b4275444cf80c3eacae48f0942aa18fbb7cd654", + "client": "/opt/homebrew/bin/mcli RELEASE.2026-08-26T17-15-27Z (70a2950478e18e38eb68162317e4b5d34c6eb6d5)", + "netcat": "/usr/bin/nc via private netcat symlink", + "command": [ + "/bin/bash", + "/Users/vonng/tmp/silo-r8-01a0a5b9/legacy-timeout/test-timeout-isolated.sh" + ], + "exit_code": 255, + "elapsed_seconds": 67.029 +} diff --git a/docs/investigations/r8/evidence/lint-final.log b/docs/investigations/r8/evidence/lint-final.log new file mode 100644 index 000000000..6a3ebaa7e --- /dev/null +++ b/docs/investigations/r8/evidence/lint-final.log @@ -0,0 +1 @@ +0 issues. diff --git a/docs/investigations/r8/evidence/lint-initial-lock.log b/docs/investigations/r8/evidence/lint-initial-lock.log new file mode 100644 index 000000000..fd1117a98 --- /dev/null +++ b/docs/investigations/r8/evidence/lint-initial-lock.log @@ -0,0 +1,2 @@ +Error: parallel golangci-lint is running +The command is terminated due to an error: parallel golangci-lint is running diff --git a/docs/investigations/r8/evidence/lint.log b/docs/investigations/r8/evidence/lint.log new file mode 100644 index 000000000..db69c561d --- /dev/null +++ b/docs/investigations/r8/evidence/lint.log @@ -0,0 +1,5 @@ +internal/http/server_deadline_test.go:61:7: QF1008: could remove embedded field "Server" from selector (staticcheck) + srv.Server.Close() + ^ +1 issues: +* staticcheck: 1 diff --git a/docs/investigations/r8/evidence/linux-race-final.log b/docs/investigations/r8/evidence/linux-race-final.log new file mode 100644 index 000000000..43f623734 --- /dev/null +++ b/docs/investigations/r8/evidence/linux-race-final.log @@ -0,0 +1,214 @@ +=== RUN TestStrictReadDeadline +=== RUN TestStrictReadDeadline/SetReadDeadline +=== RUN TestStrictReadDeadline/SetDeadline +--- PASS: TestStrictReadDeadline (0.00s) + --- PASS: TestStrictReadDeadline/SetReadDeadline (0.00s) + --- PASS: TestStrictReadDeadline/SetDeadline (0.00s) +=== RUN TestDefaultReadDeadlineStillRenews +--- PASS: TestDefaultReadDeadlineStillRenews (0.00s) +=== RUN TestStrictExpiredFutureReadDeadline +--- PASS: TestStrictExpiredFutureReadDeadline (0.06s) +=== RUN TestConcurrentStrictReadDeadline +--- PASS: TestConcurrentStrictReadDeadline (0.00s) +=== RUN TestStrictReadDeadlineRepeatedRenewal +--- PASS: TestStrictReadDeadlineRepeatedRenewal (0.91s) +=== RUN TestBuffConnReadTimeout +--- PASS: TestBuffConnReadTimeout (3.01s) +=== RUN TestBuffConnReadCheckTimeout +--- PASS: TestBuffConnReadCheckTimeout (0.50s) +PASS +ok github.com/minio/minio/internal/deadlineconn 5.490s +=== RUN TestCheckPortAvailability +--- PASS: TestCheckPortAvailability (0.00s) +=== RUN TestInternodeDialReadDeadline +--- PASS: TestInternodeDialReadDeadline (1.31s) +=== RUN TestNewHTTPListener +--- PASS: TestNewHTTPListener (0.01s) +=== RUN TestHTTPListenerStartClose +--- PASS: TestHTTPListenerStartClose (0.00s) +=== RUN TestHTTPListenerAddr +--- PASS: TestHTTPListenerAddr (0.00s) +=== RUN TestHTTPListenerAddrs +--- PASS: TestHTTPListenerAddrs (0.00s) +=== RUN TestServerReadHeaderDeadline +=== RUN TestServerReadHeaderDeadline/tls=false/second=false/trickle=false +=== PAUSE TestServerReadHeaderDeadline/tls=false/second=false/trickle=false +=== RUN TestServerReadHeaderDeadline/tls=false/second=false/trickle=true +=== PAUSE TestServerReadHeaderDeadline/tls=false/second=false/trickle=true +=== RUN TestServerReadHeaderDeadline/tls=false/second=true/trickle=false +=== PAUSE TestServerReadHeaderDeadline/tls=false/second=true/trickle=false +=== RUN TestServerReadHeaderDeadline/tls=false/second=true/trickle=true +=== PAUSE TestServerReadHeaderDeadline/tls=false/second=true/trickle=true +=== RUN TestServerReadHeaderDeadline/tls=true/second=false/trickle=false +=== PAUSE TestServerReadHeaderDeadline/tls=true/second=false/trickle=false +=== RUN TestServerReadHeaderDeadline/tls=true/second=false/trickle=true +=== PAUSE TestServerReadHeaderDeadline/tls=true/second=false/trickle=true +=== RUN TestServerReadHeaderDeadline/tls=true/second=true/trickle=false +=== PAUSE TestServerReadHeaderDeadline/tls=true/second=true/trickle=false +=== RUN TestServerReadHeaderDeadline/tls=true/second=true/trickle=true +=== PAUSE TestServerReadHeaderDeadline/tls=true/second=true/trickle=true +=== CONT TestServerReadHeaderDeadline/tls=false/second=true/trickle=true +=== CONT TestServerReadHeaderDeadline/tls=true/second=false/trickle=false +=== CONT TestServerReadHeaderDeadline/tls=false/second=false/trickle=true +=== CONT TestServerReadHeaderDeadline/tls=false/second=true/trickle=false +=== CONT TestServerReadHeaderDeadline/tls=false/second=false/trickle=false +=== CONT TestServerReadHeaderDeadline/tls=true/second=true/trickle=false +=== CONT TestServerReadHeaderDeadline/tls=true/second=true/trickle=true +=== CONT TestServerReadHeaderDeadline/tls=true/second=false/trickle=true +--- PASS: TestServerReadHeaderDeadline (0.00s) + --- PASS: TestServerReadHeaderDeadline/tls=false/second=false/trickle=true (0.82s) + --- PASS: TestServerReadHeaderDeadline/tls=true/second=false/trickle=false (0.96s) + --- PASS: TestServerReadHeaderDeadline/tls=false/second=true/trickle=true (1.58s) + --- PASS: TestServerReadHeaderDeadline/tls=false/second=true/trickle=false (1.71s) + --- PASS: TestServerReadHeaderDeadline/tls=false/second=false/trickle=false (0.95s) + --- PASS: TestServerReadHeaderDeadline/tls=true/second=false/trickle=true (0.83s) + --- PASS: TestServerReadHeaderDeadline/tls=true/second=true/trickle=false (1.72s) + --- PASS: TestServerReadHeaderDeadline/tls=true/second=true/trickle=true (1.59s) +=== RUN TestServerKeepAliveDeadline +=== RUN TestServerKeepAliveDeadline/tls=false +=== PAUSE TestServerKeepAliveDeadline/tls=false +=== RUN TestServerKeepAliveDeadline/tls=true +=== PAUSE TestServerKeepAliveDeadline/tls=true +=== CONT TestServerKeepAliveDeadline/tls=true +=== CONT TestServerKeepAliveDeadline/tls=false +--- PASS: TestServerKeepAliveDeadline (0.00s) + --- PASS: TestServerKeepAliveDeadline/tls=false (1.52s) + --- PASS: TestServerKeepAliveDeadline/tls=true (1.52s) +=== RUN TestServerContinuousUpload +=== RUN TestServerContinuousUpload/tls=false/chunked=false/expect=false +=== PAUSE TestServerContinuousUpload/tls=false/chunked=false/expect=false +=== RUN TestServerContinuousUpload/tls=false/chunked=false/expect=true +=== PAUSE TestServerContinuousUpload/tls=false/chunked=false/expect=true +=== RUN TestServerContinuousUpload/tls=false/chunked=true/expect=false +=== PAUSE TestServerContinuousUpload/tls=false/chunked=true/expect=false +=== RUN TestServerContinuousUpload/tls=false/chunked=true/expect=true +=== PAUSE TestServerContinuousUpload/tls=false/chunked=true/expect=true +=== RUN TestServerContinuousUpload/tls=true/chunked=false/expect=false +=== PAUSE TestServerContinuousUpload/tls=true/chunked=false/expect=false +=== RUN TestServerContinuousUpload/tls=true/chunked=false/expect=true +=== PAUSE TestServerContinuousUpload/tls=true/chunked=false/expect=true +=== RUN TestServerContinuousUpload/tls=true/chunked=true/expect=false +=== PAUSE TestServerContinuousUpload/tls=true/chunked=true/expect=false +=== RUN TestServerContinuousUpload/tls=true/chunked=true/expect=true +=== PAUSE TestServerContinuousUpload/tls=true/chunked=true/expect=true +=== CONT TestServerContinuousUpload/tls=false/chunked=false/expect=false +=== CONT TestServerContinuousUpload/tls=false/chunked=true/expect=true +=== CONT TestServerContinuousUpload/tls=true/chunked=true/expect=true +=== CONT TestServerContinuousUpload/tls=true/chunked=false/expect=true + server_deadline_test.go:261: continuous upload 1.320011834s > idle 300ms +=== NAME TestServerContinuousUpload/tls=false/chunked=true/expect=true + server_deadline_test.go:261: continuous upload 1.33052725s > idle 300ms +=== NAME TestServerContinuousUpload/tls=false/chunked=false/expect=false + server_deadline_test.go:261: continuous upload 1.330741292s > idle 300ms +=== CONT TestServerContinuousUpload/tls=false/chunked=true/expect=false +=== CONT TestServerContinuousUpload/tls=false/chunked=false/expect=true +=== CONT TestServerContinuousUpload/tls=true/chunked=false/expect=false +=== NAME TestServerContinuousUpload/tls=true/chunked=true/expect=true + server_deadline_test.go:261: continuous upload 1.321011251s > idle 300ms +=== CONT TestServerContinuousUpload/tls=true/chunked=true/expect=false +=== NAME TestServerContinuousUpload/tls=false/chunked=false/expect=true + server_deadline_test.go:261: continuous upload 1.325489459s > idle 300ms +=== NAME TestServerContinuousUpload/tls=true/chunked=true/expect=false + server_deadline_test.go:261: continuous upload 1.315352084s > idle 300ms +=== NAME TestServerContinuousUpload/tls=false/chunked=true/expect=false + server_deadline_test.go:261: continuous upload 1.325666792s > idle 300ms +=== NAME TestServerContinuousUpload/tls=true/chunked=false/expect=false + server_deadline_test.go:261: continuous upload 1.315434542s > idle 300ms +--- PASS: TestServerContinuousUpload (0.00s) + --- PASS: TestServerContinuousUpload/tls=false/chunked=true/expect=true (1.33s) + --- PASS: TestServerContinuousUpload/tls=true/chunked=false/expect=true (1.33s) + --- PASS: TestServerContinuousUpload/tls=false/chunked=false/expect=false (1.33s) + --- PASS: TestServerContinuousUpload/tls=true/chunked=true/expect=true (1.33s) + --- PASS: TestServerContinuousUpload/tls=false/chunked=false/expect=true (1.33s) + --- PASS: TestServerContinuousUpload/tls=true/chunked=false/expect=false (1.33s) + --- PASS: TestServerContinuousUpload/tls=false/chunked=true/expect=false (1.33s) + --- PASS: TestServerContinuousUpload/tls=true/chunked=true/expect=false (1.33s) +=== RUN TestServerDefaultIdleLongUpload + server_deadline_test.go:270: set SILO_TEST_LONG_UPLOAD=1 for >30s default-idle regression +--- SKIP: TestServerDefaultIdleLongUpload (0.00s) +=== RUN TestServerIdleBodyDeadline +=== RUN TestServerIdleBodyDeadline/tls=false +=== PAUSE TestServerIdleBodyDeadline/tls=false +=== RUN TestServerIdleBodyDeadline/tls=true +=== PAUSE TestServerIdleBodyDeadline/tls=true +=== CONT TestServerIdleBodyDeadline/tls=false +=== CONT TestServerIdleBodyDeadline/tls=true +--- PASS: TestServerIdleBodyDeadline (0.00s) + --- PASS: TestServerIdleBodyDeadline/tls=false (0.45s) + --- PASS: TestServerIdleBodyDeadline/tls=true (0.46s) +=== RUN TestServerBackgroundReadNoDeadline +=== RUN TestServerBackgroundReadNoDeadline/tls=false/body=false +=== PAUSE TestServerBackgroundReadNoDeadline/tls=false/body=false +=== RUN TestServerBackgroundReadNoDeadline/tls=false/body=true +=== PAUSE TestServerBackgroundReadNoDeadline/tls=false/body=true +=== RUN TestServerBackgroundReadNoDeadline/tls=true/body=false +=== PAUSE TestServerBackgroundReadNoDeadline/tls=true/body=false +=== RUN TestServerBackgroundReadNoDeadline/tls=true/body=true +=== PAUSE TestServerBackgroundReadNoDeadline/tls=true/body=true +=== CONT TestServerBackgroundReadNoDeadline/tls=false/body=true +=== CONT TestServerBackgroundReadNoDeadline/tls=true/body=true +=== CONT TestServerBackgroundReadNoDeadline/tls=true/body=false +=== CONT TestServerBackgroundReadNoDeadline/tls=false/body=false +--- PASS: TestServerBackgroundReadNoDeadline (0.00s) + --- PASS: TestServerBackgroundReadNoDeadline/tls=false/body=false (0.70s) + --- PASS: TestServerBackgroundReadNoDeadline/tls=false/body=true (0.70s) + --- PASS: TestServerBackgroundReadNoDeadline/tls=true/body=true (0.71s) + --- PASS: TestServerBackgroundReadNoDeadline/tls=true/body=false (0.71s) +=== RUN TestServerTLSHandshakeReadDeadline +2026/09/15 16:04:28 http: TLS handshake error from 127.0.0.1:60010: read tcp 127.0.0.1:44841->127.0.0.1:60010: i/o timeout +--- PASS: TestServerTLSHandshakeReadDeadline (0.21s) +=== RUN TestServerConnStateHook +=== RUN TestServerConnStateHook/tls=false +=== RUN TestServerConnStateHook/tls=true +--- PASS: TestServerConnStateHook (0.01s) + --- PASS: TestServerConnStateHook/tls=false (0.00s) + --- PASS: TestServerConnStateHook/tls=true (0.01s) +=== RUN TestServerHTTP2Deadlines +--- PASS: TestServerHTTP2Deadlines (0.42s) +=== RUN TestServerEarlyBodyClose +=== RUN TestServerEarlyBodyClose/tls=false +=== PAUSE TestServerEarlyBodyClose/tls=false +=== RUN TestServerEarlyBodyClose/tls=true +=== PAUSE TestServerEarlyBodyClose/tls=true +=== CONT TestServerEarlyBodyClose/tls=false +=== CONT TestServerEarlyBodyClose/tls=true +--- PASS: TestServerEarlyBodyClose (0.00s) + --- PASS: TestServerEarlyBodyClose/tls=false (0.31s) + --- PASS: TestServerEarlyBodyClose/tls=true (0.31s) +=== RUN TestServerPipelinedDeadline +=== RUN TestServerPipelinedDeadline/tls=false +=== RUN TestServerPipelinedDeadline/tls=true +--- PASS: TestServerPipelinedDeadline (1.34s) + --- PASS: TestServerPipelinedDeadline/tls=false (0.66s) + --- PASS: TestServerPipelinedDeadline/tls=true (0.68s) +=== RUN TestServerHijackedDeadline +=== RUN TestServerHijackedDeadline/tls=false +=== PAUSE TestServerHijackedDeadline/tls=false +=== RUN TestServerHijackedDeadline/tls=true +=== PAUSE TestServerHijackedDeadline/tls=true +=== CONT TestServerHijackedDeadline/tls=true +=== CONT TestServerHijackedDeadline/tls=false +--- PASS: TestServerHijackedDeadline (0.00s) + --- PASS: TestServerHijackedDeadline/tls=false (0.70s) + --- PASS: TestServerHijackedDeadline/tls=true (0.71s) +=== RUN TestServerContinuousDownload +=== RUN TestServerContinuousDownload/tls=false +=== PAUSE TestServerContinuousDownload/tls=false +=== RUN TestServerContinuousDownload/tls=true +=== PAUSE TestServerContinuousDownload/tls=true +=== CONT TestServerContinuousDownload/tls=true +=== CONT TestServerContinuousDownload/tls=false +=== NAME TestServerContinuousDownload/tls=true + server_deadline_test.go:619: continuous download 1.306085084s > idle 300ms +=== NAME TestServerContinuousDownload/tls=false + server_deadline_test.go:619: continuous download 1.319082334s > idle 300ms +--- PASS: TestServerContinuousDownload (0.00s) + --- PASS: TestServerContinuousDownload/tls=true (1.32s) + --- PASS: TestServerContinuousDownload/tls=false (1.32s) +=== RUN TestServerDefaultIdleLongDownload + server_deadline_test.go:626: set SILO_TEST_LONG_UPLOAD=1 for >30s default-idle transfer regressions +--- SKIP: TestServerDefaultIdleLongDownload (0.00s) +=== RUN TestNewServer +--- PASS: TestNewServer (0.00s) +PASS +ok github.com/minio/minio/internal/http 15.196s diff --git a/docs/investigations/r8/evidence/linux-race-initial.log b/docs/investigations/r8/evidence/linux-race-initial.log new file mode 100644 index 000000000..964d89c6f --- /dev/null +++ b/docs/investigations/r8/evidence/linux-race-initial.log @@ -0,0 +1,212 @@ +=== RUN TestStrictReadDeadline +=== RUN TestStrictReadDeadline/SetReadDeadline +=== RUN TestStrictReadDeadline/SetDeadline +--- PASS: TestStrictReadDeadline (0.00s) + --- PASS: TestStrictReadDeadline/SetReadDeadline (0.00s) + --- PASS: TestStrictReadDeadline/SetDeadline (0.00s) +=== RUN TestDefaultReadDeadlineStillRenews +--- PASS: TestDefaultReadDeadlineStillRenews (0.00s) +=== RUN TestStrictExpiredFutureReadDeadline +--- PASS: TestStrictExpiredFutureReadDeadline (0.06s) +=== RUN TestConcurrentStrictReadDeadline +--- PASS: TestConcurrentStrictReadDeadline (0.00s) +=== RUN TestBuffConnReadTimeout +--- PASS: TestBuffConnReadTimeout (3.01s) +=== RUN TestBuffConnReadCheckTimeout +--- PASS: TestBuffConnReadCheckTimeout (0.50s) +PASS +ok github.com/minio/minio/internal/deadlineconn 4.580s +=== RUN TestCheckPortAvailability +--- PASS: TestCheckPortAvailability (0.00s) +=== RUN TestInternodeDialReadDeadline +--- PASS: TestInternodeDialReadDeadline (1.31s) +=== RUN TestNewHTTPListener +--- PASS: TestNewHTTPListener (0.01s) +=== RUN TestHTTPListenerStartClose +--- PASS: TestHTTPListenerStartClose (0.00s) +=== RUN TestHTTPListenerAddr +--- PASS: TestHTTPListenerAddr (0.00s) +=== RUN TestHTTPListenerAddrs +--- PASS: TestHTTPListenerAddrs (0.00s) +=== RUN TestServerReadHeaderDeadline +=== RUN TestServerReadHeaderDeadline/tls=false/second=false/trickle=false +=== PAUSE TestServerReadHeaderDeadline/tls=false/second=false/trickle=false +=== RUN TestServerReadHeaderDeadline/tls=false/second=false/trickle=true +=== PAUSE TestServerReadHeaderDeadline/tls=false/second=false/trickle=true +=== RUN TestServerReadHeaderDeadline/tls=false/second=true/trickle=false +=== PAUSE TestServerReadHeaderDeadline/tls=false/second=true/trickle=false +=== RUN TestServerReadHeaderDeadline/tls=false/second=true/trickle=true +=== PAUSE TestServerReadHeaderDeadline/tls=false/second=true/trickle=true +=== RUN TestServerReadHeaderDeadline/tls=true/second=false/trickle=false +=== PAUSE TestServerReadHeaderDeadline/tls=true/second=false/trickle=false +=== RUN TestServerReadHeaderDeadline/tls=true/second=false/trickle=true +=== PAUSE TestServerReadHeaderDeadline/tls=true/second=false/trickle=true +=== RUN TestServerReadHeaderDeadline/tls=true/second=true/trickle=false +=== PAUSE TestServerReadHeaderDeadline/tls=true/second=true/trickle=false +=== RUN TestServerReadHeaderDeadline/tls=true/second=true/trickle=true +=== PAUSE TestServerReadHeaderDeadline/tls=true/second=true/trickle=true +=== CONT TestServerReadHeaderDeadline/tls=false/second=false/trickle=false +=== CONT TestServerReadHeaderDeadline/tls=false/second=true/trickle=true +=== CONT TestServerReadHeaderDeadline/tls=false/second=true/trickle=false +=== CONT TestServerReadHeaderDeadline/tls=true/second=true/trickle=true +=== CONT TestServerReadHeaderDeadline/tls=true/second=false/trickle=false +=== CONT TestServerReadHeaderDeadline/tls=false/second=false/trickle=true +=== CONT TestServerReadHeaderDeadline/tls=true/second=true/trickle=false +=== CONT TestServerReadHeaderDeadline/tls=true/second=false/trickle=true +--- PASS: TestServerReadHeaderDeadline (0.00s) + --- PASS: TestServerReadHeaderDeadline/tls=false/second=false/trickle=false (0.61s) + --- PASS: TestServerReadHeaderDeadline/tls=false/second=true/trickle=true (0.81s) + --- PASS: TestServerReadHeaderDeadline/tls=true/second=true/trickle=true (0.81s) + --- PASS: TestServerReadHeaderDeadline/tls=false/second=true/trickle=false (1.01s) + --- PASS: TestServerReadHeaderDeadline/tls=true/second=false/trickle=false (0.61s) + --- PASS: TestServerReadHeaderDeadline/tls=false/second=false/trickle=true (0.41s) + --- PASS: TestServerReadHeaderDeadline/tls=true/second=false/trickle=true (0.42s) + --- PASS: TestServerReadHeaderDeadline/tls=true/second=true/trickle=false (1.01s) +=== RUN TestServerKeepAliveDeadline +=== RUN TestServerKeepAliveDeadline/tls=false +=== PAUSE TestServerKeepAliveDeadline/tls=false +=== RUN TestServerKeepAliveDeadline/tls=true +=== PAUSE TestServerKeepAliveDeadline/tls=true +=== CONT TestServerKeepAliveDeadline/tls=true +=== CONT TestServerKeepAliveDeadline/tls=false +--- PASS: TestServerKeepAliveDeadline (0.00s) + --- PASS: TestServerKeepAliveDeadline/tls=true (1.52s) + --- PASS: TestServerKeepAliveDeadline/tls=false (1.52s) +=== RUN TestServerContinuousUpload +=== RUN TestServerContinuousUpload/tls=false/chunked=false/expect=false +=== PAUSE TestServerContinuousUpload/tls=false/chunked=false/expect=false +=== RUN TestServerContinuousUpload/tls=false/chunked=false/expect=true +=== PAUSE TestServerContinuousUpload/tls=false/chunked=false/expect=true +=== RUN TestServerContinuousUpload/tls=false/chunked=true/expect=false +=== PAUSE TestServerContinuousUpload/tls=false/chunked=true/expect=false +=== RUN TestServerContinuousUpload/tls=false/chunked=true/expect=true +=== PAUSE TestServerContinuousUpload/tls=false/chunked=true/expect=true +=== RUN TestServerContinuousUpload/tls=true/chunked=false/expect=false +=== PAUSE TestServerContinuousUpload/tls=true/chunked=false/expect=false +=== RUN TestServerContinuousUpload/tls=true/chunked=false/expect=true +=== PAUSE TestServerContinuousUpload/tls=true/chunked=false/expect=true +=== RUN TestServerContinuousUpload/tls=true/chunked=true/expect=false +=== PAUSE TestServerContinuousUpload/tls=true/chunked=true/expect=false +=== RUN TestServerContinuousUpload/tls=true/chunked=true/expect=true +=== PAUSE TestServerContinuousUpload/tls=true/chunked=true/expect=true +=== CONT TestServerContinuousUpload/tls=false/chunked=false/expect=true +=== CONT TestServerContinuousUpload/tls=true/chunked=true/expect=true +=== CONT TestServerContinuousUpload/tls=true/chunked=false/expect=false +=== CONT TestServerContinuousUpload/tls=false/chunked=false/expect=false +=== NAME TestServerContinuousUpload/tls=true/chunked=false/expect=false + server_deadline_test.go:259: continuous upload 1.317026792s > idle 300ms +=== NAME TestServerContinuousUpload/tls=false/chunked=false/expect=false + server_deadline_test.go:259: continuous upload 1.327268125s > idle 300ms +=== NAME TestServerContinuousUpload/tls=false/chunked=false/expect=true + server_deadline_test.go:259: continuous upload 1.327373417s > idle 300ms +=== NAME TestServerContinuousUpload/tls=true/chunked=true/expect=true + server_deadline_test.go:259: continuous upload 1.316941667s > idle 300ms +=== CONT TestServerContinuousUpload/tls=false/chunked=true/expect=false +=== CONT TestServerContinuousUpload/tls=true/chunked=false/expect=true +=== CONT TestServerContinuousUpload/tls=true/chunked=true/expect=false +=== CONT TestServerContinuousUpload/tls=false/chunked=true/expect=true +=== NAME TestServerContinuousUpload/tls=true/chunked=false/expect=true + server_deadline_test.go:259: continuous upload 1.299763501s > idle 300ms +=== NAME TestServerContinuousUpload/tls=false/chunked=true/expect=true + server_deadline_test.go:259: continuous upload 1.313516875s > idle 300ms +=== NAME TestServerContinuousUpload/tls=false/chunked=true/expect=false + server_deadline_test.go:259: continuous upload 1.314431001s > idle 300ms +=== NAME TestServerContinuousUpload/tls=true/chunked=true/expect=false + server_deadline_test.go:259: continuous upload 1.300043209s > idle 300ms +--- PASS: TestServerContinuousUpload (0.00s) + --- PASS: TestServerContinuousUpload/tls=false/chunked=false/expect=false (1.33s) + --- PASS: TestServerContinuousUpload/tls=false/chunked=false/expect=true (1.33s) + --- PASS: TestServerContinuousUpload/tls=true/chunked=false/expect=false (1.33s) + --- PASS: TestServerContinuousUpload/tls=true/chunked=true/expect=true (1.33s) + --- PASS: TestServerContinuousUpload/tls=false/chunked=true/expect=true (1.31s) + --- PASS: TestServerContinuousUpload/tls=false/chunked=true/expect=false (1.32s) + --- PASS: TestServerContinuousUpload/tls=true/chunked=true/expect=false (1.32s) + --- PASS: TestServerContinuousUpload/tls=true/chunked=false/expect=true (1.32s) +=== RUN TestServerDefaultIdleLongUpload + server_deadline_test.go:268: set SILO_TEST_LONG_UPLOAD=1 for >30s default-idle regression +--- SKIP: TestServerDefaultIdleLongUpload (0.00s) +=== RUN TestServerIdleBodyDeadline +=== RUN TestServerIdleBodyDeadline/tls=false +=== PAUSE TestServerIdleBodyDeadline/tls=false +=== RUN TestServerIdleBodyDeadline/tls=true +=== PAUSE TestServerIdleBodyDeadline/tls=true +=== CONT TestServerIdleBodyDeadline/tls=false +=== CONT TestServerIdleBodyDeadline/tls=true +--- PASS: TestServerIdleBodyDeadline (0.00s) + --- PASS: TestServerIdleBodyDeadline/tls=false (0.46s) + --- PASS: TestServerIdleBodyDeadline/tls=true (0.46s) +=== RUN TestServerBackgroundReadNoDeadline +=== RUN TestServerBackgroundReadNoDeadline/tls=false/body=false +=== PAUSE TestServerBackgroundReadNoDeadline/tls=false/body=false +=== RUN TestServerBackgroundReadNoDeadline/tls=false/body=true +=== PAUSE TestServerBackgroundReadNoDeadline/tls=false/body=true +=== RUN TestServerBackgroundReadNoDeadline/tls=true/body=false +=== PAUSE TestServerBackgroundReadNoDeadline/tls=true/body=false +=== RUN TestServerBackgroundReadNoDeadline/tls=true/body=true +=== PAUSE TestServerBackgroundReadNoDeadline/tls=true/body=true +=== CONT TestServerBackgroundReadNoDeadline/tls=false/body=false +=== CONT TestServerBackgroundReadNoDeadline/tls=true/body=false +=== CONT TestServerBackgroundReadNoDeadline/tls=false/body=true +=== CONT TestServerBackgroundReadNoDeadline/tls=true/body=true +--- PASS: TestServerBackgroundReadNoDeadline (0.00s) + --- PASS: TestServerBackgroundReadNoDeadline/tls=false/body=false (0.70s) + --- PASS: TestServerBackgroundReadNoDeadline/tls=false/body=true (0.70s) + --- PASS: TestServerBackgroundReadNoDeadline/tls=true/body=false (0.71s) + --- PASS: TestServerBackgroundReadNoDeadline/tls=true/body=true (0.71s) +=== RUN TestServerTLSHandshakeReadDeadline +2026/09/15 15:53:23 http: TLS handshake error from 127.0.0.1:45990: read tcp 127.0.0.1:34171->127.0.0.1:45990: i/o timeout +--- PASS: TestServerTLSHandshakeReadDeadline (0.20s) +=== RUN TestServerConnStateHook +=== RUN TestServerConnStateHook/tls=false +=== RUN TestServerConnStateHook/tls=true +--- PASS: TestServerConnStateHook (0.01s) + --- PASS: TestServerConnStateHook/tls=false (0.00s) + --- PASS: TestServerConnStateHook/tls=true (0.01s) +=== RUN TestServerHTTP2Deadlines +--- PASS: TestServerHTTP2Deadlines (0.42s) +=== RUN TestServerEarlyBodyClose +=== RUN TestServerEarlyBodyClose/tls=false +=== PAUSE TestServerEarlyBodyClose/tls=false +=== RUN TestServerEarlyBodyClose/tls=true +=== PAUSE TestServerEarlyBodyClose/tls=true +=== CONT TestServerEarlyBodyClose/tls=true +=== CONT TestServerEarlyBodyClose/tls=false +--- PASS: TestServerEarlyBodyClose (0.00s) + --- PASS: TestServerEarlyBodyClose/tls=false (0.31s) + --- PASS: TestServerEarlyBodyClose/tls=true (0.31s) +=== RUN TestServerPipelinedDeadline +=== RUN TestServerPipelinedDeadline/tls=false +=== RUN TestServerPipelinedDeadline/tls=true +--- PASS: TestServerPipelinedDeadline (1.33s) + --- PASS: TestServerPipelinedDeadline/tls=false (0.66s) + --- PASS: TestServerPipelinedDeadline/tls=true (0.67s) +=== RUN TestServerHijackedDeadline +=== RUN TestServerHijackedDeadline/tls=false +=== PAUSE TestServerHijackedDeadline/tls=false +=== RUN TestServerHijackedDeadline/tls=true +=== PAUSE TestServerHijackedDeadline/tls=true +=== CONT TestServerHijackedDeadline/tls=false +=== CONT TestServerHijackedDeadline/tls=true +--- PASS: TestServerHijackedDeadline (0.00s) + --- PASS: TestServerHijackedDeadline/tls=false (0.70s) + --- PASS: TestServerHijackedDeadline/tls=true (0.71s) +=== RUN TestServerContinuousDownload +=== RUN TestServerContinuousDownload/tls=false +=== PAUSE TestServerContinuousDownload/tls=false +=== RUN TestServerContinuousDownload/tls=true +=== PAUSE TestServerContinuousDownload/tls=true +=== CONT TestServerContinuousDownload/tls=false +=== CONT TestServerContinuousDownload/tls=true + server_deadline_test.go:588: continuous download 1.315636125s > idle 300ms +=== NAME TestServerContinuousDownload/tls=false + server_deadline_test.go:588: continuous download 1.324163209s > idle 300ms +--- PASS: TestServerContinuousDownload (0.00s) + --- PASS: TestServerContinuousDownload/tls=false (1.32s) + --- PASS: TestServerContinuousDownload/tls=true (1.32s) +=== RUN TestServerDefaultIdleLongDownload + server_deadline_test.go:595: set SILO_TEST_LONG_UPLOAD=1 for >30s default-idle transfer regressions +--- SKIP: TestServerDefaultIdleLongDownload (0.00s) +=== RUN TestNewServer +--- PASS: TestNewServer (0.00s) +PASS +ok github.com/minio/minio/internal/http 13.825s diff --git a/docs/investigations/r8/evidence/linux-race.log b/docs/investigations/r8/evidence/linux-race.log new file mode 100644 index 000000000..4814a2dc2 --- /dev/null +++ b/docs/investigations/r8/evidence/linux-race.log @@ -0,0 +1,213 @@ +=== RUN TestStrictReadDeadline +=== RUN TestStrictReadDeadline/SetReadDeadline +=== RUN TestStrictReadDeadline/SetDeadline +--- PASS: TestStrictReadDeadline (0.00s) + --- PASS: TestStrictReadDeadline/SetReadDeadline (0.00s) + --- PASS: TestStrictReadDeadline/SetDeadline (0.00s) +=== RUN TestDefaultReadDeadlineStillRenews +--- PASS: TestDefaultReadDeadlineStillRenews (0.00s) +=== RUN TestStrictExpiredFutureReadDeadline +--- PASS: TestStrictExpiredFutureReadDeadline (0.06s) +=== RUN TestConcurrentStrictReadDeadline +--- PASS: TestConcurrentStrictReadDeadline (0.00s) +=== RUN TestBuffConnReadTimeout +--- PASS: TestBuffConnReadTimeout (3.01s) +=== RUN TestBuffConnReadCheckTimeout +--- PASS: TestBuffConnReadCheckTimeout (0.51s) +PASS +ok github.com/minio/minio/internal/deadlineconn 4.583s +=== RUN TestCheckPortAvailability +--- PASS: TestCheckPortAvailability (0.01s) +=== RUN TestInternodeDialReadDeadline +--- PASS: TestInternodeDialReadDeadline (1.31s) +=== RUN TestNewHTTPListener +--- PASS: TestNewHTTPListener (0.01s) +=== RUN TestHTTPListenerStartClose +--- PASS: TestHTTPListenerStartClose (0.00s) +=== RUN TestHTTPListenerAddr +--- PASS: TestHTTPListenerAddr (0.00s) +=== RUN TestHTTPListenerAddrs +--- PASS: TestHTTPListenerAddrs (0.00s) +=== RUN TestServerReadHeaderDeadline +=== RUN TestServerReadHeaderDeadline/tls=false/second=false/trickle=false +=== PAUSE TestServerReadHeaderDeadline/tls=false/second=false/trickle=false +=== RUN TestServerReadHeaderDeadline/tls=false/second=false/trickle=true +=== PAUSE TestServerReadHeaderDeadline/tls=false/second=false/trickle=true +=== RUN TestServerReadHeaderDeadline/tls=false/second=true/trickle=false +=== PAUSE TestServerReadHeaderDeadline/tls=false/second=true/trickle=false +=== RUN TestServerReadHeaderDeadline/tls=false/second=true/trickle=true +=== PAUSE TestServerReadHeaderDeadline/tls=false/second=true/trickle=true +=== RUN TestServerReadHeaderDeadline/tls=true/second=false/trickle=false +=== PAUSE TestServerReadHeaderDeadline/tls=true/second=false/trickle=false +=== RUN TestServerReadHeaderDeadline/tls=true/second=false/trickle=true +=== PAUSE TestServerReadHeaderDeadline/tls=true/second=false/trickle=true +=== RUN TestServerReadHeaderDeadline/tls=true/second=true/trickle=false +=== PAUSE TestServerReadHeaderDeadline/tls=true/second=true/trickle=false +=== RUN TestServerReadHeaderDeadline/tls=true/second=true/trickle=true +=== PAUSE TestServerReadHeaderDeadline/tls=true/second=true/trickle=true +=== CONT TestServerReadHeaderDeadline/tls=false/second=false/trickle=true +=== CONT TestServerReadHeaderDeadline/tls=false/second=true/trickle=true +=== CONT TestServerReadHeaderDeadline/tls=false/second=true/trickle=false +=== CONT TestServerReadHeaderDeadline/tls=false/second=false/trickle=false +=== CONT TestServerReadHeaderDeadline/tls=true/second=true/trickle=true +=== CONT TestServerReadHeaderDeadline/tls=true/second=true/trickle=false +=== CONT TestServerReadHeaderDeadline/tls=true/second=false/trickle=true +=== CONT TestServerReadHeaderDeadline/tls=true/second=false/trickle=false +--- PASS: TestServerReadHeaderDeadline (0.00s) + --- PASS: TestServerReadHeaderDeadline/tls=false/second=false/trickle=true (0.83s) + --- PASS: TestServerReadHeaderDeadline/tls=false/second=false/trickle=false (0.95s) + --- PASS: TestServerReadHeaderDeadline/tls=false/second=true/trickle=true (1.57s) + --- PASS: TestServerReadHeaderDeadline/tls=false/second=true/trickle=false (1.71s) + --- PASS: TestServerReadHeaderDeadline/tls=true/second=false/trickle=true (0.83s) + --- PASS: TestServerReadHeaderDeadline/tls=true/second=true/trickle=true (1.58s) + --- PASS: TestServerReadHeaderDeadline/tls=true/second=true/trickle=false (1.71s) + --- PASS: TestServerReadHeaderDeadline/tls=true/second=false/trickle=false (0.96s) +=== RUN TestServerKeepAliveDeadline +=== RUN TestServerKeepAliveDeadline/tls=false +=== PAUSE TestServerKeepAliveDeadline/tls=false +=== RUN TestServerKeepAliveDeadline/tls=true +=== PAUSE TestServerKeepAliveDeadline/tls=true +=== CONT TestServerKeepAliveDeadline/tls=false +=== CONT TestServerKeepAliveDeadline/tls=true +--- PASS: TestServerKeepAliveDeadline (0.00s) + --- PASS: TestServerKeepAliveDeadline/tls=false (1.51s) + --- PASS: TestServerKeepAliveDeadline/tls=true (1.51s) +=== RUN TestServerContinuousUpload +=== RUN TestServerContinuousUpload/tls=false/chunked=false/expect=false +=== PAUSE TestServerContinuousUpload/tls=false/chunked=false/expect=false +=== RUN TestServerContinuousUpload/tls=false/chunked=false/expect=true +=== PAUSE TestServerContinuousUpload/tls=false/chunked=false/expect=true +=== RUN TestServerContinuousUpload/tls=false/chunked=true/expect=false +=== PAUSE TestServerContinuousUpload/tls=false/chunked=true/expect=false +=== RUN TestServerContinuousUpload/tls=false/chunked=true/expect=true +=== PAUSE TestServerContinuousUpload/tls=false/chunked=true/expect=true +=== RUN TestServerContinuousUpload/tls=true/chunked=false/expect=false +=== PAUSE TestServerContinuousUpload/tls=true/chunked=false/expect=false +=== RUN TestServerContinuousUpload/tls=true/chunked=false/expect=true +=== PAUSE TestServerContinuousUpload/tls=true/chunked=false/expect=true +=== RUN TestServerContinuousUpload/tls=true/chunked=true/expect=false +=== PAUSE TestServerContinuousUpload/tls=true/chunked=true/expect=false +=== RUN TestServerContinuousUpload/tls=true/chunked=true/expect=true +=== PAUSE TestServerContinuousUpload/tls=true/chunked=true/expect=true +=== CONT TestServerContinuousUpload/tls=false/chunked=true/expect=true +=== CONT TestServerContinuousUpload/tls=true/chunked=false/expect=false +=== CONT TestServerContinuousUpload/tls=true/chunked=true/expect=false +=== CONT TestServerContinuousUpload/tls=true/chunked=false/expect=true +=== NAME TestServerContinuousUpload/tls=true/chunked=true/expect=false + server_deadline_test.go:259: continuous upload 1.314443501s > idle 300ms +=== NAME TestServerContinuousUpload/tls=true/chunked=false/expect=true + server_deadline_test.go:259: continuous upload 1.313693959s > idle 300ms +=== NAME TestServerContinuousUpload/tls=false/chunked=true/expect=true + server_deadline_test.go:259: continuous upload 1.326107334s > idle 300ms +=== NAME TestServerContinuousUpload/tls=true/chunked=false/expect=false + server_deadline_test.go:259: continuous upload 1.314532709s > idle 300ms +=== CONT TestServerContinuousUpload/tls=false/chunked=false/expect=true +=== CONT TestServerContinuousUpload/tls=false/chunked=false/expect=false +=== CONT TestServerContinuousUpload/tls=false/chunked=true/expect=false +=== CONT TestServerContinuousUpload/tls=true/chunked=true/expect=true +=== NAME TestServerContinuousUpload/tls=false/chunked=false/expect=true + server_deadline_test.go:259: continuous upload 1.308128834s > idle 300ms +=== NAME TestServerContinuousUpload/tls=false/chunked=true/expect=false + server_deadline_test.go:259: continuous upload 1.308309709s > idle 300ms +=== NAME TestServerContinuousUpload/tls=false/chunked=false/expect=false + server_deadline_test.go:259: continuous upload 1.30837825s > idle 300ms +=== NAME TestServerContinuousUpload/tls=true/chunked=true/expect=true + server_deadline_test.go:259: continuous upload 1.297138459s > idle 300ms +--- PASS: TestServerContinuousUpload (0.00s) + --- PASS: TestServerContinuousUpload/tls=true/chunked=true/expect=false (1.33s) + --- PASS: TestServerContinuousUpload/tls=true/chunked=false/expect=true (1.33s) + --- PASS: TestServerContinuousUpload/tls=false/chunked=true/expect=true (1.33s) + --- PASS: TestServerContinuousUpload/tls=true/chunked=false/expect=false (1.33s) + --- PASS: TestServerContinuousUpload/tls=false/chunked=false/expect=true (1.31s) + --- PASS: TestServerContinuousUpload/tls=false/chunked=true/expect=false (1.31s) + --- PASS: TestServerContinuousUpload/tls=false/chunked=false/expect=false (1.31s) + --- PASS: TestServerContinuousUpload/tls=true/chunked=true/expect=true (1.31s) +=== RUN TestServerDefaultIdleLongUpload + server_deadline_test.go:268: set SILO_TEST_LONG_UPLOAD=1 for >30s default-idle regression +--- SKIP: TestServerDefaultIdleLongUpload (0.00s) +=== RUN TestServerIdleBodyDeadline +=== RUN TestServerIdleBodyDeadline/tls=false +=== PAUSE TestServerIdleBodyDeadline/tls=false +=== RUN TestServerIdleBodyDeadline/tls=true +=== PAUSE TestServerIdleBodyDeadline/tls=true +=== CONT TestServerIdleBodyDeadline/tls=false +=== CONT TestServerIdleBodyDeadline/tls=true +--- PASS: TestServerIdleBodyDeadline (0.00s) + --- PASS: TestServerIdleBodyDeadline/tls=false (0.45s) + --- PASS: TestServerIdleBodyDeadline/tls=true (0.46s) +=== RUN TestServerBackgroundReadNoDeadline +=== RUN TestServerBackgroundReadNoDeadline/tls=false/body=false +=== PAUSE TestServerBackgroundReadNoDeadline/tls=false/body=false +=== RUN TestServerBackgroundReadNoDeadline/tls=false/body=true +=== PAUSE TestServerBackgroundReadNoDeadline/tls=false/body=true +=== RUN TestServerBackgroundReadNoDeadline/tls=true/body=false +=== PAUSE TestServerBackgroundReadNoDeadline/tls=true/body=false +=== RUN TestServerBackgroundReadNoDeadline/tls=true/body=true +=== PAUSE TestServerBackgroundReadNoDeadline/tls=true/body=true +=== CONT TestServerBackgroundReadNoDeadline/tls=false/body=false +=== CONT TestServerBackgroundReadNoDeadline/tls=true/body=true +=== CONT TestServerBackgroundReadNoDeadline/tls=false/body=true +=== CONT TestServerBackgroundReadNoDeadline/tls=true/body=false +--- PASS: TestServerBackgroundReadNoDeadline (0.00s) + --- PASS: TestServerBackgroundReadNoDeadline/tls=false/body=false (0.71s) + --- PASS: TestServerBackgroundReadNoDeadline/tls=false/body=true (0.71s) + --- PASS: TestServerBackgroundReadNoDeadline/tls=true/body=false (0.71s) + --- PASS: TestServerBackgroundReadNoDeadline/tls=true/body=true (0.71s) +=== RUN TestServerTLSHandshakeReadDeadline +2026/09/15 15:55:04 http: TLS handshake error from 127.0.0.1:33250: read tcp 127.0.0.1:42477->127.0.0.1:33250: i/o timeout +--- PASS: TestServerTLSHandshakeReadDeadline (0.21s) +=== RUN TestServerConnStateHook +=== RUN TestServerConnStateHook/tls=false +=== RUN TestServerConnStateHook/tls=true +--- PASS: TestServerConnStateHook (0.01s) + --- PASS: TestServerConnStateHook/tls=false (0.00s) + --- PASS: TestServerConnStateHook/tls=true (0.01s) +=== RUN TestServerHTTP2Deadlines +--- PASS: TestServerHTTP2Deadlines (0.42s) +=== RUN TestServerEarlyBodyClose +=== RUN TestServerEarlyBodyClose/tls=false +=== PAUSE TestServerEarlyBodyClose/tls=false +=== RUN TestServerEarlyBodyClose/tls=true +=== PAUSE TestServerEarlyBodyClose/tls=true +=== CONT TestServerEarlyBodyClose/tls=false +=== CONT TestServerEarlyBodyClose/tls=true +--- PASS: TestServerEarlyBodyClose (0.00s) + --- PASS: TestServerEarlyBodyClose/tls=false (0.31s) + --- PASS: TestServerEarlyBodyClose/tls=true (0.31s) +=== RUN TestServerPipelinedDeadline +=== RUN TestServerPipelinedDeadline/tls=false +=== RUN TestServerPipelinedDeadline/tls=true +--- PASS: TestServerPipelinedDeadline (1.35s) + --- PASS: TestServerPipelinedDeadline/tls=false (0.67s) + --- PASS: TestServerPipelinedDeadline/tls=true (0.68s) +=== RUN TestServerHijackedDeadline +=== RUN TestServerHijackedDeadline/tls=false +=== PAUSE TestServerHijackedDeadline/tls=false +=== RUN TestServerHijackedDeadline/tls=true +=== PAUSE TestServerHijackedDeadline/tls=true +=== CONT TestServerHijackedDeadline/tls=true +=== CONT TestServerHijackedDeadline/tls=false +--- PASS: TestServerHijackedDeadline (0.00s) + --- PASS: TestServerHijackedDeadline/tls=false (0.70s) + --- PASS: TestServerHijackedDeadline/tls=true (0.71s) +=== RUN TestServerContinuousDownload +=== RUN TestServerContinuousDownload/tls=false +=== PAUSE TestServerContinuousDownload/tls=false +=== RUN TestServerContinuousDownload/tls=true +=== PAUSE TestServerContinuousDownload/tls=true +=== CONT TestServerContinuousDownload/tls=false +=== CONT TestServerContinuousDownload/tls=true +=== NAME TestServerContinuousDownload/tls=false + server_deadline_test.go:624: continuous download 1.327951834s > idle 300ms +=== NAME TestServerContinuousDownload/tls=true + server_deadline_test.go:624: continuous download 1.320759709s > idle 300ms +--- PASS: TestServerContinuousDownload (0.00s) + --- PASS: TestServerContinuousDownload/tls=false (1.33s) + --- PASS: TestServerContinuousDownload/tls=true (1.33s) +=== RUN TestServerDefaultIdleLongDownload + server_deadline_test.go:631: set SILO_TEST_LONG_UPLOAD=1 for >30s default-idle transfer regressions +--- SKIP: TestServerDefaultIdleLongDownload (0.00s) +=== RUN TestNewServer +--- PASS: TestNewServer (0.00s) +PASS +ok github.com/minio/minio/internal/http 14.683s diff --git a/docs/investigations/r8/evidence/original-reproducer-fixed.log b/docs/investigations/r8/evidence/original-reproducer-fixed.log new file mode 100644 index 000000000..01fd37e60 --- /dev/null +++ b/docs/investigations/r8/evidence/original-reproducer-fixed.log @@ -0,0 +1,10 @@ +=== RUN TestReviewR8AbsoluteHeaderTimeout +=== RUN TestReviewR8AbsoluteHeaderTimeout/standard-net-http + r8_baseline_test.go:56: request rejected after header timeout: unexpected EOF +=== RUN TestReviewR8AbsoluteHeaderTimeout/silo-listener + r8_baseline_test.go:56: request rejected after header timeout: unexpected EOF +--- PASS: TestReviewR8AbsoluteHeaderTimeout (0.80s) + --- PASS: TestReviewR8AbsoluteHeaderTimeout/standard-net-http (0.40s) + --- PASS: TestReviewR8AbsoluteHeaderTimeout/silo-listener (0.40s) +PASS +ok github.com/minio/minio/internal/http 1.312s diff --git a/docs/investigations/r8/evidence/quality-checks.json b/docs/investigations/r8/evidence/quality-checks.json new file mode 100644 index 000000000..6602a8e93 --- /dev/null +++ b/docs/investigations/r8/evidence/quality-checks.json @@ -0,0 +1,186 @@ +[ + { + "name": "vet-final", + "command": [ + "go", + "vet", + "./internal/deadlineconn", + "./internal/http" + ], + "started_at_utc": "2026-09-15T16:04:26.504181+00:00", + "exit_code": 0, + "log": "docs/investigations/r8/evidence/vet-final.log", + "log_sha256": "521e3dbf0e63e910c0025f52abf592799fe3a9e9329a7fc33cb9938df5578036" + }, + { + "name": "tidy-final", + "command": [ + "go", + "mod", + "tidy", + "-diff" + ], + "started_at_utc": "2026-09-15T16:04:26.810955+00:00", + "exit_code": 0, + "log": "docs/investigations/r8/evidence/tidy-final.log", + "log_sha256": "3ad6ef5392ed3ed22d5b0fc1d5e04665208487b3fb168910457b8197ab507471" + }, + { + "name": "whitespace-final", + "command": [ + "git", + "diff", + "--check" + ], + "started_at_utc": "2026-09-15T16:04:26.979546+00:00", + "exit_code": 0, + "log": "docs/investigations/r8/evidence/whitespace-final.log", + "log_sha256": "fa77ab6dcf895a30623113e37b6ee812c6cd35b0cbb9693a0c18de550e6b1cf6" + }, + { + "name": "linux-race-final", + "command": "docker run --rm ... golang:1.27.1-bookworm go test -p 2 -race ./internal/deadlineconn ./internal/http -count=1 -v", + "exit_code": 0, + "platform": "linux/arm64", + "log": "docs/investigations/r8/evidence/linux-race-final.log", + "log_sha256": "484577f3b3820ecfc078b38dd808e048f52704a8c3ffc0dad9100bff26ead1f3" + }, + { + "name": "darwin-race-final", + "command": "go test -race ./internal/deadlineconn ./internal/http -count=1 -v", + "exit_code": 0, + "platform": "darwin/arm64", + "log": "docs/investigations/r8/evidence/darwin-race-final.log", + "log_sha256": "e7f0bac82e1eb346ff53422bb2426995bcb7d5d06b1d81c1bb3cc8c2dcdc7774" + }, + { + "name": "review-refinements", + "command": "go test -race ./internal/http ./internal/deadlineconn -run ^Test(ServerHTTP2Deadlines|StrictReadDeadlineRepeatedRenewal)$ -count=3 -v", + "exit_code": 0, + "platform": "darwin/arm64", + "log": "docs/investigations/r8/evidence/review-refinements.log", + "log_sha256": "c9b46f946cedcca88b8ed3dc7e465a4ad7836b4add338e42db3da7572be6a83b" + }, + { + "name": "original-reproducer-fixed", + "command": "go test -overlay=/Users/vonng/tmp/silo-r8-01a0a5b9/baseline-overlay.json ./internal/http -run ^TestReviewR8AbsoluteHeaderTimeout$ -count=1 -v", + "exit_code": 0, + "platform": "darwin/arm64", + "log": "docs/investigations/r8/evidence/original-reproducer-fixed.log", + "log_sha256": "e787f5e5ad826518a39fdebbb43d28b6ceab36ed8e32267cf3a450af31ce9844" + }, + { + "name": "default-30s-transfers", + "command": "SILO_TEST_LONG_UPLOAD=1 go test ./internal/http -run ^TestServerDefaultIdleLong -count=1 -v", + "exit_code": 0, + "platform": "darwin/arm64", + "log": "docs/investigations/r8/evidence/default-30s-transfers.log", + "log_sha256": "378defff78ae565e95d52830e5b11c1a01a0a11efc14c2822228ca366df58bc3" + }, + { + "name": "grid", + "command": "go test ./internal/grid -run ^(TestSingleRoundtrip|TestDisconnect)$ -count=1 -v", + "exit_code": 0, + "platform": "darwin/arm64", + "log": "docs/investigations/r8/evidence/grid.log", + "log_sha256": "4f677621e0c927e7a37bbd4c8e29ec9c5879ae4aaaf185fe4c3e0a4c41c6ab10" + }, + { + "name": "configuration-baseline", + "command": "go test -p 2 -overlay=/Users/vonng/tmp/silo-r8-01a0a5b9/config-overlay.json ./cmd -run ^TestServerReadHeaderTimeoutConfig$ -count=1 -v", + "exit_code": 1, + "expected_failure": true, + "platform": "darwin/arm64", + "log": "docs/investigations/r8/evidence/config-baseline.log", + "log_sha256": "cc7f7bf345ecfe52db414a785ac3727dc7bf88ec6493de13c1ced1d215e706d8" + }, + { + "name": "runtime-v1", + "command": "python3 docs/investigations/r8/evidence/runtime_probe.py /Users/vonng/tmp/silo-r8-01a0a5b9/silo-v1 baseline /Users/vonng/tmp/silo-r8-01a0a5b9/runtime-v1", + "exit_code": 0, + "expected_behavior": "both configured sources still incorrectly accept delayed headers before the binding fix", + "platform": "darwin/arm64", + "log": "docs/investigations/r8/evidence/runtime-v1.json", + "log_sha256": "64808fb172e98e81c65c324f5bb967b91a931fa4a757508aa64aaac94b663ce1" + }, + { + "name": "lint-initial", + "command": "golangci-lint run --allow-serial-runners --timeout=5m ./internal/deadlineconn ./internal/http", + "exit_code": 1, + "reason": "staticcheck requested the promoted srv.Close selector; fixed without behavior change", + "log": "docs/investigations/r8/evidence/lint.log", + "log_sha256": "3370461b3b426035790fccc8b862538f3740f3c8455277518872de6befa6990a" + }, + { + "name": "configuration-fixed", + "command": "go test -p 2 ./cmd -run ^(TestServerReadHeaderTimeoutConfig|TestServerConfigFile)$ -count=1 -v", + "exit_code": 0, + "platform": "darwin/arm64", + "log": "docs/investigations/r8/evidence/config-fixed.log", + "log_sha256": "bf9b7b80ecaa011bcdd4e32d902c0e03d79dfa5f9016f6b43c65678542cc74bb" + }, + { + "name": "build-v2", + "command": "go build -p 2 -o /Users/vonng/tmp/silo-r8-01a0a5b9/silo-v2 .", + "exit_code": 0, + "platform": "darwin/arm64", + "log": "docs/investigations/r8/evidence/build-v2.log", + "log_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + { + "name": "lint-final", + "command": "golangci-lint run --allow-serial-runners --timeout=5m ./internal/deadlineconn ./internal/http", + "exit_code": 0, + "platform": "darwin/arm64", + "log": "docs/investigations/r8/evidence/lint-final.log", + "log_sha256": "e92606b0bf483111dff0a120c315ea165821348f31365020e2468a0059095c47" + }, + { + "name": "runtime-v2", + "command": "python3 docs/investigations/r8/evidence/runtime_probe.py /Users/vonng/tmp/silo-r8-01a0a5b9/silo-v2 fixed /Users/vonng/tmp/silo-r8-01a0a5b9/runtime-v2", + "exit_code": 0, + "platform": "darwin/arm64", + "log": "docs/investigations/r8/evidence/runtime-v2.json", + "log_sha256": "253b0c192a3267731bfc46545befe0b7fe07cee6f9c5785d10a03ff782189499" + }, + { + "name": "configuration-race-extra", + "command": "go test -race -p 2 ./cmd -run ^TestServerReadHeaderTimeoutConfig$ -count=1 -v", + "exit_code": 1, + "blocked_by_environment": "Darwin external linker failed: errno=28 No space left on device", + "log": "docs/investigations/r8/evidence/config-race.log", + "log_sha256": "4cec7bba425217027939e01cce8d931a0ade98695bfb31529e71a2d748fc6295" + }, + { + "name": "legacy-timeout", + "command": "/bin/bash /Users/vonng/tmp/silo-r8-01a0a5b9/legacy-timeout/test-timeout-isolated.sh", + "exit_code": 255, + "status": "supplementary S3 script blocked by host storage capacity; normal PUT independently returns HTTP 507 XMinioStorageFull", + "log": "docs/investigations/r8/evidence/legacy-timeout.log", + "diagnostic": "docs/investigations/r8/evidence/s3-capacity-probe.json", + "log_sha256": "64f838a6a726e5ed2145924ea6bdbfe6ccb4a05dc1977baaa9658e7024888af6" + }, + { + "name": "s3-capacity-diagnostic", + "command": "python3 /Users/vonng/tmp/silo-r8-01a0a5b9/s3_capacity_probe.py", + "exit_code": 0, + "observed_http_status": 507, + "observed_s3_code": "XMinioStorageFull", + "log": "docs/investigations/r8/evidence/s3-capacity-probe.json", + "log_sha256": "51111b9d72437089c8642ef2e2c2b02cf9c81227ec9de9857ba938ea280d077a" + }, + { + "name": "delivery-whitespace", + "command": [ + "git", + "diff", + "--check", + "9ebe81c1b3611f9cc73e676b5b741c2be62c467a", + "--" + ], + "exit_code": 0, + "log": "docs/investigations/r8/evidence/delivery-whitespace.log", + "log_sha256": "3a403d687fac110e2675ee7f86a4f8b3c095b54fa602535f9805a957c67b92c2", + "note": "R8 archive .gitattributes preserves raw .log/.patch whitespace; production and test source still checked normally." + } +] diff --git a/docs/investigations/r8/evidence/review-refinements.log b/docs/investigations/r8/evidence/review-refinements.log new file mode 100644 index 000000000..7430df38b --- /dev/null +++ b/docs/investigations/r8/evidence/review-refinements.log @@ -0,0 +1,16 @@ +=== RUN TestServerHTTP2Deadlines +--- PASS: TestServerHTTP2Deadlines (0.42s) +=== RUN TestServerHTTP2Deadlines +--- PASS: TestServerHTTP2Deadlines (0.41s) +=== RUN TestServerHTTP2Deadlines +--- PASS: TestServerHTTP2Deadlines (0.42s) +PASS +ok github.com/minio/minio/internal/http 3.003s +=== RUN TestStrictReadDeadlineRepeatedRenewal +--- PASS: TestStrictReadDeadlineRepeatedRenewal (0.90s) +=== RUN TestStrictReadDeadlineRepeatedRenewal +--- PASS: TestStrictReadDeadlineRepeatedRenewal (0.90s) +=== RUN TestStrictReadDeadlineRepeatedRenewal +--- PASS: TestStrictReadDeadlineRepeatedRenewal (0.90s) +PASS +ok github.com/minio/minio/internal/deadlineconn 4.915s diff --git a/docs/investigations/r8/evidence/runtime-v1.json b/docs/investigations/r8/evidence/runtime-v1.json new file mode 100644 index 000000000..f59a47999 --- /dev/null +++ b/docs/investigations/r8/evidence/runtime-v1.json @@ -0,0 +1,27 @@ +{ + "binary": "/Users/vonng/tmp/silo-r8-01a0a5b9/silo-v1", + "binary_sha256": "ed8d30cb40832f854bd82b083b9d1ec3ee16a35598416a54ef2d48bde8384ffc", + "expected_rejection": false, + "cases": [ + { + "source": "flag", + "header_timeout_ms": 100, + "idle_timeout_ms": 2000, + "header_completion_delay_ms": 400, + "rejected": false, + "status": "HTTP/1.1 200 OK", + "elapsed_seconds": 0.401, + "still_alive": true + }, + { + "source": "environment", + "header_timeout_ms": 100, + "idle_timeout_ms": 2000, + "header_completion_delay_ms": 400, + "rejected": false, + "status": "HTTP/1.1 200 OK", + "elapsed_seconds": 0.408, + "still_alive": true + } + ] +} diff --git a/docs/investigations/r8/evidence/runtime-v1.stderr.log b/docs/investigations/r8/evidence/runtime-v1.stderr.log new file mode 100644 index 000000000..e69de29bb diff --git a/docs/investigations/r8/evidence/runtime-v2.json b/docs/investigations/r8/evidence/runtime-v2.json new file mode 100644 index 000000000..68da68d83 --- /dev/null +++ b/docs/investigations/r8/evidence/runtime-v2.json @@ -0,0 +1,27 @@ +{ + "binary": "/Users/vonng/tmp/silo-r8-01a0a5b9/silo-v2", + "binary_sha256": "6b982de3262c25e326280c739b4275444cf80c3eacae48f0942aa18fbb7cd654", + "expected_rejection": true, + "cases": [ + { + "source": "flag", + "header_timeout_ms": 100, + "idle_timeout_ms": 2000, + "header_completion_delay_ms": 400, + "rejected": true, + "status": "", + "elapsed_seconds": 0.403, + "still_alive": true + }, + { + "source": "environment", + "header_timeout_ms": 100, + "idle_timeout_ms": 2000, + "header_completion_delay_ms": 400, + "rejected": true, + "status": "", + "elapsed_seconds": 0.401, + "still_alive": true + } + ] +} diff --git a/docs/investigations/r8/evidence/runtime-v2.stderr.log b/docs/investigations/r8/evidence/runtime-v2.stderr.log new file mode 100644 index 000000000..e69de29bb diff --git a/docs/investigations/r8/evidence/runtime_probe.py b/docs/investigations/r8/evidence/runtime_probe.py new file mode 100644 index 000000000..8947f5cc2 --- /dev/null +++ b/docs/investigations/r8/evidence/runtime_probe.py @@ -0,0 +1,79 @@ +"""Probe the compiled SILO CLI on disposable loopback-only single-disk servers.""" +import hashlib +import json +import os +from pathlib import Path +import signal +import socket +import subprocess +import sys +import tempfile +import time +import urllib.request + +binary = Path(sys.argv[1]).resolve() +expected_rejection = sys.argv[2] == 'fixed' +output_dir = Path(sys.argv[3]).resolve() +output_dir.mkdir(parents=True, exist_ok=True) +results = [] +for source in ('flag', 'environment'): + with socket.socket() as reservation: + reservation.bind(('127.0.0.1', 0)) + port = reservation.getsockname()[1] + data_dir = tempfile.mkdtemp(prefix=f'r8-{source}-', dir=output_dir) + env = {k: v for k, v in os.environ.items() if not k.startswith(('MINIO_', 'SILO_'))} + env.update(MINIO_ROOT_USER='r8localtest', MINIO_ROOT_PASSWORD='r8-local-disposable-test-only', MINIO_BROWSER='off') + args = [str(binary), 'server', f'--address=127.0.0.1:{port}', '--console-address=127.0.0.1:0', '--idle-timeout=2s'] + if source == 'flag': + args.append('--read-header-timeout=100ms') + else: + env['MINIO_READ_HEADER_TIMEOUT'] = '100ms' + args.append(data_dir) + with (output_dir / f'{source}-server.log').open('w') as server_log: + proc = subprocess.Popen(args, env=env, stdout=server_log, stderr=subprocess.STDOUT) + try: + url = f'http://127.0.0.1:{port}/minio/health/live' + started = time.monotonic() + while True: + if proc.poll() is not None: + raise RuntimeError(f'{source}: server exited {proc.returncode}; see its log') + try: + with urllib.request.urlopen(url, timeout=1) as resp: + if resp.status == 200: + break + except Exception: + pass + if time.monotonic() - started > 30: + raise RuntimeError(f'{source}: startup timed out') + time.sleep(0.1) + with socket.create_connection(('127.0.0.1', port), timeout=2) as conn: + conn.settimeout(3) + conn.sendall(b'GET /minio/health/live HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\nX-Slow: ') + before = time.monotonic() + time.sleep(0.4) + try: + conn.sendall(b'done\r\n\r\n') + data = conn.recv(4096) + except (BrokenPipeError, ConnectionResetError): + data = b'' + rejected = not data + status_line = data.split(b'\r\n', 1)[0].decode('ascii', 'replace') + elapsed = time.monotonic() - before + with urllib.request.urlopen(url, timeout=2) as resp: + alive = resp.status == 200 + result = dict(source=source, header_timeout_ms=100, idle_timeout_ms=2000, + header_completion_delay_ms=400, rejected=rejected, status=status_line, + elapsed_seconds=round(elapsed, 3), still_alive=alive) + results.append(result) + if rejected != expected_rejection or not alive: + raise AssertionError(result) + finally: + proc.send_signal(signal.SIGTERM) + try: + proc.wait(timeout=10) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait(timeout=5) +report = dict(binary=str(binary), binary_sha256=hashlib.sha256(binary.read_bytes()).hexdigest(), + expected_rejection=expected_rejection, cases=results) +print(json.dumps(report, indent=2)) diff --git a/docs/investigations/r8/evidence/s3-capacity-probe.json b/docs/investigations/r8/evidence/s3-capacity-probe.json new file mode 100644 index 000000000..06a88115f --- /dev/null +++ b/docs/investigations/r8/evidence/s3-capacity-probe.json @@ -0,0 +1,4 @@ +{ + "status": 507, + "body": "\nXMinioStorageFullStorage backend has reached its minimum free drive threshold. Please delete a few objects to proceed.testobjecttestbucket/testbucket/testobject18D58A9DC7CF766804c30fe94f97d234a29260401af8d884660258a809670077dfa5454fa24cb0df" +} diff --git a/docs/investigations/r8/evidence/s3-capacity-probe.stderr.log b/docs/investigations/r8/evidence/s3-capacity-probe.stderr.log new file mode 100644 index 000000000..e69de29bb diff --git a/docs/investigations/r8/evidence/s3_capacity_probe.py b/docs/investigations/r8/evidence/s3_capacity_probe.py new file mode 100644 index 000000000..0f053de02 --- /dev/null +++ b/docs/investigations/r8/evidence/s3_capacity_probe.py @@ -0,0 +1,27 @@ +from pathlib import Path +import os,subprocess,socket,tempfile,time,urllib.request,urllib.error,signal,json +root=Path('/Users/vonng/tmp/silo-r8-01a0a5b9/capacity-probe');root.mkdir(exist_ok=True) +with socket.socket() as reservation: + reservation.bind(('127.0.0.1',0));port=reservation.getsockname()[1] +env={k:v for k,v in os.environ.items() if not k.startswith(('MINIO_','MC_','SILO_'))} +env.update(MINIO_ROOT_USER='silo',MINIO_ROOT_PASSWORD='silo1234',MINIO_BROWSER='off',MINIO_CI_CD='1',MC_HOST_silo=f'http://silo:silo1234@127.0.0.1:{port}/') +url=f'http://127.0.0.1:{port}' +with (root/'server.log').open('w') as log: + proc=subprocess.Popen(['/Users/vonng/tmp/silo-r8-01a0a5b9/silo-v2','server',f'--address=127.0.0.1:{port}','--read-header-timeout=5s','--idle-timeout=5s',str(root/'data')],env=env,stdout=log,stderr=subprocess.STDOUT) + try: + for _ in range(100): + try: + with urllib.request.urlopen(url+'/minio/health/live',timeout=1):break + except Exception:time.sleep(.1) + cli=['/opt/homebrew/bin/mcli','--config-dir',str(root/'mcli')] + for args in [['mb','silo/testbucket'],['anonymous','set','public','silo/testbucket']]: + r=subprocess.run(cli+args,env=env,text=True,stdout=subprocess.PIPE,stderr=subprocess.STDOUT);assert r.returncode==0,r.stdout + req=urllib.request.Request(url+'/testbucket/testobject',data=b'x'*30,method='PUT') + try: + with urllib.request.urlopen(req,timeout=10) as response:result={'status':response.status,'body':response.read().decode()} + except urllib.error.HTTPError as error:result={'status':error.code,'body':error.read().decode()} + print(json.dumps(result,indent=2)) + finally: + proc.send_signal(signal.SIGTERM) + try:proc.wait(timeout=10) + except subprocess.TimeoutExpired:proc.kill();proc.wait() diff --git a/docs/investigations/r8/evidence/tidy-diff.log b/docs/investigations/r8/evidence/tidy-diff.log new file mode 100644 index 000000000..e69de29bb diff --git a/docs/investigations/r8/evidence/tidy-final.log b/docs/investigations/r8/evidence/tidy-final.log new file mode 100644 index 000000000..73a05a2ff --- /dev/null +++ b/docs/investigations/r8/evidence/tidy-final.log @@ -0,0 +1,3 @@ +$ go mod tidy -diff + +exit_code=0 diff --git a/docs/investigations/r8/evidence/vet-final.log b/docs/investigations/r8/evidence/vet-final.log new file mode 100644 index 000000000..cf7c47989 --- /dev/null +++ b/docs/investigations/r8/evidence/vet-final.log @@ -0,0 +1,3 @@ +$ go vet ./internal/deadlineconn ./internal/http + +exit_code=0 diff --git a/docs/investigations/r8/evidence/vet.log b/docs/investigations/r8/evidence/vet.log new file mode 100644 index 000000000..e69de29bb diff --git a/docs/investigations/r8/evidence/whitespace-final.log b/docs/investigations/r8/evidence/whitespace-final.log new file mode 100644 index 000000000..9decb7c54 --- /dev/null +++ b/docs/investigations/r8/evidence/whitespace-final.log @@ -0,0 +1,3 @@ +$ git diff --check + +exit_code=0 diff --git a/docs/investigations/r8/final-source-manifest.json b/docs/investigations/r8/final-source-manifest.json new file mode 100644 index 000000000..5cbb4ebdd --- /dev/null +++ b/docs/investigations/r8/final-source-manifest.json @@ -0,0 +1,26 @@ +{ + "created_at_utc": "2026-09-15T16:17:15.174123+00:00", + "baseline_sha": "9ebe81c1b3611f9cc73e676b5b741c2be62c467a", + "branch": "codex/r8-http-header-deadline", + "binding": "This manifest is committed with the source files it hashes; final local commit ID is reported in delivery.", + "accepted_plan": "plan-v2.md", + "plan_sha256": "426127ed9fb08aeddf8259ebdc4b1c24ebec8cda751a970ed99338a44b065f4c", + "production_diff_sha256": "92d3f43aa2bb4c636da830fc00c8a2939524d69bb8ab4bda4c4b284f043d3cf3", + "production_files": { + "cmd/common-main.go": "f8777fe8a07d175aceee07b4dd13792b2384449c404c004c38a06893be997843", + "cmd/server-main.go": "04c265de211412ba0297396928096d7f2d971244a957a3126154846035263514", + "internal/deadlineconn/deadlineconn.go": "b9272ef640f1d4403b3d0af6cdbaba9186c51ad9a0226dfe449e8ef738e1ec4b", + "internal/http/listener.go": "49628575367f6ab9b6986caf594726d74d370f7d2ac4eed582903600b6eb3fa2", + "internal/http/server.go": "b7b0355f2781f8c5f7c77bc910cd4180cd3e5f22a87de41bd35ef119d36b4cdf" + }, + "test_files": { + "cmd/server_deadline_config_test.go": "1013157f83baa5f7882ec2d41c7b1fccb9e05fb418d0fa61263953037c9698c4", + "internal/deadlineconn/deadlineconn_strict_test.go": "f405690c9ff044595f48323d68f4a9b33ce695b3ad6820f54f17151067bfae5e", + "internal/http/dial_deadline_linux_test.go": "0939d05b72a09760d53fcdf249775989e3f89bca824b9961d0b2a657ebfdf41e", + "internal/http/server_deadline_test.go": "a6e687b3904a876fa92a4c5b86453159f3e5a38a4b9412dc213c7772f47fbf0b" + }, + "dependencies_unchanged": { + "go.mod": "8351bb86377a8deed95fd0bd67c1e363e11d33f8da7631cea8f68cea11259976", + "go.sum": "2287ce975cab91f92f59a3b6e164d49325f141f35d480b8c3d23f796df3772b2" + } +} diff --git a/docs/investigations/r8/plan-v1.md b/docs/investigations/r8/plan-v1.md new file mode 100644 index 000000000..7a1aa9e3c --- /dev/null +++ b/docs/investigations/r8/plan-v1.md @@ -0,0 +1,82 @@ +# R8 plan v1 — phase-aware read deadline preservation + +## Baseline and authorization + +- Worktree: `/Users/vonng/.codex/worktrees/3bae/silo`; HEAD and origin/main: `9ebe81c1b3611f9cc73e676b5b741c2be62c467a` (live checked). +- Go: go1.27.1 darwin/arm64; CLI: Claude Code 2.1.270. No production implementation has started. +- Root AGENTS.md is ignored and was absent from the generated worktree; copied the actual primary checkout guide into this worktree. Maintained PGSTY stack and inexpensive compatibility govern this repair. +- GitHub open PRs: #184 and #187, neither changes R8. No R8 implementation PR found. +- Parent workflow permits research and temporary reproductions before consensus; ordinary implementation after explicit same-version Codex/Opus consensus. Merge, publish and deployment excluded. + +## Evidence and cause + +`evidence/baseline.log` is a fresh real TCP run on this SHA with the parent's temporary Go overlay. Header limit 100ms, connection idle 2s, header finishes at 400ms. Standard net/http rejects; SILO returns HTTP 204. This is inherited DeadlineConn behavior, not a new SILO option. + +`DeadlineConn.Read` calls `setReadDeadline`, replacing an explicit future deadline by now+readIdle+250ms. Past deadlines and explicit zero are already special (abort and disable respectively). `cmd/server-main.go` sets ReadTimeout and WriteTimeout to IdleTimeout as well as setting TCPOptions.IdleTimeout. Therefore globally preserving every explicit deadline makes normal HTTP/1 uploads time out at the idle duration in total. + +Current Go source examined: `/opt/homebrew/Cellar/go/1.27.1/libexec/src/net/http/server.go` and `net/http/internal/http2/server.go`. Public semantics: https://pkg.go.dev/net/http#Server and https://pkg.go.dev/net#Conn . + +- StateNew occurs before serving; TLS read timeout is min positive header/read/write timeouts. +- HTTP/1 reads under the configured header deadline. After parsing/validation, readRequest sets wholeReqDeadline based on ReadTimeout, then the serving loop calls StateActive (also on malformed input, followed by rejection). +- With a body, net/http registers an EOF callback; without a body it immediately calls startBackgroundRead. That clears the read deadline. Body EOF also clears the deadline before launching background disconnect detection. +- StateIdle happens after finishRequest/abortPendingRead, before setting keep-alive deadline and peeking the next request. The next header deadline is then installed. Thus StateIdle can restore strictness for both waiting and reading the next header. +- TLS wrapping remains `tls.Conn -> DeadlineConn -> TCPConn`; use tls.Conn.NetConn to reach the wrapper in the state callback. +- TLS can negotiate h2 (`cmd/utils.go`). HTTP/2 uses per-stream ReadTimeout (currently an absolute body limit), clears the underlying read deadline after handshake, and has independent connection state hooks. Do not impose a connection-wide request timer on multiplexed h2. Preserve this existing behavior; test HTTP/2 smoke and record that its preexisting absolute stream timeout is not fixed by this R8 HTTP/1 change. + +## Options and decision + +1. Globally clamp all future deadlines: too broad; turns ReadTimeout=idle into a total HTTP/1 upload limit, changes Linux internode caller behavior. +2. Remove ReadTimeout and interpret zero as rolling idle: wrong; zero is also how net/http disables background read deadlines, and a long handler could be spuriously canceled. H2 loses its existing per-stream read timeout. +3. Body-reader/ResponseController timer wrappers: possible but introduce body/drain/EOF bookkeeping, affect buffered/chunked reads, require h2-specific treatment and rewrite behavior beyond the header bug. +4. Selected: opt-in preservation of explicit read deadlines during HTTP/1 header/keep-alive/TLS phases; retain legacy rolling reads during the HTTP/1 body/handler phase. Keep generic DeadlineConn default and existing production timeout configuration. + +## Concrete implementation + +### internal/deadlineconn/deadlineconn.go + +- Add mutex-protected last explicit read deadline and a `readDeadlineStrict` boolean (default false). Existing constructors and internode callers retain rolling semantics. +- Store the explicit read timestamp in SetReadDeadline and SetDeadline; preserve zero/abort flags and immediate forwarding to net.Conn. +- Expose a small `SetReadDeadlineStrict(bool)` method, documented as toggling whether automatic idle renewal may extend explicit deadlines. Lock the same mutex and reset readSetAt so the next read applies the current mode. +- In setReadDeadline, recheck abort/inf under the lock, keep existing throttling and idle slack, and when strict and explicit is nonzero take min(idleCandidate, explicit). Never add 250ms slack to the absolute limit. Deadline updates themselves are never throttled. +- Writes remain unchanged. Expired explicit times remain expired when strict. Existing explicit zero always disables read renewal, and explicit past time keeps abort semantics. + +### internal/http/listener.go + +- Keep the accepted concrete type *DeadlineConn, read/write idle durations and unwrap compatibility. +- Enable strict read mode before returning each newly accepted connection. This covers initial HTTP headers and TLS handshake reads, including a normal net/http Server using this listener. + +### internal/http/server.go + +- In Init, compose (do not drop) the caller's existing ConnState hook. +- Find the *DeadlineConn, unwrapping one *tls.Conn with NetConn when necessary. +- For HTTP/1 StateActive: turn strict mode off, retaining the current rolling ReadTimeout=idle semantics for bodies and long uploads. Do so before the caller's state hook. +- For StateNew/StateIdle: turn strict mode on. Ignore other states. +- For negotiated HTTP/2, skip per-request phase changes; after handshake raw zero deadlines stay disabled, and stream timeouts remain native net/http behavior. +- Do not remove ReadTimeout/WriteTimeout or change flags. Add a short explanatory comment around the production timeout setup if useful. + +## Failure paths and compatibility + +- Slow/incomplete headers (including byte trickles across many 250ms update intervals): explicit cap must hold. +- Continuous uploads: request duration can exceed idle; underlying socket reads renew with existing +250ms slack. A truly stalled socket body read times out. No promise of application CPU/storage wait deadlines. +- Empty and completed bodies: net/http's zero deadline must keep disconnect detection from timing out otherwise active long handlers. +- Keep-alive: caller sets idle wait, then next header absolute timeout; both remain capped after StateIdle. +- TLS: read handshake cap cannot be renewed; completed handshake transitions into fresh HTTP header cap. Write-side handshake deadline behavior is unchanged/out of this read-side defect. +- Chunked encoding/Expect 100-continue/early close: preserve existing generic read path; exercise actual requests. +- Explicit body deadlines retain historical SILO behavior (future can renew; past abort works); this patch does not promise a generic net.Conn behavior migration. +- Linux internode DriveOPTimeout caller uses default mode false; same read/write/zero/abort behavior. Grid raw upgrade still unwraps the same concrete type; TLS upgraded connections stay in legacy body mode. No data/format migration or stored-state rewriting. + +## Validation matrix + +1. Deterministic recording net.Conn tests: strict future cap, strict far-future deadline bounded by idle, no slack on cap, mode transition, default rolling behavior, SetDeadline/read direction separation, zero disable, past cancellation, explicit updates resetting throttle, expired future deadline, concurrent Read/SetReadDeadline (race). +2. Real TCP HTTP/1: initial slow header; byte trickle longer than header timeout; successful ordinary request; keep-alive idle and second slow header; continuous long body exceeding scaled idle; truly idle body; chunked body and Expect 100-continue; early body close followed by next request where supported; handler spending >idle after no body/after EOF without request context cancellation. +3. Run HTTP/1 body and header matrix through TLS (real tls.Client), plus stalled TLS ClientHello/handshake. Test existing ConnState hook chaining. +4. HTTP/2 over real TLS smoke and native timeout preservation; explicitly no cross-stream socket timeout introduced. +5. DeadlineConn existing tests and internal/http full suite under race, targeted grid roundtrips/disconnect; Linux compile for modified packages and default caller behavior fixture (Darwin does not exercise dial_linux at runtime). +6. One explicit >30s continuously progressing upload using production default idle, both cleartext and TLS HTTP/1 if feasible, to guard against an unintended total-30s cap. This may be opt-in to keep the routine suite fast, but run it for acceptance. +7. Scope tests, gofmt, git diff --check. If Linux runtime available without unrelated environment changes, run focused tests there; otherwise report compile vs runtime separately. + +## Work and delivery + +Estimate: 1–3 engineer days including design review and matrix; expected production diff tens of lines plus tests. Isolated `codex/` branch after agreement. Save raw Opus JSONL and stderr outside Git; keep prompt, exact plan SHA256, actual assistant model identity, effort, reviewer text, issue dispositions and consensus record here. No commit/push required for local review, and no merge/release/deploy is authorized in this phase. + +Codex position: recommend option 4. Opus must independently verify the phase ordering, EOF/background behavior, keep-alive, TLS/H2 and shared-caller boundaries. Any blocking disagreement requires a revised plan and another review; a failed/limited/wrong-model response is not consensus. diff --git a/docs/investigations/r8/plan-v2.md b/docs/investigations/r8/plan-v2.md new file mode 100644 index 000000000..43d4f25b9 --- /dev/null +++ b/docs/investigations/r8/plan-v2.md @@ -0,0 +1,115 @@ +# R8 plan v2 — configuration propagation and phase-aware read deadline preservation + +## Baseline and authorization + +- Worktree: `/Users/vonng/.codex/worktrees/3bae/silo`; HEAD and origin/main: `9ebe81c1b3611f9cc73e676b5b741c2be62c467a` (live checked). +- Go: go1.27.1 darwin/arm64; CLI: Claude Code 2.1.270. V1 connection implementation exists after v1 consensus; the additional configuration binding below is NOT implemented yet. +- Root AGENTS.md is ignored and was absent from the generated worktree; copied the actual primary checkout guide into this worktree. Maintained PGSTY stack and inexpensive compatibility govern this repair. +- GitHub open PRs: #184 and #187, neither changes R8. No R8 implementation PR found. Local branch is `codex/r8-http-header-deadline`; no commits/push/merge/release/deployment. +- Parent workflow permits research and temporary reproductions before consensus; ordinary implementation after explicit same-version Codex/Opus consensus. Merge, publish and deployment excluded. + +## V2 revision boundary and new evidence + +This v2 is the complete plan: retain the agreed v1 connection design below and add the missing CLI/env-to-server-context assignment. V1 approval does not authorize this newly discovered binding change. The parent explicitly confirmed that v2 needs its own same-version Opus agreement before the binding is edited. + +Fresh temporary CLI tests are saved in `evidence/config-baseline.log` and `evidence/config_baseline_test.go.txt`. They invoke the real `serverCmd.Flags` through a `cli.App`, then the actual `buildServerCtxt`; no production configuration code was changed to obtain the evidence. The CLI reports default=30s, flag=100ms, env=170ms, flag-over-env=80ms, YAML+flag=100ms, negative=-1s. The context remains 0 in all those cases. Explicit zero naturally remains 0. Initial fixture mistakes (wrong variable name, empty env vs absent env) are saved separately and are not product findings. + +Root cause: `cmd/common-main.go:447` transfers IdleTimeout but omits ReadHeaderTimeout. The only production reads of that field are the Server.UseReadHeaderTimeout call. Thus after the v1 connection fix, a real process still ignores the configured header limit and Go falls back from zero ReadHeaderTimeout to ReadTimeout=idle. Defaults accidentally appear correct only when the two durations match (30s). + +V2 addition is one assignment: `ctxt.ReadHeaderTimeout = ctx.Duration("read-header-timeout")` next to IdleTimeout. No new option/schema, dependency, storage change, or new timeout policy. Preserve the current cli library's precedence and error checking, including the fact that an explicitly empty environment duration is invalid. + +## Evidence and cause + +`evidence/baseline.log` is a fresh real TCP run on this SHA with the parent's temporary Go overlay. Header limit 100ms, connection idle 2s, header finishes at 400ms. Standard net/http rejects; SILO returns HTTP 204. This is inherited DeadlineConn behavior, not a new SILO option. + +`DeadlineConn.Read` calls `setReadDeadline`, replacing an explicit future deadline by now+readIdle+250ms. Past deadlines and explicit zero are already special (abort and disable respectively). `cmd/server-main.go` sets ReadTimeout and WriteTimeout to IdleTimeout as well as setting TCPOptions.IdleTimeout. Therefore globally preserving every explicit deadline makes normal HTTP/1 uploads time out at the idle duration in total. + +Current Go source examined: `/opt/homebrew/Cellar/go/1.27.1/libexec/src/net/http/server.go` and `net/http/internal/http2/server.go`. Public semantics: https://pkg.go.dev/net/http#Server and https://pkg.go.dev/net#Conn . + +- StateNew occurs before serving; TLS read timeout is min positive header/read/write timeouts. +- HTTP/1 reads under the configured header deadline. After parsing/validation, readRequest sets wholeReqDeadline based on ReadTimeout, then the serving loop calls StateActive (also on malformed input, followed by rejection). +- With a body, net/http registers an EOF callback; without a body it immediately calls startBackgroundRead. That clears the read deadline. Body EOF also clears the deadline before launching background disconnect detection. +- StateIdle happens after finishRequest/abortPendingRead, before setting keep-alive deadline and peeking the next request. The next header deadline is then installed. Thus StateIdle can restore strictness for both waiting and reading the next header. +- TLS wrapping remains `tls.Conn -> DeadlineConn -> TCPConn`; use tls.Conn.NetConn to reach the wrapper in the state callback. +- TLS can negotiate h2 (`cmd/utils.go`). HTTP/2 uses per-stream ReadTimeout (currently an absolute body limit), clears the underlying read deadline after handshake, and has independent connection state hooks. Do not impose a connection-wide request timer on multiplexed h2. Preserve this existing behavior; test HTTP/2 smoke and record that its preexisting absolute stream timeout is not fixed by this R8 HTTP/1 change. + +## Options and decision + +1. Globally clamp all future deadlines: too broad; turns ReadTimeout=idle into a total HTTP/1 upload limit, changes Linux internode caller behavior. +2. Remove ReadTimeout and interpret zero as rolling idle: wrong; zero is also how net/http disables background read deadlines, and a long handler could be spuriously canceled. H2 loses its existing per-stream read timeout. +3. Body-reader/ResponseController timer wrappers: possible but introduce body/drain/EOF bookkeeping, affect buffered/chunked reads, require h2-specific treatment and rewrite behavior beyond the header bug. +4. Selected: opt-in preservation of explicit read deadlines during HTTP/1 header/keep-alive/TLS phases; retain legacy rolling reads during the HTTP/1 body/handler phase. Keep generic DeadlineConn default and existing production timeout configuration. + +## Concrete implementation + +### cmd/common-main.go (V2 addition, pending new consensus) + +- Add the single assignment shown above in buildServerCtxt beside the other TCP/HTTP duration options. +- Default 30s becomes effective independently of idle. An explicit positive header timeout is honored; flag precedence over env stays in the CLI. Zero retains Go's fallback to ReadTimeout; a negative value disables Go's header cap. Socket idle and past/zero DeadlineConn rules remain as described below. +- YAML merging does not contain or overwrite these duration fields; do not invent a YAML option. Test that CLI/env values survive the existing merge. +- This expands the user-visible behavior changes listed in v1: previously ignored defaults/overrides now take effect. With idle customized or disabled, header timeout can therefore differ from prior observed behavior, exactly as the declared option intends. No new migration or confirmation required for the ordinary code fix after consensus. + +### internal/deadlineconn/deadlineconn.go + +- Add mutex-protected last explicit read deadline and a `readDeadlineStrict` boolean (default false). Existing constructors and internode callers retain rolling semantics. +- Store the explicit read timestamp in SetReadDeadline and SetDeadline; preserve zero/abort flags and immediate forwarding to net.Conn. +- Expose a small `SetReadDeadlineStrict(bool)` method, documented as toggling whether automatic idle renewal may extend explicit deadlines. Lock the same mutex and reset readSetAt so the next read applies the current mode. +- In setReadDeadline, recheck abort/inf under the lock, keep existing throttling and idle slack, and when strict and explicit is nonzero take min(idleCandidate, explicit). Never add 250ms slack to the absolute limit. Deadline updates themselves are never throttled. +- Writes remain unchanged. Expired explicit times remain expired when strict. Existing explicit zero always disables read renewal, and explicit past time keeps abort semantics. + +### internal/http/listener.go + +- Keep the accepted concrete type *DeadlineConn, read/write idle durations and unwrap compatibility. +- Enable strict read mode before returning each newly accepted connection. This covers initial HTTP headers and TLS handshake reads, including a normal net/http Server using this listener. + +### internal/http/server.go + +- In Init, compose (do not drop) the caller's existing ConnState hook. +- Find the *DeadlineConn, unwrapping one *tls.Conn with NetConn when necessary. +- For HTTP/1 StateActive: turn strict mode off, retaining the current rolling ReadTimeout=idle semantics for bodies and long uploads. Do so before the caller's state hook. +- For StateNew/StateIdle: turn strict mode on. Ignore other states. +- For negotiated HTTP/2, skip per-request phase changes; after handshake raw zero deadlines stay disabled, and stream timeouts remain native net/http behavior. +- Do not remove ReadTimeout/WriteTimeout or change flags. Add a short explanatory comment around the production timeout setup if useful. + +## Failure paths and compatibility + +- Slow/incomplete headers (including byte trickles across many 250ms update intervals): explicit cap must hold. +- Continuous uploads: request duration can exceed idle; underlying socket reads renew with existing +250ms slack. A truly stalled socket body read times out. No promise of application CPU/storage wait deadlines. +- Empty and completed bodies: net/http's zero deadline must keep disconnect detection from timing out otherwise active long handlers. +- Keep-alive: caller sets idle wait, then next header absolute timeout; both remain capped after StateIdle. +- TLS: read handshake cap cannot be renewed; completed handshake transitions into fresh HTTP header cap. Write-side handshake deadline behavior is unchanged/out of this read-side defect. +- Chunked encoding/Expect 100-continue/early close: preserve existing generic read path; exercise actual requests. +- Explicit body deadlines retain historical SILO behavior (future can renew; past abort works); this patch does not promise a generic net.Conn behavior migration. +- Linux internode DriveOPTimeout caller uses default mode false; same read/write/zero/abort behavior. Grid raw upgrade still unwraps the same concrete type; TLS upgraded connections stay in legacy body mode. No data/format migration or stored-state rewriting. + +## Validation matrix + +V2 adds two separately evidenced chains: + +- Configuration: use the real CLI parser and actual buildServerCtxt; cover default, explicit flag, environment, flag-over-env, YAML merge, zero and negative duration. Run focused `./cmd` tests. Verify parser output and context output independently. +- Compiled process: build SILO on this exact worktree; launch disposable single-disk servers bound only to 127.0.0.1 with browser disabled and isolated test credentials/data. For both flag and env source, set header=100ms, idle=2s, finish a real health request header after 400ms. Preserve pre-binding evidence (v1 binary still accepts), and require the v2 binary to reject while a subsequent ordinary health request still succeeds. Stop only the child processes this test started. Use `evidence/runtime_probe.py`, retaining binary SHA256 and logs in the task's temp directory. This is local runtime verification, not deployment or S3 production acceptance. + +The retained connection matrix follows: + +1. Deterministic recording net.Conn tests: strict future cap, strict far-future deadline bounded by idle, no slack on cap, mode transition, default rolling behavior, SetDeadline/read direction separation, zero disable, past cancellation, explicit updates resetting throttle, expired future deadline, concurrent Read/SetReadDeadline (race). +2. Real TCP HTTP/1: initial slow header; byte trickle longer than header timeout; successful ordinary request; keep-alive idle and second slow header; continuous long body exceeding scaled idle; truly idle body; chunked body and Expect 100-continue; early body close followed by next request where supported; handler spending >idle after no body/after EOF without request context cancellation. +3. Run HTTP/1 body and header matrix through TLS (real tls.Client), plus stalled TLS ClientHello/handshake. Test existing ConnState hook chaining. +4. HTTP/2 over real TLS smoke and native timeout preservation; explicitly no cross-stream socket timeout introduced. +5. DeadlineConn existing tests and internal/http full suite under race, targeted grid roundtrips/disconnect; Linux compile for modified packages and default caller behavior fixture (Darwin does not exercise dial_linux at runtime). +6. One explicit >30s continuously progressing upload using production default idle, both cleartext and TLS HTTP/1 if feasible, to guard against an unintended total-30s cap. This may be opt-in to keep the routine suite fast, but run it for acceptance. +7. Scope tests, gofmt, git diff --check. If Linux runtime available without unrelated environment changes, run focused tests there; otherwise report compile vs runtime separately. + +## Work and delivery + +Estimate: 1–3 engineer days including design review and matrix; expected production diff tens of lines plus tests. Isolated `codex/` branch after agreement. Save raw Opus JSONL and stderr outside Git; keep prompt, exact plan SHA256, actual assistant model identity, effort, reviewer text, issue dispositions and consensus record here. No commit/push required for local review, and no merge/release/deploy is authorized in this phase. + +Codex position: recommend option 4 plus the one-line configuration binding; both are needed for the configured HTTP header deadline to work in the actual server. Opus must independently verify the phase ordering, EOF/background behavior, keep-alive, TLS/H2 and shared-caller boundaries. Any blocking disagreement requires a revised plan and another review; a failed/limited/wrong-model response is not consensus. + +## Current v1 implementation evidence and review refinements + +- Actual initial 100ms/400ms standard/SILO TCP comparison passes after the connection fix (`original-reproducer-fixed.log`). +- macOS and Linux arm64 full `internal/deadlineconn` + `internal/http` race suites pass. Linux explicitly exercises the optional DriveOPTimeout dialer. Grid roundtrip/disconnect and focused go vet pass. +- With default 30s idle, plaintext and TLS HTTP/1 uploads AND downloads continued for 33s and succeeded (`default-30s-transfers.log`). +- Nine v1 review notes are individually resolved in `consensus.md`: pipelined parsing test; explicit h2-only TLS negotiation and HTTP/2 response assertions; background EOF and hijack zero semantics; long download coverage; unchanged TLS write-handshake limitation; default internode DriveOPTimeout currently commented out. +- The H2 test initially fell back to H1, then the native read/write timers produced differing errors when racing. Both fixture problems were fixed, retained as raw evidence, and the final test isolates the read timer by clearing the stream write deadline; checks concurrent healthy streams and reuse of the same TLS connection. No production H2 behavior changed. +- V1 production diff remains exactly the previously reviewed phase-aware design. V2 will not be declared complete until both configuration and connection chains pass. Remaining boundaries: HTTP/2's preexisting absolute per-stream timeout and the legacy TLS handshake write deadline behavior remain outside R8. diff --git a/docs/investigations/r8/review/final-production.patch b/docs/investigations/r8/review/final-production.patch new file mode 100644 index 000000000..49b8aed0a --- /dev/null +++ b/docs/investigations/r8/review/final-production.patch @@ -0,0 +1,149 @@ +diff --git a/cmd/common-main.go b/cmd/common-main.go +index 393ab17d0..c127759fc 100644 +--- a/cmd/common-main.go ++++ b/cmd/common-main.go +@@ -445,6 +445,7 @@ func buildServerCtxt(ctx *cli.Context, ctxt *serverCtxt) (err error) { + ctxt.SendBufSize = ctx.Int("send-buf-size") + ctxt.RecvBufSize = ctx.Int("recv-buf-size") + ctxt.IdleTimeout = ctx.Duration("idle-timeout") ++ ctxt.ReadHeaderTimeout = ctx.Duration("read-header-timeout") + ctxt.UserTimeout = ctx.Duration("conn-user-timeout") + + if conf := ctx.String("config"); len(conf) > 0 { +diff --git a/cmd/server-main.go b/cmd/server-main.go +index 48ed0f87d..c8a3a19ca 100644 +--- a/cmd/server-main.go ++++ b/cmd/server-main.go +@@ -901,6 +901,8 @@ func serverMain(ctx *cli.Context) { + close(globalGridStart) + close(globalLockGridStart) + ++ // The HTTP/1 listener preserves absolute header deadlines and renews the ++ // body read/write idle limits, so transfers may outlast IdleTimeout. + httpServer := xhttp.NewServer(getServerListenAddrs()). + UseHandler(setCriticalErrorHandler(corsHandler(handler))). + UseTLSConfig(newTLSConfig(getCert)). +diff --git a/internal/deadlineconn/deadlineconn.go b/internal/deadlineconn/deadlineconn.go +index 95bb43eff..5fa5a1403 100644 +--- a/internal/deadlineconn/deadlineconn.go ++++ b/internal/deadlineconn/deadlineconn.go +@@ -34,6 +34,8 @@ type DeadlineConn struct { + net.Conn + readDeadline time.Duration // sets the read deadline on a connection. + readSetAt time.Time ++ readExplicit time.Time // last deadline requested by the caller. ++ readDeadlineStrict bool // idle renewal must not extend readExplicit. + writeDeadline time.Duration // sets the write deadline on a connection. + writeSetAt time.Time + abortReads, abortWrites atomic.Bool // A deadline was set to indicate caller wanted the conn to time out. +@@ -59,17 +61,31 @@ func (c *DeadlineConn) setReadDeadline() { + + c.mu.Lock() + defer c.mu.Unlock() +- if c.abortReads.Load() { ++ if c.abortReads.Load() || c.infReads.Load() { + return + } + + now := time.Now() + if now.Sub(c.readSetAt) > updateInterval { +- c.Conn.SetReadDeadline(now.Add(c.readDeadline + updateInterval)) ++ deadline := now.Add(c.readDeadline + updateInterval) ++ if c.readDeadlineStrict && !c.readExplicit.IsZero() && c.readExplicit.Before(deadline) { ++ deadline = c.readExplicit ++ } ++ c.Conn.SetReadDeadline(deadline) + c.readSetAt = now + } + } + ++// SetReadDeadlineStrict controls whether idle renewal may extend a deadline set ++// by SetReadDeadline or SetDeadline. The default is false. Explicit zero and ++// past deadlines retain their disable/cancel semantics in either mode. ++func (c *DeadlineConn) SetReadDeadlineStrict(strict bool) { ++ c.mu.Lock() ++ defer c.mu.Unlock() ++ c.readDeadlineStrict = strict ++ c.readSetAt = time.Time{} ++} ++ + func (c *DeadlineConn) setWriteDeadline() { + // Do not set a Write deadline, if upstream wants to cancel all reads. + if c.writeDeadline <= 0 || c.abortWrites.Load() || c.infWrites.Load() { +@@ -115,6 +131,7 @@ func (c *DeadlineConn) SetDeadline(t time.Time) error { + defer c.mu.Unlock() + + c.readSetAt = time.Time{} ++ c.readExplicit = t + c.writeSetAt = time.Time{} + c.abortReads.Store(!t.IsZero() && time.Until(t) < 0) + c.abortWrites.Store(!t.IsZero() && time.Until(t) < 0) +@@ -132,6 +149,7 @@ func (c *DeadlineConn) SetReadDeadline(t time.Time) error { + c.abortReads.Store(!t.IsZero() && time.Until(t) < 0) + c.infReads.Store(t.IsZero()) + c.readSetAt = time.Time{} ++ c.readExplicit = t + return c.Conn.SetReadDeadline(t) + } + +diff --git a/internal/http/listener.go b/internal/http/listener.go +index bc6de3af9..14d34f6ea 100644 +--- a/internal/http/listener.go ++++ b/internal/http/listener.go +@@ -70,7 +70,10 @@ func (listener *httpListener) Accept() (conn net.Conn, err error) { + if result.err != nil { + return nil, result.err + } +- return deadlineconn.New(result.conn).WithReadDeadline(listener.opts.IdleTimeout).WithWriteDeadline(listener.opts.IdleTimeout), result.err ++ conn := deadlineconn.New(result.conn).WithReadDeadline(listener.opts.IdleTimeout).WithWriteDeadline(listener.opts.IdleTimeout) ++ // Server.Init switches to rolling reads only after HTTP/1 headers are read. ++ conn.SetReadDeadlineStrict(true) ++ return conn, nil + case <-listener.ctxDoneCh: + } + return nil, syscall.EINVAL +diff --git a/internal/http/server.go b/internal/http/server.go +index 2934fda6c..d9c19a33f 100644 +--- a/internal/http/server.go ++++ b/internal/http/server.go +@@ -29,6 +29,7 @@ import ( + "time" + + "github.com/dustin/go-humanize" ++ "github.com/minio/minio/internal/deadlineconn" + ) + + var ( +@@ -123,6 +124,32 @@ func (srv *Server) Init(listenCtx context.Context, listenErrCallback func(listen + srv.listener = listener + srv.listenerMutex.Unlock() + ++ connState := srv.ConnState ++ srv.ConnState = func(conn net.Conn, state http.ConnState) { ++ raw := conn ++ if tlsConn, ok := raw.(*tls.Conn); ok { ++ if tlsConn.ConnectionState().NegotiatedProtocol == "h2" { ++ // HTTP/2 owns its stream deadlines; do not change the connection. ++ raw = nil ++ } else { ++ raw = tlsConn.NetConn() ++ } ++ } ++ if dc, ok := raw.(*deadlineconn.DeadlineConn); ok { ++ switch state { ++ case http.StateNew, http.StateIdle: ++ dc.SetReadDeadlineStrict(true) ++ case http.StateActive: ++ // net/http has finished reading the headers, including buffered ++ // requests. Keep ReadTimeout as a rolling idle limit for uploads. ++ dc.SetReadDeadlineStrict(false) ++ } ++ } ++ if connState != nil { ++ connState(conn, state) ++ } ++ } ++ + var l net.Listener = listener + if tlsConfig != nil { + l = tls.NewListener(listener, tlsConfig) diff --git a/docs/investigations/r8/review/implementation-dispositions.md b/docs/investigations/r8/review/implementation-dispositions.md new file mode 100644 index 000000000..4aaba21f1 --- /dev/null +++ b/docs/investigations/r8/review/implementation-dispositions.md @@ -0,0 +1,16 @@ +# V1 implementation-review dispositions (carried into v2) + +The real Opus implementation review returned REQUEST_CHANGES with one test/dependency blocker and no production-behavior defect. This is not recorded as implementation approval. + +- B1 accepted: the test's direct x/net/http2 import would change the indirect annotation during tidy. Removed that import and use the Go1.27 standard-library HTTP/2 client with an explicit Protocols set containing only HTTP/2. Keep the server's HTTP/1-first ALPN order, and assert actual h2 negotiation and HTTP/2.0 response. No go.mod/go.sum change. Revalidation and dependency hygiene follow. +- N1 accepted: add repeated-renewal recording-connection test across three actual 300ms intervals, without resetting the cap. +- N2 noted: the existing concurrent test is a race probe; semantic assertions are supplied by separate deadline and HTTP tests, not inferred from that probe. +- N3 accepted for h2: give the fixture a 3s server keep-alive idle period while retaining 400ms ReadTimeout and isolating the native read timer. Small scaled streaming tests retain their prior margin, separately backed by 33s production-default runs. +- N4 retained: local conn shadowing is legal, no behavior concern; avoid unrelated cleanup. +- N5 retained: redundant deadline calls are low-cost and intentional; no performance redesign. +- N6 retained: explicit h2 exclusion documents the multiplexed boundary and is safe; zero deadlines remain the fundamental compatibility mechanism. +- N7/N8 accepted: release notes distinguish header/handshake-read tightening from unchanged h2 stream and TLS handshake-write limitations. +- N9 resolved with direct final macOS/Linux race logs, then recheck modified fixtures after this review's changes. +- N10 accepted: record explicit command and exit status for vet and final checks, not only empty stdout. + +The new common-main.go binding is still unmodified and awaits complete-plan v2 agreement. V1 approval cannot cover it. V2 review must also verify B1's chosen no-new-dependency resolution. diff --git a/docs/investigations/r8/review/implementation-files.json b/docs/investigations/r8/review/implementation-files.json new file mode 100644 index 000000000..74920bd2f --- /dev/null +++ b/docs/investigations/r8/review/implementation-files.json @@ -0,0 +1,9 @@ +{ + "internal/deadlineconn/deadlineconn.go": "b9272ef640f1d4403b3d0af6cdbaba9186c51ad9a0226dfe449e8ef738e1ec4b", + "internal/http/listener.go": "49628575367f6ab9b6986caf594726d74d370f7d2ac4eed582903600b6eb3fa2", + "internal/http/server.go": "b7b0355f2781f8c5f7c77bc910cd4180cd3e5f22a87de41bd35ef119d36b4cdf", + "cmd/server-main.go": "04c265de211412ba0297396928096d7f2d971244a957a3126154846035263514", + "internal/deadlineconn/deadlineconn_strict_test.go": "fec37273f338d3cb6f56c71eb6d8d677f8d5e4c2a7c55bc5ab2e64c56ad4a50b", + "internal/http/server_deadline_test.go": "c56460fd8258a7fe8cf25d88fb2030ecfee0e142e0559fad0eb1a05d43d27c0f", + "internal/http/dial_deadline_linux_test.go": "0939d05b72a09760d53fcdf249775989e3f89bca824b9961d0b2a657ebfdf41e" +} diff --git a/docs/investigations/r8/review/implementation-opus.md b/docs/investigations/r8/review/implementation-opus.md new file mode 100644 index 000000000..3ecd1815a --- /dev/null +++ b/docs/investigations/r8/review/implementation-opus.md @@ -0,0 +1,78 @@ +I'll start by reading the review artifacts and the plan documents. + +已完成只读复核。我独立读取了 Go 1.27.1 的 `net/http` 源码、全部 7 个当前实现文件、既有测试与全部 evidence 日志。 + +--- + +# R8 实现评审 — Opus 5.0(只读,实测复核) + +## 结论:**REQUEST_CHANGES** + +生产代码(`deadlineconn.go` / `listener.go` / `server.go` / `server-main.go`)我**未发现任何行为性缺陷**,与已同意的 v1(SHA256 `7cb6…6366`)逐条吻合。唯一阻塞项在**测试侧引入的依赖,会确定性地打红仓库自身的 CI 质量门**(`make check-gen`),一行 `go.mod` 即可修复,不需要改动任何生产语义。 + +> 诚实性声明:① 我**没有执行任何测试、`go vet`、`gofmt`、`go mod tidy` 或哈希计算**(本会话仅 Read/Grep/Glob);所有 SHA256(plan、production diff、implementation-files.json)均沿用你提供的值,**未独立重算**。② 我逐字节比对了 `review/implementation.patch` 与四个生产文件的当前内容,二者一致。③ 下文所有"已核验"均指**我亲自读过的源码**(含 `/opt/homebrew/Cellar/go/1.27.1/libexec/src/net/http/`);所有"日志显示"均指**我读日志得出的转述,非我运行**。④ 未使用子代理。 + +--- + +## 一、对 v1 的实现符合性(逐条,全部达成) + +| v1 条款 | 实现位置 | 结论 | +|---|---|---| +| mutex 保护的 explicit 时间 + `readDeadlineStrict`(默认 false) | `deadlineconn.go:37-38,43` | ✅ | +| `SetReadDeadline`/`SetDeadline` 记录 explicit,保留 zero/abort 与立即转发 | `deadlineconn.go:134,152` + `136-139,149-150` | ✅ | +| `SetReadDeadlineStrict(bool)`,同锁、重置 `readSetAt`、带文档 | `deadlineconn.go:79-87` | ✅ | +| 锁内复检 abort/inf;保留节流与 250ms 松弛;strict 下取 `min(idle, explicit)`;绝对上限不加松弛 | `deadlineconn.go:64-66,69-76`(`deadline = c.readExplicit`,未 `Add`) | ✅ | +| 写侧完全不动 | `deadlineconn.go:89-105,161-168` 与基线一致 | ✅ | +| listener 保留具体类型/读写 idle/Unwrap 兼容,返回前开启 strict | `listener.go:73-76` | ✅ | +| Init **组合而非丢弃** 调用方 ConnState;先切模式再调用方 hook | `server.go:127,138-151`(`connState(conn,state)` 在 switch 之后) | ✅ | +| 一层 `*tls.Conn` 用 `NetConn()` 解包;h2 跳过;其他状态忽略 | `server.go:130-137,139-147` | ✅ | +| 不删 ReadTimeout/WriteTimeout、不改 flag;生产超时处加说明注释 | `cmd/server-main.go:904-905`(其余 906-915 未变) | ✅ | + +验证矩阵 1–7 项的**内容**也已全部落地(含 N2 pipelined、N5 真 h2 断言、N6 >30s 下载三项补充)。 + +--- + +## 二、你点名的 8 项独立核验(结果) + +1. **Go 1.27.1 相位序(实读源码)**:`setState(c.rwc,StateNew,runHooks)` 在 `go c.serve()` **之前**、accept 循环内同步执行(`server.go:3580-3581`、hook 同步调用见 `1881-1883`);首个 header deadline 在 serve loop 之前(`2038-2040`);`StateActive` 在 `readRequest` **返回之后**(`2054-2059`);整请求 deadline **无条件**下发(`1103`,`ReadTimeout<=0` 时下发 zero);`StateIdle`(`2152`) → idle deadline(`2163-2167`) → `Peek(4)`(`2173`) → 第二个 header deadline(`2177-2181`) **全部落在 strict 窗口内**。结论:v1 的相位切分正确。 +2. **mutex/atomic 交互**:`readExplicit`/`readDeadlineStrict`/`readSetAt` 三者只在 `mu` 下读写;`abortReads`/`infReads` 原子量在锁内**复检**(`deadlineconn.go:64`),恰好堵住"`Read` 已过外层门 → 并发 `SetReadDeadline(zero)` → 滚动 deadline 覆盖 net/http 背景读的零值"这一 TOCTOU;锁内无阻塞 I/O(`SetReadDeadline` 只是 runtime 定时器调整),因此 accept 循环里的 hook 不会被卡住。未见数据竞争面。 +3. **>250ms 更新不得续期 header 上限**:`deadlineconn.go:69-76` 每次到期重算都会再次 clamp 回同一个绝对 `readExplicit`,绝不外推。推演 trickle 用例(header 650ms、100ms 一字节):t=0/300/600ms 三次落入更新分支,每次都 clamp 到 t0+650ms,650ms 必断。**不变量成立**。 +4. **nonzero/zero/past**:nonzero → `min()`;zero → `infReads` 在外层与锁内双重短路(`58`/`64`),背景读、hijack、h2 全部维持"永不超时";past → `abortReads` 使 `Read` 直接返回 `context.DeadlineExceeded`(实现 `net.Error.Timeout()`)。strict 下**已过期的未来时间保持过期**(clamp 出一个过去时刻)。 +5. **H1 body 滚动**:`1103` 在 `2058` 之前就把 socket deadline 改成 `t0+ReadTimeout` 并同步刷新 `readExplicit`,二者之间**不存在任何读**;`StateActive` 关 strict 且清 `readSetAt`,首个 body 读立刻续期 → 长上传不被硬顶。`ReadTimeout<=0` 时退化为 `infReads`,与补丁前一致。 +6. **buffered/pipelined 与 keep-alive**:`StateActive` 的触发条件 `c.r.remain != initialReadLimitSize()` 在**成功路径上恒成立**——`readRequest` 在 `1067` 调 `setInfiniteReadLimit()`(`remain=maxInt64`),故纯缓冲的流水线第二个请求同样会关 strict(opus-v1 N2 得到证实)。keep-alive 侧 `2163` 会立刻覆盖掉上一请求遗留的 `readExplicit`,中间窗口无读,无陈旧值风险。 +7. **TLS/h2**:握手读上限 = `min(正 RHT,RT,WT)`(`server.go:969-983,1962-1968`),成功后两侧清零(`1988-1992`)→ 再进入新的 header 上限。协商 h2 时 net/http 走 `setState(...,skipHooks)`(`2002`)且在 `ServeConn` 前把两侧 deadline 清零(`http2.go:100-101`);h2 自身只上报 Active/Idle(`internal/http2/server.go:568-572,796-800`),我们的 h2 分支跳过它们,即便不跳过也被 `infReads` 短路。h2 的 per-stream `ReadTimeout` 是 `time.AfterFunc` 计时器(`1970-1972` → `onReadTimeout` `1836-1841`,返回包装后的 `os.ErrDeadlineExceeded`,满足 `net.Error`),**未新增任何连接级读超时**。 +8. **grid/hijack 与默认调用方**:`hijackLocked` 先 `abortPendingRead` 再 `rwc.SetDeadline(zero)`,之后才 `StateHijacked`(`server.go:322-326,336`);grid 随后 `deadlineconn.Unwrap(conn)` 取回裸 `*net.TCPConn`(`internal/grid/manager.go:193`),strict 根本触达不到 grid。内节点 dialer 走默认 false(`internal/http/dial_linux.go:126-131`),且 `DriveOPTimeout` 在生产仍被注释(`cmd/server-main.go:421-422`),生产内节点连接压根不经过 DeadlineConn。 + +--- + +## 三、阻塞项(1 项,不涉及生产语义) + +**B1 — 新测试的直接依赖会打红 `make check-gen` / CI `quality` job** +- `internal/http/server_deadline_test.go:35` 直接 `import "golang.org/x/net/http2"`,而 `go.mod:297` 为 `golang.org/x/net v0.59.0 // indirect`;全仓库(Grep 确认)**只有这一个文件**直接导入 `golang.org/x/net/*`。 +- `Makefile:49-58` 的 `check-gen` 会执行 `go mod tidy -compat=1.27`,随后 `git diff --name-only -- … go.mod go.sum` 非空即 `exit 1`;`.github/workflows/go.yml:75-76` 把它作为必跑步骤。`go mod tidy` 会因"主模块的测试直接导入"而把该行提升为直接依赖(去掉 `// indirect`)→ **go.mod 产生 diff → CI 红**。 +- 注意这**不是构建失败**:`go.sum:765` 已有完整 `h1:` 哈希,所以本地 `go test` 能过(日志也显示过了),git status 里 go.mod/go.sum 也确实未变——问题只在 tidy 门禁。 +- 两种修法任选其一:(a) 把 `golang.org/x/net v0.59.0` 移入直接 require 块(不改版本、不引新模块,零风险);(b) 去掉该依赖,用标准库 h2 客户端(`stdhttp.Transport` + `TLSClientConfig.NextProtos=[]string{"h2"}`)——被测服务端本就是 net/http 内置 http2,(b) 反而更贴切。 +- 声明:我**无法执行** `go mod tidy` 验证,此结论由 `go.mod:297` + `Makefile:49-58` + `go.yml:75-76` + Grep 结果推得。 + +--- + +## 四、非阻塞项 + +- **N1(最值得补的测试缺口)** `deadlineconn_strict_test.go:40-100` 中每次 `Read` 之前都有 `SetReadDeadline*`,而它们会把 `readSetAt` 清零(`deadlineconn.go:86,151`),因此**只覆盖了"第一次更新"的 clamp,从未覆盖"跨 250ms 的第二/第三次更新仍 clamp"**——而这正是本缺陷的核心不变量。目前它只由真实 socket 的 trickle 用例(`server_deadline_test.go:145-151`)间接覆盖。建议加一个确定性用例:strict + 设上限 → `Read` → `sleep(300ms)` → `Read` → 断言 `raw.read` 仍等于上限。 +- **N2** `TestConcurrentStrictReadDeadline`(`:140-162`)无断言,且 setter 每轮立刻把 deadline 归零,**从未让 `Read` 与"正在生效的 clamp"并发**;作为 race 探针可以,但别把它当作语义回归。 +- **N3(flake 风险)** `TestServerHTTP2Deadlines`(`:376-478`)里 `IdleTimeout=400ms` 会被 net/http 映射为 h2 连接级 idle(`net/http/http2.go:57-61`);两次初始 GET 之后、以及 `<-done` 到最后一次 GET 之间,连接处于 idle,若 runner 抖动 >400ms,连接会被 h2 自身关闭 → `connections.Load()==1` 假失败。同理 `TestServerContinuousUpload` 每块只有 550ms 余量。建议 h2 fixture 单独用更大的 idle。(fixture 清写定时器的修法本身是对的:`onWriteTimeout` 产生的是 `StreamError/INTERNAL_ERROR`(`internal/http2/server.go:1846-1852`),不满足 `net.Error`,正是初版失败的原因。) +- **N4** `listener.go:73` 的局部 `conn` 遮蔽了具名返回值 `conn net.Conn`(且 `err` 也不再使用);合法但可读性差,`dc := …` 更干净。 +- **N5(微小开销)** clamp 命中时 `deadlineconn.go:74` 会把 socket 已持有的同一时刻再写一遍;加上每次相位切换清 `readSetAt`(`server.go:141,145`),每个 H1 请求多出约 2 次 deadline 重算。相对 net/http 自身每请求 3 次 `SetReadDeadline` 可忽略,无需改。 +- **N6** `server.go:131` 每次状态转换都调 `tlsConn.ConnectionState()`(持 `handshakeMutex`)。正确且安全(StateNew 发生在 `go c.serve()` 之前),但如 opus-v1 N3 所述该分支严格冗余(`http2.go:100-101` 已清零,`infReads` 必然短路)。保留可作纵深防御,建议注释点明"仅为防御,非必要条件"。 +- **N7(发布说明,已在 consensus N7 登记)** 两处用户可见收紧:TLS 握手读被 `min(RHT,RT,WT)`(生产 30s)硬顶;请求头被 ReadHeaderTimeout 硬顶(即便字节持续到达)。 +- **N8(已知边界,非本轮)** 生产 `NextProtos{"http/1.1","h2"}`(`cmd/utils.go:970`)下,只宣告 h2 的客户端仍受 h2 **绝对** per-stream `ReadTimeout`(30s)约束;plan 第 24 行/consensus N3 已声明不修,交付说明请重述。 +- **N9(证据对齐,已更正)** 我核对了日志里的 `t.Logf` 归属行(注意 `runContinuousUpload/Download` 调了 `t.Helper()`,报的是**调用点**):`implementation-focused.log:85` 的 `:259`、`default-30s-transfers.log:24` 的 `:274` 与当前文件 **完全一致**;下载区整体偏移 +36/+37 行,与"仅 H2 fixture 被重写(当前 `:376-478`,初版失败点 `:435` → 现 `:463`)+ 长用例补了 `t.Parallel()`"完全吻合。**结论:现存日志并非整体过期,H2 用例之前的部分与当前源码同版;H2 用例及其后的部分尚无对应通过记录**——与你说的"最终 race 复跑进行中"一致,我不将其计为已通过。 +- **N10** `evidence/vet.log` 为 **0 字节**。无输出多半就是干净,但空文件不自证;建议在其中记录命令行与退出码。 + +--- + +## 五、未由我执行的部分(请勿当作我背书的通过) + +`implementation-focused.log` / `darwin-race.log` / `linux-race.log` / `grid.log`(仅 `TestDisconnect`+`TestSingleRoundtrip`)/ `default-30s-transfers.log`(33.03s 明文与 TLS 上传/下载)/ `vet.log` 均为**我阅读的记录**,非我运行。Linux 的 `dial_deadline_linux_test.go`(`//go:build linux`)在本机 Darwin 上不会运行,我只静态核验了它确实断言了默认滚动语义的三种情形(50ms 显式被续期、zero 禁用、1min 显式仍被 idle 截断)。最终全量 race 复跑结果待你回填。 + +修掉 B1(或明确判定 tidy 门禁不适用)后,我这边即可转 APPROVE。 diff --git a/docs/investigations/r8/review/implementation-prompt.md b/docs/investigations/r8/review/implementation-prompt.md new file mode 100644 index 000000000..867b80196 --- /dev/null +++ b/docs/investigations/r8/review/implementation-prompt.md @@ -0,0 +1,5 @@ +Read-only implementation review of SILO R8 on baseline 9ebe81c1b3611f9cc73e676b5b741c2be62c467a. You previously agreed plan v1 SHA256 7cb609e37e3199ecd93c992f08d123968ba8082682a108f20490e0644d735366; this is a fresh actual Opus 5.0 review. Read docs/investigations/r8/plan-v1.md, consensus.md, review/opus-v1.md, review/implementation.patch (production diff SHA256 88716d9af433b55527308e09949b0adaab6980aca3bf1ad38b677e9c1c0ce083), and every current file in review/implementation-files.json. These are actual edits, including new untracked tests not represented in git diff. Do not edit or try to write a plan file; deliver findings in the response. Allowed tools are Read/Grep/Glob only. + +Independent checks: exact phase ordering in current Go1.27.1 net/http, mutex/atomic interaction, no header cap renewal across >250ms updates, nonzero/zero/past handling, H1 body rolling, buffered/pipelined and keep-alive transitions, TLS/h2 and grid/hijack default-caller compatibility. Detect concrete bugs and weak tests, avoid hypothetical unrelated expansions. Also see evidence/implementation-focused.log, evidence/default-30s-transfers.log (>33s plaintext and TLS H1 uploads/downloads passed), evidence/grid.log, and evidence/linux-race.log. The first Darwin race run failed only because the H2 fixture's equal read/write timers raced to supply different timeout error types (not a data race); final fixture clears the per-stream write timer to isolate native ReadTimeout and checks healthy multiplexed requests and connection reuse. Final full race reruns are in progress; do not treat pending checks as passed. + +Give Chinese verdict APPROVE or REQUEST_CHANGES, concrete file/line findings separated into blocking and nonblocking, and explicit implementation conformance to accepted v1. Clearly distinguish independently inspected source from tests you did not personally execute. If no blockers, say so. diff --git a/docs/investigations/r8/review/implementation.metadata.json b/docs/investigations/r8/review/implementation.metadata.json new file mode 100644 index 000000000..c8e2b4b88 --- /dev/null +++ b/docs/investigations/r8/review/implementation.metadata.json @@ -0,0 +1,22 @@ +{ + "requested_model": "claude-opus-5", + "effort": "max", + "cli_version": "2.1.270", + "baseline_sha": "9ebe81c1b3611f9cc73e676b5b741c2be62c467a", + "plan_sha256": "7cb609e37e3199ecd93c992f08d123968ba8082682a108f20490e0644d735366", + "production_diff_sha256": "88716d9af433b55527308e09949b0adaab6980aca3bf1ad38b677e9c1c0ce083", + "status": "completed", + "created_at_utc": "2026-09-15T15:54:25.631091+00:00", + "actual_assistant_models": [ + "claude-opus-5" + ], + "result_subtype": "success", + "is_error": false, + "session_id": "541429b8-dc15-4c36-8bf9-f6117e086ae3", + "duration_ms": 382141, + "raw_path": "/Users/vonng/tmp/silo-r8-01a0a5b9/opus-implementation.jsonl", + "raw_sha256": "d5ec088b66ceed9024afc74fc5df40340dc6d208e73d99a8638beb1fde906952", + "review_sha256": "e5861d0da930fe590606722563109d2b87b03b1497abc4a758b7846f446e1eba", + "completed_at_utc": "2026-09-15T16:01:49.467166+00:00", + "scope_note": "Covers v1 connection implementation only; newly discovered config binding requires v2 agreement." +} diff --git a/docs/investigations/r8/review/implementation.patch b/docs/investigations/r8/review/implementation.patch new file mode 100644 index 000000000..ec1c36863 --- /dev/null +++ b/docs/investigations/r8/review/implementation.patch @@ -0,0 +1,137 @@ +diff --git a/cmd/server-main.go b/cmd/server-main.go +index 48ed0f87d..c8a3a19ca 100644 +--- a/cmd/server-main.go ++++ b/cmd/server-main.go +@@ -901,6 +901,8 @@ func serverMain(ctx *cli.Context) { + close(globalGridStart) + close(globalLockGridStart) + ++ // The HTTP/1 listener preserves absolute header deadlines and renews the ++ // body read/write idle limits, so transfers may outlast IdleTimeout. + httpServer := xhttp.NewServer(getServerListenAddrs()). + UseHandler(setCriticalErrorHandler(corsHandler(handler))). + UseTLSConfig(newTLSConfig(getCert)). +diff --git a/internal/deadlineconn/deadlineconn.go b/internal/deadlineconn/deadlineconn.go +index 95bb43eff..5fa5a1403 100644 +--- a/internal/deadlineconn/deadlineconn.go ++++ b/internal/deadlineconn/deadlineconn.go +@@ -34,6 +34,8 @@ type DeadlineConn struct { + net.Conn + readDeadline time.Duration // sets the read deadline on a connection. + readSetAt time.Time ++ readExplicit time.Time // last deadline requested by the caller. ++ readDeadlineStrict bool // idle renewal must not extend readExplicit. + writeDeadline time.Duration // sets the write deadline on a connection. + writeSetAt time.Time + abortReads, abortWrites atomic.Bool // A deadline was set to indicate caller wanted the conn to time out. +@@ -59,17 +61,31 @@ func (c *DeadlineConn) setReadDeadline() { + + c.mu.Lock() + defer c.mu.Unlock() +- if c.abortReads.Load() { ++ if c.abortReads.Load() || c.infReads.Load() { + return + } + + now := time.Now() + if now.Sub(c.readSetAt) > updateInterval { +- c.Conn.SetReadDeadline(now.Add(c.readDeadline + updateInterval)) ++ deadline := now.Add(c.readDeadline + updateInterval) ++ if c.readDeadlineStrict && !c.readExplicit.IsZero() && c.readExplicit.Before(deadline) { ++ deadline = c.readExplicit ++ } ++ c.Conn.SetReadDeadline(deadline) + c.readSetAt = now + } + } + ++// SetReadDeadlineStrict controls whether idle renewal may extend a deadline set ++// by SetReadDeadline or SetDeadline. The default is false. Explicit zero and ++// past deadlines retain their disable/cancel semantics in either mode. ++func (c *DeadlineConn) SetReadDeadlineStrict(strict bool) { ++ c.mu.Lock() ++ defer c.mu.Unlock() ++ c.readDeadlineStrict = strict ++ c.readSetAt = time.Time{} ++} ++ + func (c *DeadlineConn) setWriteDeadline() { + // Do not set a Write deadline, if upstream wants to cancel all reads. + if c.writeDeadline <= 0 || c.abortWrites.Load() || c.infWrites.Load() { +@@ -115,6 +131,7 @@ func (c *DeadlineConn) SetDeadline(t time.Time) error { + defer c.mu.Unlock() + + c.readSetAt = time.Time{} ++ c.readExplicit = t + c.writeSetAt = time.Time{} + c.abortReads.Store(!t.IsZero() && time.Until(t) < 0) + c.abortWrites.Store(!t.IsZero() && time.Until(t) < 0) +@@ -132,6 +149,7 @@ func (c *DeadlineConn) SetReadDeadline(t time.Time) error { + c.abortReads.Store(!t.IsZero() && time.Until(t) < 0) + c.infReads.Store(t.IsZero()) + c.readSetAt = time.Time{} ++ c.readExplicit = t + return c.Conn.SetReadDeadline(t) + } + +diff --git a/internal/http/listener.go b/internal/http/listener.go +index bc6de3af9..14d34f6ea 100644 +--- a/internal/http/listener.go ++++ b/internal/http/listener.go +@@ -70,7 +70,10 @@ func (listener *httpListener) Accept() (conn net.Conn, err error) { + if result.err != nil { + return nil, result.err + } +- return deadlineconn.New(result.conn).WithReadDeadline(listener.opts.IdleTimeout).WithWriteDeadline(listener.opts.IdleTimeout), result.err ++ conn := deadlineconn.New(result.conn).WithReadDeadline(listener.opts.IdleTimeout).WithWriteDeadline(listener.opts.IdleTimeout) ++ // Server.Init switches to rolling reads only after HTTP/1 headers are read. ++ conn.SetReadDeadlineStrict(true) ++ return conn, nil + case <-listener.ctxDoneCh: + } + return nil, syscall.EINVAL +diff --git a/internal/http/server.go b/internal/http/server.go +index 2934fda6c..d9c19a33f 100644 +--- a/internal/http/server.go ++++ b/internal/http/server.go +@@ -29,6 +29,7 @@ import ( + "time" + + "github.com/dustin/go-humanize" ++ "github.com/minio/minio/internal/deadlineconn" + ) + + var ( +@@ -123,6 +124,32 @@ func (srv *Server) Init(listenCtx context.Context, listenErrCallback func(listen + srv.listener = listener + srv.listenerMutex.Unlock() + ++ connState := srv.ConnState ++ srv.ConnState = func(conn net.Conn, state http.ConnState) { ++ raw := conn ++ if tlsConn, ok := raw.(*tls.Conn); ok { ++ if tlsConn.ConnectionState().NegotiatedProtocol == "h2" { ++ // HTTP/2 owns its stream deadlines; do not change the connection. ++ raw = nil ++ } else { ++ raw = tlsConn.NetConn() ++ } ++ } ++ if dc, ok := raw.(*deadlineconn.DeadlineConn); ok { ++ switch state { ++ case http.StateNew, http.StateIdle: ++ dc.SetReadDeadlineStrict(true) ++ case http.StateActive: ++ // net/http has finished reading the headers, including buffered ++ // requests. Keep ReadTimeout as a rolling idle limit for uploads. ++ dc.SetReadDeadlineStrict(false) ++ } ++ } ++ if connState != nil { ++ connState(conn, state) ++ } ++ } ++ + var l net.Listener = listener + if tlsConfig != nil { + l = tls.NewListener(listener, tlsConfig) diff --git a/docs/investigations/r8/review/opus-v1.md b/docs/investigations/r8/review/opus-v1.md new file mode 100644 index 000000000..e930f60c1 --- /dev/null +++ b/docs/investigations/r8/review/opus-v1.md @@ -0,0 +1,70 @@ +I'll start by reading the required materials to ground my review. + +Now let me verify the actual Go 1.27.1 `net/http` behavior rather than trusting the plan's summary. + +Verification complete. Writing my review record to the plan file. + +Write 工具在本会话被禁用(确为只读评审),故评审记录直接以回复形式交付。 + +--- + +# R8 独立评审 — Opus 5.0 reviewer + +**结论:CONSENSUS —— 同意 `docs/investigations/r8/plan-v1.md`(SHA256 `7cb609e37e3199ecd93c992f08d123968ba8082682a108f20490e0644d735366`),无遗留阻塞性分歧。** 共 9 条非阻塞意见。 + +> 诚实性声明(先行):① SHA256 采用你给出的值——本会话只读工具集不含哈希命令,我**未独立重算**;② `evidence/baseline.log` 对应的临时测试文件已不在 worktree(`git status` 仅 `docs/investigations/r8/` 未跟踪),我**未重跑**该日志,只是从 Go 源码独立推导出的机制与该日志完全吻合;③ 未使用子代理、未模拟工具、未声称运行过任何测试。 + +## 一、逐条核验(Go 1.27.1 源码,`/opt/homebrew/Cellar/go/1.27.1/libexec/src/net/http/`) + +| 核验项 | 事实 | 位置 | +|---|---|---| +| ConnState 相位序 | `setState(c.rwc, StateNew, runHooks)` 在 `go c.serve()` **之前**,hook 同步执行、传入 `c.rwc` | `server.go:3580`, `1865-1884` | +| 首个 header deadline | 在 serve loop **之前**设置,此时 state 仍为 StateNew | `server.go:2038-2040` | +| StateActive 时机 | `readRequest` **返回之后**才触发;成功路径必然触发(`setInfiniteReadLimit()` 把 `remain` 置 `maxInt64`) | `2054-2059`, `1067` | +| whole-request deadline | Go 1.27 **无条件** `SetReadDeadline(t0+ReadTimeout)`(旧版的 `!hdrDeadline.Equal(...)` 条件已删) | `1103`, `1038-1041` | +| TLS 握手 | 读写 deadline = `now + min(正RHT,RT,WT)` = SILO 30s;成功后两侧清零 | `1962-1968`, `964-984`, `1988-1992` | +| zero / background EOF | 有 body → EOF 回调启动 background read;无 body → 立即启动,且 `SetReadDeadline(zero)` | `2123-2127`, `731-743` | +| abort / finishRequest | `abortPendingRead` 设 `aLongTimeAgo` → 等待 → 清零;由 `finishRequest` 调用 | `785-797`, `1694-1707` | +| keep-alive 第二个 header | StateIdle hook → `SetReadDeadline(now+idle)` → `Peek(4)` → `SetReadDeadline(now+RHT)`,**全部落在 strict 窗口内** | `2152`, `2163-2181` | +| Hijack(grid/ws) | `abortPendingRead` + `rwc.SetDeadline(zero)` + StateHijacked | `318-337` | +| 写侧 | 每请求由 `readRequest` 的 defer 重新武装,请求结束清零 | `1042-1046`, `2145` | +| H2 是否启用 | SILO 设了 `TLSConfig` 且 `NextProtos` 含 `"h2"` → `shouldConfigureHTTP2ForServe()` 为真 → `s.h2` 配置,走内置 http2 | `3469-3489`, `http2.go:82` | +| H2 与 hook | h2 **会**调用用户 hook(带 nil 保护);进入 ServeConn 前把两侧 deadline 清零;除 per-stream 定时器外无任何 conn 级 `SetReadDeadline` | `http2.go:100-101,188-198`; `internal/http2/server.go:571-572, 799-800, 1567, 2100, 1970-1972` | + +SILO 侧:`cmd/server-main.go:907-913`(RT=WT=Idle=30s,RHT=30s)、`internal/http/listener.go:73`、`dial_linux.go:126-132`、`grid/manager.go:193`、`cmd/utils.go:970`。全仓库 `*.go` **无任何 `ConnState` 使用**;现有 `internal/deadlineconn`、`internal/http` 测试均不设显式 deadline,故不受 strict 影响。 + +## 二、相位推导:option 4 为何正确 + +- **strict ON 覆盖**:accept/StateNew → TLS 握手读 → 首个 header(`2038`);以及 `2152` StateIdle 到下一轮 `2058` StateActive 之间的 idle 等待(`2163`)**与第二个 header**(`2177`)。 +- **strict OFF 覆盖**:`2058` 之后到 `2152` 之前,即 body 读 + handler 全程 → 保留 rolling,长上传不被硬顶。 +- **`1103` 与 `2058` 之间**虽仍 strict 且 explicit 已变为 `t0+ReadTimeout`,但该区间**不存在任何读**(只有 `2106` 的 header 检查与 `2118-2127` 的登记)——无副作用。 +- **zero 优先于 strict**(`infReads` 提前 return)→ background read、hijack、h2 全部保持原语义;长 handler(如 `mc admin trace` 这类流式 GET)不会被误杀。 +- 节流与 `readSetAt` 重置的组合可保证:strict 上限一旦写入 socket 就不会被后续 `Read` 重新拉长;模式切换重置 `readSetAt` 使下一次读立即按新模式重算。 + +**对 baseline 的验证**:strict 下 header 上限 = `min(now+2s+250ms, now+100ms)` = `now+100ms` → 400ms 完成的 header 必被拒,与标准 `net/http` 一致。 + +## 三、非阻塞意见(N1–N9) + +1. **N1 措辞订正**:计划 20 行说 header deadline 在 `readRequest` 内设;实际在 `2038`/`2177`。且 Go 1.27 的 `1103` 是**无条件**的——这反而让 option 1 的否决理由更硬:RT=30s 会硬顶「header+body」整请求。 +2. **N2 不变量要写死**:StateActive 的触发依据应记为 `1067` 的 `setInfiniteReadLimit()`,而非「读到字节」。pipelined 请求即使 header 全来自 `bufio` 缓冲、零 socket 读也必然触发。建议补一个 pipelined 用例。 +3. **N3 可简化**:h2 特判可省——`http2.go:100-101` 已清零两侧 deadline,strict 分支恒被 `infReads` 短路。保留亦正确,只多一次 `ConnectionState()` 加锁;若保留,注意 StateNew 时握手尚未发生,不能依赖其返回值。 +4. **N4 可简化**:`Accept` 里开 strict 是冗余的(`3580` 严格 happens-before 任何读)。保留可作纵深防御,但会让「未装 hook 的 httpListener 使用者」隐式获得 strict body 语义(今天不存在,`httpListener` 未导出、仅 `Server.Init` 构造)。两者皆可,请写明这层耦合。 +5. **N5 测试有效性(最重要)**:`cmd/utils.go:970` 顺序为 `{"http/1.1","h2"}`,Go 的 ALPN 以**服务端顺序**优先 → 普通 Go HTTP/2 客户端会落回 http/1.1。H2 smoke 必须用只声明 `NextProtos: []string{"h2"}` 的 `tls.Client`,并断言 `NegotiatedProtocol == "h2"` 与 `resp.Proto == "HTTP/2.0"`,否则是假阳性。 +6. **N6 回归护栏**:写侧 rolling 是大对象 GET 的命门(`1042-1046` 每请求 `now+30s`,全靠每 250ms 续期)。计划正确地不动写侧;建议在 `setWriteDeadline` 旁注明「不可对称 strict」,并在矩阵补一个 >30s 的持续**下载**用例。 +7. **N7 发布说明**:两处用户可见收紧——TLS 握手读侧被 `min(RHT,RT,WT)=30s` 硬顶;请求头被 `ReadHeaderTimeout` 硬顶(即便字节持续到达)。这正是 stock `net/http` 语义,但极慢链路客户端可能受影响,需文档化。 +8. **N8 影响面比计划更小**:`cmd/server-main.go:421-422` 的 `DriveOPTimeout` 仍被 FIXME 注释掉 → `dial_linux.go:126` 分支不成立,**生产内节点连接根本不经过 DeadlineConn**。计划结论正确,建议据实补记。 +9. **N9 已知项**:握手写侧仍会被续期,慢读 ServerHello 的客户端仍能拉长写侧占用。既有缺陷、本轮不修,建议显式登记而非静默。 + +## 四、替代方案对比(是否存在更简单的正确解:无) + +- **全局 clamp**:`1103` 无条件设 `wholeReqDeadline`,RT=30s 直接破功大对象上传。❌ +- **去 ReadTimeout + 把 zero 解释成 rolling**:与 `731-743`(background read)、`326`(hijack)冲突,两处都用 zero 表达「永不超时」。❌ +- **body/ResponseController wrapper**:引入 drain/EOF/chunked 记账 + h2 专门处理,范围远超本缺陷。❌ +- **由 hook 自行推算 cap(`now+RHT` 等)**:等于在 SILO 侧复刻 stdlib 的 deadline 算术,会随 Go 版本漂移(1.27 相对旧版已改两处)。计划「记住 stdlib 实际要求的值」更稳健。❌ +- **按「显式值是否短于 idle 窗口」自动判定**:生产默认 RHT = RT = Idle = 30s 三者相等,无法区分相位,仅在 baseline 的 100ms/2s 配置下看似有效,**不是通用正确解**。❌ + +所需信息(deadline 由哪一层请求 + 当前 HTTP 相位)只在 `DeadlineConn` API 边界与 `ConnState` 两处可得,**option 4 是最小正确形状**。 + +## 五、后续(实现仍需实际执行验证,不得以推断代替) + +同意计划的 7 项矩阵,并请纳入 N2(pipelined)、N5(真·h2 断言)、N6(>30s 持续下载)三项补充;`-race` 下必须覆盖 `Read` / `SetReadDeadline` / `SetReadDeadlineStrict` 三者并发。Linux 部分若只有编译而无运行时验证,请分别如实报告。 diff --git a/docs/investigations/r8/review/opus-v1.metadata.json b/docs/investigations/r8/review/opus-v1.metadata.json new file mode 100644 index 000000000..f0df7f2f7 --- /dev/null +++ b/docs/investigations/r8/review/opus-v1.metadata.json @@ -0,0 +1,24 @@ +{ + "baseline_sha": "9ebe81c1b3611f9cc73e676b5b741c2be62c467a", + "plan": "plan-v1.md", + "plan_sha256": "7cb609e37e3199ecd93c992f08d123968ba8082682a108f20490e0644d735366", + "requested_model": "claude-opus-5", + "effort": "max", + "cli_version": "2.1.270", + "status": "completed", + "created_at_utc": "2026-09-15T15:44:56.500168+00:00", + "actual_assistant_models": [ + "claude-opus-5" + ], + "result_subtype": "success", + "is_error": false, + "session_id": "7e51fd30-3842-47e9-9d19-47604d87b45b", + "duration_ms": 292649, + "raw_path": "/Users/vonng/tmp/silo-r8-01a0a5b9/opus-v1.jsonl", + "stderr_path": "/Users/vonng/tmp/silo-r8-01a0a5b9/opus-v1.stderr.log", + "raw_sha256": "7922230a171b041a3b736fa9b162282ce1d38614b57d0cf0c42f2f5eade566b7", + "review_sha256": "a6e7c616fe3a158f73e90af7414569f699eedc93e259289dcd0b5c94d75f2dbf", + "completed_at_utc": "2026-09-15T15:50:14.799630+00:00", + "verdict": "CONSENSUS", + "blocking_disagreements": 0 +} diff --git a/docs/investigations/r8/review/opus-v2.md b/docs/investigations/r8/review/opus-v2.md new file mode 100644 index 000000000..253d0c252 --- /dev/null +++ b/docs/investigations/r8/review/opus-v2.md @@ -0,0 +1,70 @@ +I'll begin by reading the plan and its supporting evidence directly — as an independent reviewer I won't delegate verification. + +Now the actual production code — the core of what I must verify independently. + +Now the Go 1.27.1 source, to verify zero/negative semantics of `ReadHeaderTimeout` myself. + +已完成独立只读复核。我亲自读取了 Go 1.27.1 `net/http` 源码、`minio/cli@v1.24.2` 源码、全部相关生产文件与新测试、以及 r8 目录下的计划/共识/证据文档。 + +--- + +# R8 完整方案 v2 评审 — Opus 5.0(独立、只读) + +## 结论:**CONSENSUS** + +我对 **`docs/investigations/r8/plan-v2.md`(v2,SHA256 `426127ed9fb08aeddf8259ebdc4b1c24ebec8cda751a970ed99338a44b065f4c`,基线 `9ebe81c1b3611f9cc73e676b5b741c2be62c467a`)的完整范围**(v1 连接层设计 + 新增配置绑定)表示同意,**零条阻塞性分歧**。 + +**据此授权在该方案范围内实施所提议的配置绑定**:在 `cmd/common-main.go` 的 `buildServerCtxt` 中于 `IdleTimeout` 旁新增 `ctxt.ReadHeaderTimeout = ctx.Duration("read-header-timeout")`。实现与测试验收仍是独立环节,不在本次同意范围内;合并、发布、部署同样不在内。 + +**B1 判定:已解决。** 全仓库 `.go` 文件 Grep `golang.org/x/net` **零命中**;`internal/http/server_deadline_test.go:20-35` 只引入标准库与 `internal/deadlineconn`。h2 改由标准库 `stdhttp.Protocols` + `SetHTTP2(true)`(`:409-411`)强制,断言 `NegotiatedProtocol=="h2" && Proto=="HTTP/2.0"`(`:420`)与 `ProtoMajor!=2` 保护(`:383-384`)均保留——即使协议回落也会响亮失败而非静默降级。由于主模块无任何包(含测试)直接导入 `golang.org/x/net/*`,`go mod tidy` 没有理由把 `go.mod:297` 的 `// indirect` 提升为直接依赖,`Makefile:49-58` 的 `check-gen` 门禁不会被打红,也不会因此丢失该 require(它仍被其他模块间接需要)。N1 亦已落实:`deadlineconn_strict_test.go:165-178` 跨 3 个真实 300ms 周期、全程不重设上限地断言 clamp 不外推。N3 仅对 h2 夹具放宽 keep-alive idle(`server_deadline_test.go:406` `srv.IdleTimeout = 3 * time.Second`),未触及其他用例。 + +> **诚实性声明**:① 本会话仅有 Read/Grep/Glob,**无 shell**。因此我**没有重算 v2 的 SHA256**,也没有运行任何 `go test`/`go vet`/`go mod tidy`/`git`;我核验的是该路径下实际文件的**内容**(116 行,与下文逐条引用一致)。② 所有"日志显示"均为我**阅读记录**的转述,非我运行。③ 未使用子代理。④ 未验证当前 HEAD 是否等于所述基线。 + +--- + +## 一、最小充分性:我独立确认的闭链(C1–C8) + +| # | 结论 | 我核验的依据 | +|---|---|---| +| C1 | 一行赋值**充分** | 全库仅 `cmd/server-main.go:912` 读 `globalServerCtxt.ReadHeaderTimeout`;唯一 HTTP server 构造点也仅 `server-main.go:906`(`xhttp.NewServer` 全库两处命中,另一处在 patch 文档里) | +| C2 | 链路闭合 | `buildServerCtxt`(`common-main.go:370`)是 serverCtxt 唯一填充函数;`serverMain` 在 `server-main.go:799` 用它填 `globalServerCtxt` | +| C3 | **默认行为零变化** | `DefaultIdleTimeout == DefaultReadHeaderTimeout == 30s`(`internal/http/server.go:44-48`)→ `readHeaderTimeout()`(Go `server.go:3752-3757`)绑定前后都返回 30s;`tlsHandshakeTimeout()`(`:969-983`)也都是 30s。plan 第 17 行"两值相等时默认值恰好看起来正常"我独立证实 | +| C4 | YAML 不可能覆盖 | `ServerConfigCommon`/`Opts`(`internal/config/server.go:20-45`)**根本没有超时字段**;`configCommonToSrvCtx`(`server-main.go:274-302`)只处理 RootUser/Pwd/Addr/ConsoleAddr/CertsDir/FTP/SFTP。即便 merge 发生在赋值之后(`common-main.go:450-454`)也无字段可覆盖 | +| C5 | 优先级/错误面**完全不变** | `minio/cli@v1.24.2/flag.go:569-594`:env 作为 flag 默认值、命令行再覆盖 → flag > env > 静态默认;"存在但为空"的 env 在 `time.ParseDuration("")` 处报错,**发生在 flag 解析阶段,与本绑定无关、绑定前已如此**(`config-baseline-env-fixture-failure.log` 正是该现象)。新行不引入任何新错误路径 | +| C6 | h2 不受影响 | `net/http/http2.go:199-211` 只把 `ReadTimeout`/`WriteTimeout`/`IdleTimeout` 交给 h2,`ReadHeaderTimeout` **完全不进入** h2 配置。plan 第 34/71 行正确 | +| C7 | 共享调用方不受影响 | `deadlineconn.New` 仅 `listener.go:73` 与 `dial_linux.go:130`(后者不开 strict);`SetReadDeadlineStrict` 生产调用仅 `listener.go:75`、`server.go:141/145` | +| C8 | v1 在 v2 中**逐字保留**,实现与之一致 | plan-v1 `13-24/26-31/35-55/57-66/70-76/78-80` ↔ plan-v2 `23-34/36-41/52-72/74-83/94-100/102-104`;当前生产文件 `deadlineconn.go:37-38,56-87,129-154`、`listener.go:73-76`、`server.go:127-151`、`server-main.go:904-915` 与之吻合。v2 没有夹带修改已同意的 v1 语义 | + +关于**证据可信度**:我不采信"已执行"的口头声明,但 `config_baseline_test.go.txt:26,36` 的设计本身具备自证力——同一夹具用 `MINIO_IDLE_TIMEOUT=2s` 并断言 `IdleTimeout==2s` 在全部 7 个子用例成立,这是一个**对照组**,排除了"夹具坏了"的解释;而 `ReadHeaderTimeout` 恒为 0。再叠加我上面对 `flag.go:569-594` 的独立源码核验,`config-baseline.log` 的每一行(30s/100ms/170ms/80ms/-1s)都可从源码推导出来。 + +**阻塞性发现:0 条。** + +--- + +## 二、非阻塞发现(N1–N9) + +1. **N1(新耦合,建议写入交付说明)** 绑定后 RHT 首次参与 TLS 握手窗口:`tlsHandshakeTimeout()`(Go `server.go:964-983`)取 RHT/RT/WT 中**正值的最小者**,并在 `:1962-1968` 同时下发读写 deadline。绑定前 RHT 恒为 0 → 窗口 = idle;绑定后 `--read-header-timeout=1s` 会把 TLS 握手(含 h2 的握手阶段)一起压到 1s。默认下无变化(30s)。 + +2. **N2(唯一"变松"的组合,最值得单列)** 相对**当前已实现的 v1**:用户只设 `--idle-timeout=2s`、不设 RHT 时,请求头上限从 2s 变为默认 30s。相对**真实基线 v0**(滚动续期、滴入可无限延长)仍是收紧。这是两个独立旋钮的正确语义,但它是全部组合中唯一"看起来放松"的一种,交付说明应明确点名,避免被误读为回归。 + +3. **N3(负值语义在明文/TLS 下不对称)** `--read-header-timeout=-1s` → `readHeaderTimeout()` 返回 -1s(`:3752-3757`),首请求处 `server.go:2038` 的 `d>0` 不成立 → **不下发任何 deadline**:明文连接因 `readExplicit` 为零值而在 `deadlineconn.go:71` 短路 clamp,回落到滚动 idle;TLS 连接则已在 `:1990-1991` 被清零 → `infReads=true` → 首个请求头**真正无上限**。keep-alive 第二个请求处 `:2179-2180` 显式下发零值,两者统一为无上限。这是 Go 文档化的 "negative = no timeout"(`:3072-3078`)且需用户显式选择,不要求改设计;建议测试断言并在说明中写明这一差异。 + +4. **N4(正面变化,但仍是可见变化)** `--idle-timeout=0`/负值时:绑定前 RT≤0 且 RHT=0 → `readHeaderTimeout()` 返回 0 → **完全没有请求头上限**,`tlsHandshakeTimeout()` 也为 0;绑定后默认 30s 生效。这是修复带来的安全性改善,建议同样纳入说明并补一条用例。 + +5. **N5(验证矩阵缺口)** v2 矩阵未提及 `buildscripts/test-timeout.sh`(`Makefile:177-179`),而它是仓库现存唯一端到端超时测试且直接使用 `--read-header-timeout 5s --idle-timeout 5s`。我推演结论不变:两值相等 → 绑定前后 `readHeaderTimeout()` 都是 5s,三个用例(20s 慢头 / 40s 慢 body / 1s+1s 正常)判定与 `:69` 的 `<= 11s` 时限均满足。建议验收时实跑并记录。顺带值得写进报告:该脚本用的是"一次长睡眠"而非"持续滴入"(`:44-61`),这正是 R8 缺陷长期未被它捕获的原因。 + +6. **N6(强烈建议)** 把配置用例固化为仓库内**常驻**回归测试,而非仅留 `evidence/*.txt`。缺陷本质是"结构体少复制一行",只有常驻断言能防止再次静默回归;夹具可直接落地(`cmd/testdata/config/1.yaml` 存在)。注意 `zero-fallback` 子用例期望值为 0,**绑定前后都通过、不具判别力**,应保留但标注。 + +7. **N7(备案,无需改动)** 第二调用方 `cmd/fmt-gen.go:80` 也执行 `buildServerCtxt`,而 `fmtGenFlags`(`:30-45`)未注册该 flag。我核验 `minio/cli@v1.24.2/flag_generated.go:141-151`:`lookupDuration` 对未注册 flag 返回 0 且不 panic;现存 `ctx.Duration("idle-timeout")` 已在该路径长期运行,证明模式安全,且 fmt-gen 不启动 HTTP server。 + +8. **N8(勿顺手清理)** `common-main.go:444` 与 `:448` 重复赋值 `ctxt.UserTimeout`,属既有无害冗余。新行紧邻该处,**请不要在本次改动中一并清理**,以保持 diff 最小。 + +9. **N9(证据闭环)** `vet.log` 仍为 0 字节(v1 评审 N10 已接受尚未回填);v2 两条新链建议统一记录命令行、退出码与二进制 SHA256。`runtime-v1.json` 只是**修复前**证据(`expected_rejection=false`,两例均 `HTTP/1.1 200 OK`),必须补一份 `expected_rejection=true` 的同探针输出才算闭环。我另行核验了探针判据 `rejected = not data`(`runtime_probe.py:59`)**正确**:读头超时后 Go 走 `isCommonNetReadError`(`server.go:1915-1926`,`net.Error.Timeout()` 为真)→ `:2090-2091` `return // don't reply`,确实不写任何响应字节,不会出现 408 误判。 + +--- + +## 三、边界(不由本次修复覆盖,请在交付说明中重述) + +- HTTP/2 既有的**绝对** per-stream `ReadTimeout` 不在 R8 范围(`http2.go:203` 直接透传 `ReadTimeout`)。 +- TLS 握手**写**侧仍为滚动截止,属既有边界。 +- 我未执行任何测试;`darwin-race.log` / `linux-race.log` / `grid.log` / `default-30s-transfers.log` 等均为我阅读的记录,不构成我的背书。修改过的 h2 与 strict 夹具需在本轮变更后重跑并回填。 diff --git a/docs/investigations/r8/review/opus-v2.metadata.json b/docs/investigations/r8/review/opus-v2.metadata.json new file mode 100644 index 000000000..00151d11e --- /dev/null +++ b/docs/investigations/r8/review/opus-v2.metadata.json @@ -0,0 +1,24 @@ +{ + "baseline_sha": "9ebe81c1b3611f9cc73e676b5b741c2be62c467a", + "plan": "plan-v2.md", + "plan_sha256": "426127ed9fb08aeddf8259ebdc4b1c24ebec8cda751a970ed99338a44b065f4c", + "requested_model": "claude-opus-5", + "effort": "max", + "cli_version": "2.1.270", + "status": "completed", + "created_at_utc": "2026-09-15T15:59:43.188547+00:00", + "actual_assistant_models": [ + "claude-opus-5" + ], + "result_subtype": "success", + "is_error": false, + "session_id": "a1e9e8a2-3417-440b-93b6-c749cbca204f", + "duration_ms": 211549, + "raw_path": "/Users/vonng/tmp/silo-r8-01a0a5b9/opus-v2.jsonl", + "stderr_path": "/Users/vonng/tmp/silo-r8-01a0a5b9/opus-v2.stderr.log", + "raw_sha256": "28a1266b05b8813295168530d9b9dcc792a379a798e74419ee1f06aad364defd", + "review_sha256": "da31df576da632377c963da2ad32e96a9bf4d5f747fb8e2493573b32a899a4ed", + "completed_at_utc": "2026-09-15T16:07:10.728735+00:00", + "verdict": "CONSENSUS", + "blocking_disagreements": 0 +} diff --git a/docs/investigations/r8/review/prompt-v1.md b/docs/investigations/r8/review/prompt-v1.md new file mode 100644 index 000000000..d84e40a74 --- /dev/null +++ b/docs/investigations/r8/review/prompt-v1.md @@ -0,0 +1,3 @@ +You are the independent actual Opus 5.0 reviewer for SILO R8. Read-only review; no code changes. Read AGENTS.md, docs/investigations/r8/plan-v1.md (SHA256 7cb609e37e3199ecd93c992f08d123968ba8082682a108f20490e0644d735366), docs/investigations/r8/evidence/baseline.log, internal/deadlineconn/deadlineconn.go, internal/http/listener.go, internal/http/server.go, cmd/server-main.go (timeout configuration), internal/http/dial_linux.go, internal/grid/manager.go (unwrap), cmd/utils.go (TLS NextProtos). Inspect actual Go1.27.1 net/http code as needed using allowed Read/Grep tools; baseline is 9ebe81c1b3611f9cc73e676b5b741c2be62c467a. No production patch exists yet. + +The user requires real Opus discussion and explicit same-plan agreement before implementation. Independently challenge correctness and minimal compatibility. Verify exact ConnState phase ordering, TLS handshake, zero deadline/background EOF behavior, keep-alive second header, long uploads, strict mode concurrency, H2 preservation and Linux internode/grid callers. State blocking vs nonblocking findings with concrete source reasoning; compare alternatives if there is a simpler correct approach. Respond in Chinese (technical identifiers preserved), with verdict CONSENSUS or REVISE. CONSENSUS must explicitly name plan v1 and SHA256 and mean there are no remaining blocking disagreements; test implementation still follows. If any blocker exists do not give consensus. Do not simulate tools or pretend tests were run. diff --git a/docs/investigations/r8/review/prompt-v2.md b/docs/investigations/r8/review/prompt-v2.md new file mode 100644 index 000000000..cd8f8c5cf --- /dev/null +++ b/docs/investigations/r8/review/prompt-v2.md @@ -0,0 +1,11 @@ +You are the actual independent Opus 5.0 reviewer. Read-only; do not attempt to write any file. The user requires explicit same-version agreement for the COMPLETE R8 scope before the new configuration change is implemented. + +Review plan v2 at docs/investigations/r8/plan-v2.md, SHA256 426127ed9fb08aeddf8259ebdc4b1c24ebec8cda751a970ed99338a44b065f4c, on baseline 9ebe81c1b3611f9cc73e676b5b741c2be62c467a. V1 connection repair is already implemented after actual v1 CONSENSUS; see consensus.md and review/opus-v1.md. V1 does not cover the newly found CLI binding omission. The only new proposed production edit is ctxt.ReadHeaderTimeout = ctx.Duration("read-header-timeout") beside IdleTimeout in cmd/common-main.go. It has NOT been applied. + +Evidence: evidence/config-baseline.log and config_baseline_test.go.txt exercise the REAL cli.App with serverCmd.Flags and buildServerCtxt. CLI parses correct default/flag/env/precedence/YAML/negative durations, but context is 0. Explicit zero remains 0. Also read evidence/runtime-v1.json and runtime_probe.py: a compiled v1 SILO binary was launched on disposable localhost-only storage; both flag and env set 100ms, idle 2s, but a 400ms health request header still returns HTTP 200 because missing binding leaves ReadHeaderTimeout=0 and Go falls back to idle. These are real tests by Codex, not claims that you ran them. + +Read actual cmd/common-main.go buildServerCtxt; cmd/server-main.go ServerFlags, configCommonToSrvCtx, server setup; current DeadlineConn/listener/server files and new tests; review/implementation-opus.md if it now exists (v1 implementation review only); v2 validation matrix and compatibility notes. Independently confirm minimal sufficiency and side effects of binding defaults and custom values, zero/negative semantics, env precedence, YAML retention, header vs idle behavior, keepalive, TLS, h2, body/download renewal and shared callers. Do not trust a patch/plan claim as execution. Inspect Go1.27.1 source as needed. + +Return Chinese verdict CONSENSUS or REVISE for COMPLETE plan v2, explicitly naming v2 and its SHA256. If consensus, say there are zero blocking disagreements and authorize the proposed configuration binding within that plan, with implementation/testing still separate. List numbered blocking/nonblocking findings with concrete evidence. Any substantive required design change must use REVISE. No rate-limit or missing reply counts as agreement. + +Additional completed v1 implementation review: review/implementation-opus.md returned REQUEST_CHANGES for one TEST dependency issue (new direct x/net/http2 import while go.mod marked module indirect), explicitly no production behavior defect. Read review/implementation-dispositions.md. The test now uses the standard Go HTTP/2-only Protocols setting, so no external x/net import or go.mod change is needed; h2 assertions remain. Also added repeated-clamp unit coverage and widened only h2 keep-alive idle. Independently decide whether B1 is resolved and whether COMPLETE v2 has any remaining blockers. diff --git a/docs/investigations/r8/review/v2-review-files.json b/docs/investigations/r8/review/v2-review-files.json new file mode 100644 index 000000000..3b879343c --- /dev/null +++ b/docs/investigations/r8/review/v2-review-files.json @@ -0,0 +1,10 @@ +{ + "internal/deadlineconn/deadlineconn.go": "b9272ef640f1d4403b3d0af6cdbaba9186c51ad9a0226dfe449e8ef738e1ec4b", + "internal/http/listener.go": "49628575367f6ab9b6986caf594726d74d370f7d2ac4eed582903600b6eb3fa2", + "internal/http/server.go": "b7b0355f2781f8c5f7c77bc910cd4180cd3e5f22a87de41bd35ef119d36b4cdf", + "cmd/server-main.go": "04c265de211412ba0297396928096d7f2d971244a957a3126154846035263514", + "cmd/common-main.go": "c06df6c3051ceb264f7ddbdddea33ebe38a59e0c113b5c4c44906c1c2e08a01b", + "internal/deadlineconn/deadlineconn_strict_test.go": "f405690c9ff044595f48323d68f4a9b33ce695b3ad6820f54f17151067bfae5e", + "internal/http/server_deadline_test.go": "36103c155795ab24a7b8f3101f2091c793f9cbe1f2459b5bea51a8a2dc192a52", + "internal/http/dial_deadline_linux_test.go": "0939d05b72a09760d53fcdf249775989e3f89bca824b9961d0b2a657ebfdf41e" +} diff --git a/internal/deadlineconn/deadlineconn.go b/internal/deadlineconn/deadlineconn.go index 95bb43eff..5fa5a1403 100644 --- a/internal/deadlineconn/deadlineconn.go +++ b/internal/deadlineconn/deadlineconn.go @@ -34,6 +34,8 @@ type DeadlineConn struct { net.Conn readDeadline time.Duration // sets the read deadline on a connection. readSetAt time.Time + readExplicit time.Time // last deadline requested by the caller. + readDeadlineStrict bool // idle renewal must not extend readExplicit. writeDeadline time.Duration // sets the write deadline on a connection. writeSetAt time.Time abortReads, abortWrites atomic.Bool // A deadline was set to indicate caller wanted the conn to time out. @@ -59,17 +61,31 @@ func (c *DeadlineConn) setReadDeadline() { c.mu.Lock() defer c.mu.Unlock() - if c.abortReads.Load() { + if c.abortReads.Load() || c.infReads.Load() { return } now := time.Now() if now.Sub(c.readSetAt) > updateInterval { - c.Conn.SetReadDeadline(now.Add(c.readDeadline + updateInterval)) + deadline := now.Add(c.readDeadline + updateInterval) + if c.readDeadlineStrict && !c.readExplicit.IsZero() && c.readExplicit.Before(deadline) { + deadline = c.readExplicit + } + c.Conn.SetReadDeadline(deadline) c.readSetAt = now } } +// SetReadDeadlineStrict controls whether idle renewal may extend a deadline set +// by SetReadDeadline or SetDeadline. The default is false. Explicit zero and +// past deadlines retain their disable/cancel semantics in either mode. +func (c *DeadlineConn) SetReadDeadlineStrict(strict bool) { + c.mu.Lock() + defer c.mu.Unlock() + c.readDeadlineStrict = strict + c.readSetAt = time.Time{} +} + func (c *DeadlineConn) setWriteDeadline() { // Do not set a Write deadline, if upstream wants to cancel all reads. if c.writeDeadline <= 0 || c.abortWrites.Load() || c.infWrites.Load() { @@ -115,6 +131,7 @@ func (c *DeadlineConn) SetDeadline(t time.Time) error { defer c.mu.Unlock() c.readSetAt = time.Time{} + c.readExplicit = t c.writeSetAt = time.Time{} c.abortReads.Store(!t.IsZero() && time.Until(t) < 0) c.abortWrites.Store(!t.IsZero() && time.Until(t) < 0) @@ -132,6 +149,7 @@ func (c *DeadlineConn) SetReadDeadline(t time.Time) error { c.abortReads.Store(!t.IsZero() && time.Until(t) < 0) c.infReads.Store(t.IsZero()) c.readSetAt = time.Time{} + c.readExplicit = t return c.Conn.SetReadDeadline(t) } diff --git a/internal/deadlineconn/deadlineconn_strict_test.go b/internal/deadlineconn/deadlineconn_strict_test.go new file mode 100644 index 000000000..342ce7322 --- /dev/null +++ b/internal/deadlineconn/deadlineconn_strict_test.go @@ -0,0 +1,178 @@ +// Copyright (c) 2026 Feng Ruohang +// +// This file is part of Silo Object Storage stack +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package deadlineconn + +import ( + "errors" + "io" + "net" + "sync" + "testing" + "time" +) + +type deadlineRecorder struct { + net.Conn + read, write time.Time +} + +func (c *deadlineRecorder) SetReadDeadline(t time.Time) error { c.read = t; return nil } +func (c *deadlineRecorder) SetWriteDeadline(t time.Time) error { c.write = t; return nil } +func (c *deadlineRecorder) SetDeadline(t time.Time) error { c.read = t; c.write = t; return nil } +func (c *deadlineRecorder) Read([]byte) (int, error) { return 0, io.EOF } +func (c *deadlineRecorder) Write(b []byte) (int, error) { return len(b), nil } + +func TestStrictReadDeadline(t *testing.T) { + for _, both := range []bool{false, true} { + name := "SetReadDeadline" + if both { + name = "SetDeadline" + } + t.Run(name, func(t *testing.T) { + raw := &deadlineRecorder{} + conn := New(raw).WithReadDeadline(time.Second).WithWriteDeadline(time.Second) + conn.SetReadDeadlineStrict(true) + absolute := time.Now().Add(100 * time.Millisecond) + if both { + conn.SetDeadline(absolute) + } else { + conn.SetReadDeadline(absolute) + } + conn.Read(nil) + if !raw.read.Equal(absolute) { + t.Fatalf("absolute read deadline extended: %s, want %s", raw.read, absolute) + } + // Strictness only affects reads. Existing rolling writes remain unchanged. + conn.SetWriteDeadline(absolute) + conn.Write([]byte("x")) + if !raw.write.After(absolute) { + t.Fatal("strict read mode changed write semantics") + } + // Body phase can renew even when the header deadline was shorter than idle. + conn.SetReadDeadlineStrict(false) + conn.Read(nil) + if !raw.read.After(absolute) { + t.Fatal("body deadline did not renew") + } + // Keep-alive installs a new absolute deadline; do not reuse the first header's cap. + next := time.Now().Add(200 * time.Millisecond) + conn.SetReadDeadlineStrict(true) + conn.SetReadDeadline(next) + conn.Read(nil) + if !raw.read.Equal(next) { + t.Fatalf("second deadline = %s, want %s", raw.read, next) + } + // A longer header deadline must still allow the socket idle bound. + later := time.Now().Add(time.Hour) + conn.SetReadDeadline(later) + conn.Read(nil) + if !raw.read.Before(later) || raw.read.Before(time.Now()) { + t.Fatal("idle deadline not applied within absolute cap") + } + // Explicit zero disables both the absolute cap and idle renewal. + conn.SetReadDeadline(time.Time{}) + conn.Read(nil) + if !raw.read.IsZero() { + t.Fatal("zero deadline re-enabled idle timeout") + } + past := time.Unix(1, 0) + conn.SetReadDeadline(past) + if _, err := conn.Read(nil); err == nil { + t.Fatal("past deadline did not abort") + } + }) + } +} + +func TestDefaultReadDeadlineStillRenews(t *testing.T) { + raw := &deadlineRecorder{} + conn := New(raw).WithReadDeadline(time.Second) + absolute := time.Now().Add(100 * time.Millisecond) + conn.SetReadDeadline(absolute) + conn.Read(nil) + if !raw.read.After(absolute) { + t.Fatal("default internode-style read no longer renews") + } + if Unwrap(conn) != raw { + t.Fatal("Unwrap changed the underlying connection") + } + conn.SetReadDeadline(time.Time{}) + conn.Read(nil) + if !raw.read.IsZero() { + t.Fatal("default zero deadline no longer disables timeout") + } +} + +func TestStrictExpiredFutureReadDeadline(t *testing.T) { + server, client := net.Pipe() + defer server.Close() + defer client.Close() + conn := New(server).WithReadDeadline(time.Second) + conn.SetReadDeadlineStrict(true) + absolute := time.Now().Add(30 * time.Millisecond) + conn.SetReadDeadline(absolute) + time.Sleep(60 * time.Millisecond) + _, err := conn.Read(make([]byte, 1)) + var ne net.Error + if !errors.As(err, &ne) || !ne.Timeout() { + t.Fatalf("expired absolute read: %v", err) + } + if time.Since(absolute) > 300*time.Millisecond { + t.Fatal("expired future deadline was extended by idle renewal") + } +} + +func TestConcurrentStrictReadDeadline(t *testing.T) { + server, client := net.Pipe() + defer server.Close() + defer client.Close() + conn := New(server).WithReadDeadline(time.Second) + conn.SetReadDeadlineStrict(true) + var wg sync.WaitGroup + wg.Go(func() { + for range 100 { + conn.SetReadDeadline(time.Now().Add(time.Second)) + conn.SetReadDeadline(time.Time{}) + conn.SetReadDeadlineStrict(false) + conn.SetReadDeadlineStrict(true) + } + conn.Close() + }) + for { + if _, err := conn.Read(make([]byte, 1)); err != nil { + break + } + } + wg.Wait() +} + +// Force multiple real update intervals without resetting the explicit cap. +func TestStrictReadDeadlineRepeatedRenewal(t *testing.T) { + raw := &deadlineRecorder{} + conn := New(raw).WithReadDeadline(2 * time.Second) + conn.SetReadDeadlineStrict(true) + absolute := time.Now().Add(time.Second) + conn.SetReadDeadline(absolute) + for range 3 { + conn.Read(nil) + if !raw.read.Equal(absolute) { + t.Fatalf("idle renewal changed absolute deadline: %s, want %s", raw.read, absolute) + } + time.Sleep(300 * time.Millisecond) + } +} diff --git a/internal/http/dial_deadline_linux_test.go b/internal/http/dial_deadline_linux_test.go new file mode 100644 index 000000000..19ab1ef94 --- /dev/null +++ b/internal/http/dial_deadline_linux_test.go @@ -0,0 +1,91 @@ +//go:build linux + +// Copyright (c) 2026 Feng Ruohang +// +// This file is part of Silo Object Storage stack +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package http + +import ( + "errors" + "io" + "net" + "testing" + "time" + + "github.com/minio/minio/internal/deadlineconn" +) + +// The optional drive dialer must retain its legacy rolling read semantics. +func TestInternodeDialReadDeadline(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer ln.Close() + accepted := make(chan net.Conn, 1) + go func() { + conn, err := ln.Accept() + if err != nil { + accepted <- nil + return + } + accepted <- conn + }() + dial := NewInternodeDialContext(time.Second, TCPOptions{DriveOPTimeout: func() time.Duration { return 200 * time.Millisecond }}) + conn, err := dial(t.Context(), "tcp", ln.Addr().String()) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + peer := <-accepted + if peer == nil { + t.Fatal("accept failed") + } + defer peer.Close() + if _, ok := conn.(*deadlineconn.DeadlineConn); !ok { + t.Fatalf("drive connection type %T", conn) + } + sent := make(chan error, 1) + go func() { + time.Sleep(150 * time.Millisecond) + if _, err := io.WriteString(peer, "a"); err != nil { + sent <- err + return + } + time.Sleep(700 * time.Millisecond) + _, err := io.WriteString(peer, "b") + sent <- err + }() + conn.SetReadDeadline(time.Now().Add(50 * time.Millisecond)) + var b [1]byte + if _, err := io.ReadFull(conn, b[:]); err != nil || b[0] != 'a' { + t.Fatalf("rolling explicit deadline: %q %v", b, err) + } + conn.SetReadDeadline(time.Time{}) + if _, err := io.ReadFull(conn, b[:]); err != nil || b[0] != 'b' { + t.Fatalf("disabled read deadline: %q %v", b, err) + } + if err := <-sent; err != nil { + t.Fatal(err) + } + conn.SetReadDeadline(time.Now().Add(time.Minute)) + _, err = conn.Read(b[:]) + var ne net.Error + if !errors.As(err, &ne) || !ne.Timeout() { + t.Fatalf("drive idle timeout: %v", err) + } +} diff --git a/internal/http/listener.go b/internal/http/listener.go index bc6de3af9..14d34f6ea 100644 --- a/internal/http/listener.go +++ b/internal/http/listener.go @@ -70,7 +70,10 @@ func (listener *httpListener) Accept() (conn net.Conn, err error) { if result.err != nil { return nil, result.err } - return deadlineconn.New(result.conn).WithReadDeadline(listener.opts.IdleTimeout).WithWriteDeadline(listener.opts.IdleTimeout), result.err + conn := deadlineconn.New(result.conn).WithReadDeadline(listener.opts.IdleTimeout).WithWriteDeadline(listener.opts.IdleTimeout) + // Server.Init switches to rolling reads only after HTTP/1 headers are read. + conn.SetReadDeadlineStrict(true) + return conn, nil case <-listener.ctxDoneCh: } return nil, syscall.EINVAL diff --git a/internal/http/server.go b/internal/http/server.go index 2934fda6c..d9c19a33f 100644 --- a/internal/http/server.go +++ b/internal/http/server.go @@ -29,6 +29,7 @@ import ( "time" "github.com/dustin/go-humanize" + "github.com/minio/minio/internal/deadlineconn" ) var ( @@ -123,6 +124,32 @@ func (srv *Server) Init(listenCtx context.Context, listenErrCallback func(listen srv.listener = listener srv.listenerMutex.Unlock() + connState := srv.ConnState + srv.ConnState = func(conn net.Conn, state http.ConnState) { + raw := conn + if tlsConn, ok := raw.(*tls.Conn); ok { + if tlsConn.ConnectionState().NegotiatedProtocol == "h2" { + // HTTP/2 owns its stream deadlines; do not change the connection. + raw = nil + } else { + raw = tlsConn.NetConn() + } + } + if dc, ok := raw.(*deadlineconn.DeadlineConn); ok { + switch state { + case http.StateNew, http.StateIdle: + dc.SetReadDeadlineStrict(true) + case http.StateActive: + // net/http has finished reading the headers, including buffered + // requests. Keep ReadTimeout as a rolling idle limit for uploads. + dc.SetReadDeadlineStrict(false) + } + } + if connState != nil { + connState(conn, state) + } + } + var l net.Listener = listener if tlsConfig != nil { l = tls.NewListener(listener, tlsConfig) diff --git a/internal/http/server_deadline_test.go b/internal/http/server_deadline_test.go new file mode 100644 index 000000000..4bb5ece13 --- /dev/null +++ b/internal/http/server_deadline_test.go @@ -0,0 +1,635 @@ +// Copyright (c) 2026 Feng Ruohang +// +// This file is part of Silo Object Storage stack +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package http + +import ( + "bufio" + "context" + "crypto/tls" + "errors" + "fmt" + "io" + "net" + stdhttp "net/http" + "os" + "sync/atomic" + "testing" + "time" + + "github.com/minio/minio/internal/deadlineconn" +) + +// Real sockets exercise net/http's TLS, read-ahead and connection-state transitions. +func startDeadlineServer(t *testing.T, secure bool, idle, header time.Duration, handler stdhttp.Handler, hook func(net.Conn, stdhttp.ConnState), configure ...func(*Server)) (*Server, string) { + t.Helper() + srv := NewServer([]string{"127.0.0.1:0"}).UseHandler(handler). + UseTCPOptions(TCPOptions{IdleTimeout: idle}).UseIdleTimeout(idle). + UseReadTimeout(idle).UseWriteTimeout(idle).UseReadHeaderTimeout(header) + srv.ConnState = hook + for _, fn := range configure { + fn(srv) + } + if secure { + cert, err := getTLSCert() + if err != nil { + t.Fatal(err) + } + srv.UseTLSConfig(&tls.Config{Certificates: []tls.Certificate{cert}, NextProtos: []string{"http/1.1", "h2"}}) + } + serve, err := srv.Init(context.Background(), func(_ string, err error) { t.Error(err) }) + if err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { done <- serve() }() + t.Cleanup(func() { + srv.Close() + select { + case err := <-done: + if !errors.Is(err, stdhttp.ErrServerClosed) { + t.Errorf("Serve: %v", err) + } + case <-time.After(5 * time.Second): + t.Error("Serve did not stop") + } + }) + return srv, srv.listener.Addr().String() +} + +func dialDeadlineServer(t *testing.T, addr string, secure bool) net.Conn { + t.Helper() + conn, err := net.DialTimeout("tcp", addr, time.Second) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { conn.Close() }) + if err := conn.SetDeadline(time.Now().Add(5 * time.Second)); err != nil { + t.Fatal(err) + } + if secure { + tc := tls.Client(conn, &tls.Config{InsecureSkipVerify: true, NextProtos: []string{"http/1.1"}}) + if err := tc.Handshake(); err != nil { + t.Fatal(err) + } + return tc + } + return conn +} + +func writeDeadlineRequest(t *testing.T, conn net.Conn, data string) { + t.Helper() + if _, err := io.WriteString(conn, data); err != nil { + t.Fatal(err) + } +} + +func readDeadlineResponse(t *testing.T, r *bufio.Reader) { + t.Helper() + resp, err := stdhttp.ReadResponse(r, nil) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != stdhttp.StatusNoContent { + t.Fatalf("status: %s", resp.Status) + } + if _, err := io.Copy(io.Discard, resp.Body); err != nil { + t.Fatal(err) + } +} + +func requireDeadlineRejection(t *testing.T, conn net.Conn) { + t.Helper() + resp, err := stdhttp.ReadResponse(bufio.NewReader(conn), nil) + if err == nil { + resp.Body.Close() + t.Fatalf("request accepted: %s", resp.Status) + } + var ne net.Error + if errors.As(err, &ne) && ne.Timeout() { + t.Fatalf("client timed out before server rejected request: %v", err) + } +} + +func TestServerReadHeaderDeadline(t *testing.T) { + for _, secure := range []bool{false, true} { + for _, second := range []bool{false, true} { + for _, trickle := range []bool{false, true} { + t.Run(fmt.Sprintf("tls=%t/second=%t/trickle=%t", secure, second, trickle), func(t *testing.T) { + t.Parallel() + var calls atomic.Int32 + _, addr := startDeadlineServer(t, secure, 2*time.Second, 650*time.Millisecond, stdhttp.HandlerFunc(func(w stdhttp.ResponseWriter, r *stdhttp.Request) { calls.Add(1); w.WriteHeader(204) }), nil) + conn := dialDeadlineServer(t, addr, secure) + expected := int32(0) + if second { + writeDeadlineRequest(t, conn, "GET /first HTTP/1.1\r\nHost: localhost\r\n\r\n") + readDeadlineResponse(t, bufio.NewReader(conn)) + expected = 1 + // Header time starts anew for the second request, after the keep-alive wait. + time.Sleep(750 * time.Millisecond) + } + writeDeadlineRequest(t, conn, "GET /slow HTTP/1.1\r\nHost: localhost\r\nX-Slow: ") + if trickle { + for range 10 { + time.Sleep(100 * time.Millisecond) + if _, err := io.WriteString(conn, "x"); err != nil { + break + } + } + } else { + time.Sleep(950 * time.Millisecond) + } + _, _ = io.WriteString(conn, "done\r\n\r\n") + requireDeadlineRejection(t, conn) + if got := calls.Load(); got != expected { + t.Fatalf("handler calls = %d, want %d", got, expected) + } + }) + } + } + } +} + +func TestServerKeepAliveDeadline(t *testing.T) { + for _, secure := range []bool{false, true} { + t.Run(fmt.Sprintf("tls=%t", secure), func(t *testing.T) { + t.Parallel() + _, addr := startDeadlineServer(t, secure, 800*time.Millisecond, 200*time.Millisecond, stdhttp.HandlerFunc(func(w stdhttp.ResponseWriter, r *stdhttp.Request) { w.WriteHeader(204) }), nil) + conn := dialDeadlineServer(t, addr, secure) + br := bufio.NewReader(conn) + for range 2 { + writeDeadlineRequest(t, conn, "GET / HTTP/1.1\r\nHost: localhost\r\n\r\n") + readDeadlineResponse(t, br) + time.Sleep(300 * time.Millisecond) + } + // An incomplete method must not renew the keep-alive deadline on each byte. + writeDeadlineRequest(t, conn, "G") + time.Sleep(300 * time.Millisecond) + _, _ = io.WriteString(conn, "E") + time.Sleep(300 * time.Millisecond) + _, _ = io.WriteString(conn, "T") + time.Sleep(300 * time.Millisecond) + conn.SetReadDeadline(time.Now().Add(150 * time.Millisecond)) + requireDeadlineRejection(t, conn) + }) + } +} + +func runContinuousUpload(t *testing.T, secure, chunked, expect bool, idle, period time.Duration, chunks int) { + t.Helper() + nread := make(chan int64, 1) + _, addr := startDeadlineServer(t, secure, idle, 2*time.Second, stdhttp.HandlerFunc(func(w stdhttp.ResponseWriter, r *stdhttp.Request) { + n, err := io.Copy(io.Discard, r.Body) + if err != nil { + t.Errorf("body read: %v", err) + w.WriteHeader(400) + return + } + nread <- n + w.WriteHeader(204) + }), nil) + conn := dialDeadlineServer(t, addr, secure) + conn.SetDeadline(time.Now().Add(time.Duration(chunks)*period + 5*time.Second)) + headers := "POST / HTTP/1.1\r\nHost: localhost\r\n" + if chunked { + headers += "Transfer-Encoding: chunked\r\n" + } else { + headers += fmt.Sprintf("Content-Length: %d\r\n", chunks) + } + if expect { + headers += "Expect: 100-continue\r\n" + } + writeDeadlineRequest(t, conn, headers+"\r\n") + br := bufio.NewReader(conn) + if expect { + resp, err := stdhttp.ReadResponse(br, nil) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != 100 { + t.Fatalf("expected 100 Continue, got %s", resp.Status) + } + } + start := time.Now() + for range chunks { + time.Sleep(period) + if chunked { + writeDeadlineRequest(t, conn, "1\r\nx\r\n") + } else { + writeDeadlineRequest(t, conn, "x") + } + } + if chunked { + writeDeadlineRequest(t, conn, "0\r\n\r\n") + } + readDeadlineResponse(t, br) + if n := <-nread; n != int64(chunks) { + t.Fatalf("read %d bytes, want %d", n, chunks) + } + if elapsed := time.Since(start); elapsed <= idle { + t.Fatalf("upload took %s, must exceed idle %s", elapsed, idle) + } else { + t.Logf("continuous upload %s > idle %s", elapsed, idle) + } + // Verify a fresh request after body EOF and read-deadline cancellation. + writeDeadlineRequest(t, conn, "GET / HTTP/1.1\r\nHost: localhost\r\n\r\n") + readDeadlineResponse(t, br) +} + +func TestServerContinuousUpload(t *testing.T) { + for _, secure := range []bool{false, true} { + for _, chunked := range []bool{false, true} { + for _, expect := range []bool{false, true} { + t.Run(fmt.Sprintf("tls=%t/chunked=%t/expect=%t", secure, chunked, expect), func(t *testing.T) { + t.Parallel() + runContinuousUpload(t, secure, chunked, expect, 300*time.Millisecond, 80*time.Millisecond, 16) + }) + } + } + } +} + +func TestServerDefaultIdleLongUpload(t *testing.T) { + if os.Getenv("SILO_TEST_LONG_UPLOAD") != "1" { + t.Skip("set SILO_TEST_LONG_UPLOAD=1 for >30s default-idle regression") + } + t.Parallel() + for _, secure := range []bool{false, true} { + t.Run(fmt.Sprintf("tls=%t", secure), func(t *testing.T) { + t.Parallel() + runContinuousUpload(t, secure, false, false, DefaultIdleTimeout, time.Second, 33) + }) + } +} + +func TestServerIdleBodyDeadline(t *testing.T) { + for _, secure := range []bool{false, true} { + t.Run(fmt.Sprintf("tls=%t", secure), func(t *testing.T) { + t.Parallel() + result := make(chan error, 1) + _, addr := startDeadlineServer(t, secure, 200*time.Millisecond, time.Second, stdhttp.HandlerFunc(func(w stdhttp.ResponseWriter, r *stdhttp.Request) { + _, err := io.Copy(io.Discard, r.Body) + result <- err + w.WriteHeader(400) + }), nil) + conn := dialDeadlineServer(t, addr, secure) + writeDeadlineRequest(t, conn, "PUT / HTTP/1.1\r\nHost: localhost\r\nContent-Length: 2\r\n\r\nx") + select { + case err := <-result: + var ne net.Error + if !errors.As(err, &ne) || !ne.Timeout() { + t.Fatalf("expected server body timeout, got %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("idle body did not time out") + } + }) + } +} + +func TestServerBackgroundReadNoDeadline(t *testing.T) { + for _, secure := range []bool{false, true} { + for _, body := range []bool{false, true} { + t.Run(fmt.Sprintf("tls=%t/body=%t", secure, body), func(t *testing.T) { + t.Parallel() + _, addr := startDeadlineServer(t, secure, 150*time.Millisecond, time.Second, stdhttp.HandlerFunc(func(w stdhttp.ResponseWriter, r *stdhttp.Request) { + if _, err := io.Copy(io.Discard, r.Body); err != nil { + t.Error(err) + return + } + select { + case <-r.Context().Done(): + t.Errorf("background read canceled handler: %v", r.Context().Err()) + return + case <-time.After(700 * time.Millisecond): + } + w.WriteHeader(204) + }), nil) + conn := dialDeadlineServer(t, addr, secure) + req := "GET / HTTP/1.1\r\nHost: localhost\r\n\r\n" + if body { + req = "POST / HTTP/1.1\r\nHost: localhost\r\nContent-Length: 1\r\n\r\nx" + } + writeDeadlineRequest(t, conn, req) + readDeadlineResponse(t, bufio.NewReader(conn)) + }) + } + } +} + +func TestServerTLSHandshakeReadDeadline(t *testing.T) { + _, addr := startDeadlineServer(t, true, 2*time.Second, 200*time.Millisecond, stdhttp.HandlerFunc(func(w stdhttp.ResponseWriter, r *stdhttp.Request) { t.Error("unexpected handler") }), nil) + conn := dialDeadlineServer(t, addr, false) + conn.SetReadDeadline(time.Now().Add(time.Second)) + // Partial TLS record header: server must wait for bytes and enforce its own deadline. + writeDeadlineRequest(t, conn, "\x16\x03") + var b [1]byte + _, err := conn.Read(b[:]) + if err == nil { + t.Fatal("expected handshake failure") + } + var ne net.Error + if errors.As(err, &ne) && ne.Timeout() { + t.Fatalf("client timeout before server handshake deadline: %v", err) + } +} + +func TestServerConnStateHook(t *testing.T) { + for _, secure := range []bool{false, true} { + t.Run(fmt.Sprintf("tls=%t", secure), func(t *testing.T) { + states := make(chan stdhttp.ConnState, 16) + _, addr := startDeadlineServer(t, secure, time.Second, 500*time.Millisecond, stdhttp.HandlerFunc(func(w stdhttp.ResponseWriter, r *stdhttp.Request) { w.WriteHeader(204) }), func(_ net.Conn, s stdhttp.ConnState) { states <- s }) + conn := dialDeadlineServer(t, addr, secure) + br := bufio.NewReader(conn) + for range 2 { + writeDeadlineRequest(t, conn, "GET / HTTP/1.1\r\nHost: localhost\r\n\r\n") + readDeadlineResponse(t, br) + } + for _, want := range []stdhttp.ConnState{stdhttp.StateNew, stdhttp.StateActive, stdhttp.StateIdle, stdhttp.StateActive, stdhttp.StateIdle} { + select { + case got := <-states: + if got != want { + t.Fatalf("state %v, want %v", got, want) + } + case <-time.After(time.Second): + t.Fatalf("missing state %v", want) + } + } + }) + } +} + +func TestServerHTTP2Deadlines(t *testing.T) { + bodyErr := make(chan error, 1) + started := make(chan struct{}) + var connections atomic.Int32 + _, addr := startDeadlineServer(t, true, 400*time.Millisecond, 200*time.Millisecond, stdhttp.HandlerFunc(func(w stdhttp.ResponseWriter, r *stdhttp.Request) { + if r.ProtoMajor != 2 { + t.Errorf("expected HTTP/2, got %s", r.Proto) + } + if r.Method == "PUT" { + // Isolate the native read timer: otherwise the equal-duration write + // timer can win and close the stream with a different error. + if err := stdhttp.NewResponseController(w).SetWriteDeadline(time.Time{}); err != nil { + bodyErr <- err + close(started) + return + } + close(started) + _, err := io.Copy(io.Discard, r.Body) + bodyErr <- err + if err != nil { + return + } + } + w.WriteHeader(204) + }), func(_ net.Conn, state stdhttp.ConnState) { + if state == stdhttp.StateNew { + connections.Add(1) + } + }, func(srv *Server) { srv.IdleTimeout = 3 * time.Second }) + // Configure only HTTP/2 so the server's HTTP/1-first ALPN preference + // cannot turn this into an HTTP/1 smoke test. + protocols := new(stdhttp.Protocols) + protocols.SetHTTP2(true) + tr := &stdhttp.Transport{Protocols: protocols, TLSClientConfig: &tls.Config{InsecureSkipVerify: true}} + t.Cleanup(tr.CloseIdleConnections) + client := &stdhttp.Client{Transport: tr, Timeout: 3 * time.Second} + for range 2 { + resp, err := client.Get("https://" + addr) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.TLS == nil || resp.TLS.NegotiatedProtocol != "h2" || resp.Proto != "HTTP/2.0" || resp.StatusCode != 204 { + t.Fatalf("unexpected %s %s", resp.Proto, resp.Status) + } + } + pr, pw := io.Pipe() + defer pr.Close() + defer pw.Close() + req, err := stdhttp.NewRequest("PUT", "https://"+addr, pr) + if err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { + resp, err := client.Do(req) + if resp != nil { + resp.Body.Close() + } + done <- err + }() + select { + case <-started: + case <-time.After(2 * time.Second): + t.Fatal("HTTP/2 PUT handler did not start") + } + // A stalled stream must not set a deadline on other multiplexed requests. + resp, err := client.Get("https://" + addr) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != 204 { + t.Fatalf("healthy HTTP/2 stream: %s", resp.Status) + } + // Native HTTP/2 still applies its existing per-stream ReadTimeout. + select { + case err := <-bodyErr: + var ne net.Error + if !errors.As(err, &ne) || !ne.Timeout() { + t.Fatalf("expected native HTTP/2 read timeout, got %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("native HTTP/2 stream timeout was lost") + } + pw.Close() + <-done + resp, err = client.Get("https://" + addr) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if connections.Load() != 1 { + t.Fatalf("HTTP/2 stream timeout replaced the connection: %d connections", connections.Load()) + } +} + +func TestServerEarlyBodyClose(t *testing.T) { + for _, secure := range []bool{false, true} { + t.Run(fmt.Sprintf("tls=%t", secure), func(t *testing.T) { + t.Parallel() + _, addr := startDeadlineServer(t, secure, 200*time.Millisecond, time.Second, stdhttp.HandlerFunc(func(w stdhttp.ResponseWriter, r *stdhttp.Request) { + if err := r.Body.Close(); err != nil { + t.Error(err) + } + w.WriteHeader(204) + }), nil) + conn := dialDeadlineServer(t, addr, secure) + br := bufio.NewReader(conn) + writeDeadlineRequest(t, conn, "PUT / HTTP/1.1\r\nHost: localhost\r\nContent-Length: 2\r\n\r\nx") + time.Sleep(300 * time.Millisecond) + writeDeadlineRequest(t, conn, "y") + readDeadlineResponse(t, br) + writeDeadlineRequest(t, conn, "GET / HTTP/1.1\r\nHost: localhost\r\n\r\n") + readDeadlineResponse(t, br) + }) + } +} + +func TestServerPipelinedDeadline(t *testing.T) { + for _, secure := range []bool{false, true} { + t.Run(fmt.Sprintf("tls=%t", secure), func(t *testing.T) { + _, addr := startDeadlineServer(t, secure, 300*time.Millisecond, 200*time.Millisecond, stdhttp.HandlerFunc(func(w stdhttp.ResponseWriter, r *stdhttp.Request) { + if _, err := io.Copy(io.Discard, r.Body); err != nil { + t.Error(err) + w.WriteHeader(400) + return + } + w.WriteHeader(204) + }), nil) + conn := dialDeadlineServer(t, addr, secure) + // Second request headers arrive in the first socket read; body continues + // past ReadTimeout to require the buffered request's StateActive hook. + writeDeadlineRequest(t, conn, "GET /first HTTP/1.1\r\nHost: localhost\r\n\r\nPUT /second HTTP/1.1\r\nHost: localhost\r\nContent-Length: 8\r\n\r\n") + br := bufio.NewReader(conn) + readDeadlineResponse(t, br) + for range 8 { + time.Sleep(80 * time.Millisecond) + writeDeadlineRequest(t, conn, "x") + } + readDeadlineResponse(t, br) + }) + } +} + +func TestServerHijackedDeadline(t *testing.T) { + for _, secure := range []bool{false, true} { + t.Run(fmt.Sprintf("tls=%t", secure), func(t *testing.T) { + t.Parallel() + done := make(chan error, 1) + _, addr := startDeadlineServer(t, secure, 150*time.Millisecond, time.Second, stdhttp.HandlerFunc(func(w stdhttp.ResponseWriter, r *stdhttp.Request) { + conn, rw, err := stdhttp.NewResponseController(w).Hijack() + if err != nil { + done <- err + return + } + defer conn.Close() + if !secure { + if _, ok := deadlineconn.Unwrap(conn).(*net.TCPConn); !ok { + done <- errors.New("grid-style Unwrap no longer returns TCPConn") + return + } + } + _, err = rw.WriteString("HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: test\r\n\r\n") + if err == nil { + err = rw.Flush() + } + if err != nil { + done <- err + return + } + b, err := rw.ReadByte() + if err == nil { + _, err = conn.Write([]byte{b}) + } + done <- err + }), nil) + conn := dialDeadlineServer(t, addr, secure) + writeDeadlineRequest(t, conn, "GET / HTTP/1.1\r\nHost: localhost\r\nConnection: Upgrade\r\nUpgrade: test\r\n\r\n") + br := bufio.NewReader(conn) + resp, err := stdhttp.ReadResponse(br, nil) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != 101 { + t.Fatalf("expected upgrade, got %s", resp.Status) + } + time.Sleep(700 * time.Millisecond) + writeDeadlineRequest(t, conn, "x") + b, err := br.ReadByte() + if err != nil || b != 'x' { + t.Fatalf("hijacked echo: %q, %v", b, err) + } + if err := <-done; err != nil { + t.Fatal(err) + } + }) + } +} + +func runContinuousDownload(t *testing.T, secure bool, idle, period time.Duration, chunks int) { + t.Helper() + _, addr := startDeadlineServer(t, secure, idle, 2*time.Second, stdhttp.HandlerFunc(func(w stdhttp.ResponseWriter, r *stdhttp.Request) { + w.Header().Set("Content-Length", fmt.Sprint(chunks)) + for range chunks { + time.Sleep(period) + if _, err := io.WriteString(w, "x"); err != nil { + t.Error(err) + return + } + if err := stdhttp.NewResponseController(w).Flush(); err != nil { + t.Error(err) + return + } + } + }), nil) + conn := dialDeadlineServer(t, addr, secure) + conn.SetDeadline(time.Now().Add(time.Duration(chunks)*period + 5*time.Second)) + start := time.Now() + writeDeadlineRequest(t, conn, "GET / HTTP/1.1\r\nHost: localhost\r\n\r\n") + resp, err := stdhttp.ReadResponse(bufio.NewReader(conn), nil) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + n, err := io.Copy(io.Discard, resp.Body) + if err != nil || n != int64(chunks) { + t.Fatalf("download: %d bytes, %v", n, err) + } + if elapsed := time.Since(start); elapsed <= idle { + t.Fatalf("download must outlast idle: %s <= %s", elapsed, idle) + } else { + t.Logf("continuous download %s > idle %s", elapsed, idle) + } +} + +func TestServerContinuousDownload(t *testing.T) { + for _, secure := range []bool{false, true} { + t.Run(fmt.Sprintf("tls=%t", secure), func(t *testing.T) { + t.Parallel() + runContinuousDownload(t, secure, 300*time.Millisecond, 80*time.Millisecond, 16) + }) + } +} + +func TestServerDefaultIdleLongDownload(t *testing.T) { + if os.Getenv("SILO_TEST_LONG_UPLOAD") != "1" { + t.Skip("set SILO_TEST_LONG_UPLOAD=1 for >30s default-idle transfer regressions") + } + t.Parallel() + for _, secure := range []bool{false, true} { + t.Run(fmt.Sprintf("tls=%t", secure), func(t *testing.T) { + t.Parallel() + runContinuousDownload(t, secure, DefaultIdleTimeout, time.Second, 33) + }) + } +}