Summary
On the legacy etcd federation path, CopyObjectResult.LastModified is 0001-01-01T00:00:00.000Z, and the federated UploadPartCopy has the same gap for CopyPartResult.LastModified. The object is stored correctly; only the reported time is wrong.
Pre-existing on d1105bbb3 and bcc8871b1. Found during the Codex post-merge review of #163.
Root cause
CopyObjectHandler sets objInfo.ModTime = remoteObjInfo.LastModified (cmd/object-handlers.go around line 2005); CopyObjectPartHandler reads partInfo.LastModified from Core.PutObjectPart the same way (cmd/object-multipart-handlers.go around line 575 to 581).
- The selected minio-go's single-PUT and
PutObjectPart result constructors leave LastModified unset, even if the response included Last-Modified (api-put-object-streaming.go around line 787, api-put-object-multipart.go around line 352).
- The server does not emit a
Last-Modified header on PutObject (setPutObjHeadersWithChecksum sets ETag, version id, delete marker, lifecycle and checksum headers only), and Core.PutObject exposes neither raw response headers nor Date. Date is the HTTP response time, not the object's modification time, and must not be substituted.
- The response serializer (
cmd/api-response.go around line 780) renders an unset ModTime as the zero time, so the current output is a visibly wrong value, not an omission.
Options
- Authoritative write response (preferred long term). For CopyObject, return this write's object modification time in the destination PutObject response (a MinIO-specific header). For UploadPartCopy, return this part write's modification time in the destination UploadPart response. Expose the corresponding values through both forwarding calls (minio-go or a thin wrapper around
Core.PutObject / Core.PutObjectPart). Needs server plus client cooperation, so it is not a one-line change.
- Version-qualified HEAD after the write (CopyObject only). When the remote returned a non-null
VersionID, core.StatObject with that version yields the time of exactly this version. It cannot recover an UploadPartCopy part's modification time: an uncompleted part has no object version to bind to, and the same upload id / part number can be re-uploaded, so a plain ListParts is not proof of "this write" either. Costs that must be decided first even for CopyObject: it needs destination read permission the copy did not need before; an SSE-C destination needs the destination key on the HEAD; a HEAD failure (403, timeout, version already deleted) happens after a successful PUT and must not be turned into a copy failure that triggers retries and extra versions; it only has second precision while the serializer emits milliseconds; a "null" version is not a stable binding, and an unqualified HEAD on an unversioned bucket cannot exclude a concurrent overwrite even if ETags match.
- Leave it unset: keeps the zero time; this is the status quo, not a fix.
Recommendation: keep this issue open until option 1 or an explicitly bounded option 2 is chosen. Do not use the proxy's UTCNow().
Test plan (once a mechanism is chosen)
- Versioned remote bucket:
LastModified equals the destination version's stored ModTime at the precision of the chosen mechanism.
- Unversioned remote bucket: documented behaviour (either a bounded fallback or explicitly unset), asserted as such.
- For UploadPartCopy, validate the timestamp against the exact part write using the separately chosen part-response mechanism; the CopyObject HEAD fallback does not close this acceptance item.
Priority
P2: wrong metadata in an API response; no data loss.
Summary
On the legacy etcd federation path,
CopyObjectResult.LastModifiedis0001-01-01T00:00:00.000Z, and the federatedUploadPartCopyhas the same gap forCopyPartResult.LastModified. The object is stored correctly; only the reported time is wrong.Pre-existing on
d1105bbb3andbcc8871b1. Found during the Codex post-merge review of #163.Root cause
CopyObjectHandlersetsobjInfo.ModTime = remoteObjInfo.LastModified(cmd/object-handlers.goaround line 2005);CopyObjectPartHandlerreadspartInfo.LastModifiedfromCore.PutObjectPartthe same way (cmd/object-multipart-handlers.goaround line 575 to 581).PutObjectPartresult constructors leaveLastModifiedunset, even if the response includedLast-Modified(api-put-object-streaming.goaround line 787,api-put-object-multipart.goaround line 352).Last-Modifiedheader onPutObject(setPutObjHeadersWithChecksumsets ETag, version id, delete marker, lifecycle and checksum headers only), andCore.PutObjectexposes neither raw response headers norDate.Dateis the HTTP response time, not the object's modification time, and must not be substituted.cmd/api-response.goaround line 780) renders an unsetModTimeas the zero time, so the current output is a visibly wrong value, not an omission.Options
Core.PutObject/Core.PutObjectPart). Needs server plus client cooperation, so it is not a one-line change.VersionID,core.StatObjectwith that version yields the time of exactly this version. It cannot recover an UploadPartCopy part's modification time: an uncompleted part has no object version to bind to, and the same upload id / part number can be re-uploaded, so a plain ListParts is not proof of "this write" either. Costs that must be decided first even for CopyObject: it needs destination read permission the copy did not need before; an SSE-C destination needs the destination key on the HEAD; a HEAD failure (403, timeout, version already deleted) happens after a successful PUT and must not be turned into a copy failure that triggers retries and extra versions; it only has second precision while the serializer emits milliseconds; a"null"version is not a stable binding, and an unqualified HEAD on an unversioned bucket cannot exclude a concurrent overwrite even if ETags match.Recommendation: keep this issue open until option 1 or an explicitly bounded option 2 is chosen. Do not use the proxy's
UTCNow().Test plan (once a mechanism is chosen)
LastModifiedequals the destination version's storedModTimeat the precision of the chosen mechanism.Priority
P2: wrong metadata in an API response; no data loss.