diff --git a/server/src/main/kotlin/com/adobe/testing/s3mock/controller/MultipartController.kt b/server/src/main/kotlin/com/adobe/testing/s3mock/controller/MultipartController.kt index 7a77b21a9..320988da4 100644 --- a/server/src/main/kotlin/com/adobe/testing/s3mock/controller/MultipartController.kt +++ b/server/src/main/kotlin/com/adobe/testing/s3mock/controller/MultipartController.kt @@ -262,6 +262,8 @@ class MultipartController( partNum, tempFile, encryptionHeadersFrom(httpHeaders), + checksumAlgorithm, + checksum, ) runCatching { tempFile.toFile().deleteRecursively() } diff --git a/server/src/main/kotlin/com/adobe/testing/s3mock/dto/Part.kt b/server/src/main/kotlin/com/adobe/testing/s3mock/dto/Part.kt index 191798d5a..40b4ae4c5 100644 --- a/server/src/main/kotlin/com/adobe/testing/s3mock/dto/Part.kt +++ b/server/src/main/kotlin/com/adobe/testing/s3mock/dto/Part.kt @@ -34,6 +34,16 @@ class Part( val lastModified: Date, @param:JsonProperty("Size", namespace = "http://s3.amazonaws.com/doc/2006-03-01/") val size: Long, + @param:JsonProperty("ChecksumCRC32", namespace = "http://s3.amazonaws.com/doc/2006-03-01/") + val checksumCRC32: String? = null, + @param:JsonProperty("ChecksumCRC32C", namespace = "http://s3.amazonaws.com/doc/2006-03-01/") + val checksumCRC32C: String? = null, + @param:JsonProperty("ChecksumCRC64NVME", namespace = "http://s3.amazonaws.com/doc/2006-03-01/") + val checksumCRC64NVME: String? = null, + @param:JsonProperty("ChecksumSHA1", namespace = "http://s3.amazonaws.com/doc/2006-03-01/") + val checksumSHA1: String? = null, + @param:JsonProperty("ChecksumSHA256", namespace = "http://s3.amazonaws.com/doc/2006-03-01/") + val checksumSHA256: String? = null, ) { constructor(partNumber: Int, etag: String?, size: Long) : this(partNumber, normalizeEtag(etag), Date(), size) @@ -47,6 +57,17 @@ class Part( this.etag = etag } + /** Returns the checksum value for the given [algorithm], or `null` if not set. */ + @JsonIgnore + fun checksum(algorithm: ChecksumAlgorithm): String? = + when (algorithm) { + ChecksumAlgorithm.CRC32 -> checksumCRC32 + ChecksumAlgorithm.CRC32C -> checksumCRC32C + ChecksumAlgorithm.CRC64NVME -> checksumCRC64NVME + ChecksumAlgorithm.SHA1 -> checksumSHA1 + ChecksumAlgorithm.SHA256 -> checksumSHA256 + } + override fun equals(other: Any?): Boolean { if (this === other) return true if (javaClass != other?.javaClass) return false @@ -57,6 +78,11 @@ class Part( if (size != other.size) return false if (lastModified != other.lastModified) return false if (etag != other.etag) return false + if (checksumCRC32 != other.checksumCRC32) return false + if (checksumCRC32C != other.checksumCRC32C) return false + if (checksumCRC64NVME != other.checksumCRC64NVME) return false + if (checksumSHA1 != other.checksumSHA1) return false + if (checksumSHA256 != other.checksumSHA256) return false return true } @@ -66,6 +92,11 @@ class Part( result = 31 * result + size.hashCode() result = 31 * result + lastModified.hashCode() result = 31 * result + (etag?.hashCode() ?: 0) + result = 31 * result + (checksumCRC32?.hashCode() ?: 0) + result = 31 * result + (checksumCRC32C?.hashCode() ?: 0) + result = 31 * result + (checksumCRC64NVME?.hashCode() ?: 0) + result = 31 * result + (checksumSHA1?.hashCode() ?: 0) + result = 31 * result + (checksumSHA256?.hashCode() ?: 0) return result } } diff --git a/server/src/main/kotlin/com/adobe/testing/s3mock/service/MultipartService.kt b/server/src/main/kotlin/com/adobe/testing/s3mock/service/MultipartService.kt index d6e1809ac..4e137ded6 100644 --- a/server/src/main/kotlin/com/adobe/testing/s3mock/service/MultipartService.kt +++ b/server/src/main/kotlin/com/adobe/testing/s3mock/service/MultipartService.kt @@ -53,6 +53,8 @@ open class MultipartService( partNumber: Int, path: Path, encryptionHeaders: Map, + checksumAlgorithm: ChecksumAlgorithm? = null, + checksum: String? = null, ): String? { val bucketMetadata = bucketStore.getBucketMetadata(bucketName) val uuid = bucketMetadata.getID(key) ?: return null @@ -63,6 +65,8 @@ open class MultipartService( partNumber, path, encryptionHeaders, + checksumAlgorithm, + checksum, ) } diff --git a/server/src/main/kotlin/com/adobe/testing/s3mock/store/MultipartStore.kt b/server/src/main/kotlin/com/adobe/testing/s3mock/store/MultipartStore.kt index 9725538dd..5b79ef6ca 100644 --- a/server/src/main/kotlin/com/adobe/testing/s3mock/store/MultipartStore.kt +++ b/server/src/main/kotlin/com/adobe/testing/s3mock/store/MultipartStore.kt @@ -193,10 +193,24 @@ open class MultipartStore( partNumber: Int, path: Path, encryptionHeaders: Map, + checksumAlgorithm: ChecksumAlgorithm? = null, + checksum: String? = null, ): String { val file = inputPathToFile(path, getPartPath(bucket, uploadId, partNumber)) - - return DigestUtil.hexDigest(encryptionHeaders[AwsHttpHeaders.X_AMZ_SERVER_SIDE_ENCRYPTION_AWS_KMS_KEY_ID], file) + val etag = DigestUtil.hexDigest(encryptionHeaders[AwsHttpHeaders.X_AMZ_SERVER_SIDE_ENCRYPTION_AWS_KMS_KEY_ID], file) + writePartMetadata( + bucket, + uploadId, + PartMetadata( + partNumber = partNumber, + etag = etag, + checksumAlgorithm = checksumAlgorithm, + checksum = checksum, + size = file.length(), + lastModified = file.lastModified(), + ), + ) + return etag } fun completeMultipartUpload( @@ -225,7 +239,8 @@ open class MultipartStore( input.transferTo(os) } } - val checksumFor = validateChecksums(uploadInfo, tempFile, parts, partsPaths, checksum, checksumType, checksumAlgorithm) + val checksumFor = + validateChecksums(uploadInfo, tempFile, parts, partsPaths, checksum, checksumType, checksumAlgorithm, bucket, uploadId) val etag = DigestUtil.hexDigestMultipart(partsPaths) val s3ObjectMetadata = objectStore.storeS3ObjectMetadata( @@ -280,10 +295,15 @@ open class MultipartStore( val name = it.fileName.toString() val prefix = name.substringBefore('.') val partNumber = prefix.toInt() - val file = it.toFile() - val partMd5 = DigestUtil.hexDigest(file) - val lastModified = Date(file.lastModified()) - Part(partNumber, partMd5, lastModified, file.length()) + val metadata = readPartMetadata(bucket, uploadId, partNumber) + if (metadata != null) { + partFromMetadata(metadata) + } else { + val file = it.toFile() + val partMd5 = DigestUtil.hexDigest(file) + val lastModified = Date(file.lastModified()) + Part(partNumber, partMd5, lastModified, file.length()) + } }.sortedBy { it.partNumber } .toList() } catch (e: IOException) { @@ -304,13 +324,21 @@ open class MultipartStore( ): String { verifyMultipartUploadPreparation(destinationBucket, destinationId, uploadId) - return copyPartToFile( - bucket, - id, - copyRange, - createPartFile(destinationBucket, destinationId, uploadId, partNumber), - versionId, + val partFile = createPartFile(destinationBucket, destinationId, uploadId, partNumber) + val etag = copyPartToFile(bucket, id, copyRange, partFile, versionId) + writePartMetadata( + destinationBucket, + uploadId, + PartMetadata( + partNumber = partNumber, + etag = etag, + checksumAlgorithm = null, + checksum = null, + size = partFile.length(), + lastModified = partFile.lastModified(), + ), ) + return etag } private fun copyPartToFile( @@ -396,6 +424,78 @@ open class MultipartStore( uploadId: UUID, ): Path = getPartsFolder(bucket, uploadId).resolve(MULTIPART_UPLOAD_META_FILE) + private fun getPartMetadataPath( + bucket: BucketMetadata, + uploadId: UUID, + partNumber: Int, + ): Path = getPartsFolder(bucket, uploadId).resolve(partNumber.toString() + PART_METADATA_SUFFIX) + + private fun writePartMetadata( + bucket: BucketMetadata, + uploadId: UUID, + partMetadata: PartMetadata, + ) { + try { + val metaFile = getPartMetadataPath(bucket, uploadId, partMetadata.partNumber).toFile() + objectMapper.writeValue(metaFile, partMetadata) + } catch (e: IOException) { + throw IllegalStateException( + "Could not write part metadata file. uploadId=$uploadId, partNumber=${partMetadata.partNumber}", + e, + ) + } + } + + private fun readPartMetadata( + bucket: BucketMetadata, + uploadId: UUID, + partNumber: Int, + ): PartMetadata? { + val metaPath = getPartMetadataPath(bucket, uploadId, partNumber) + if (!metaPath.exists()) return null + return try { + objectMapper.readValue(metaPath.toFile(), PartMetadata::class.java) + } catch (e: IOException) { + LOG.warn("Could not read part metadata file. uploadId={}, partNumber={}", uploadId, partNumber, e) + null + } + } + + private fun partFromMetadata(metadata: PartMetadata): Part { + val checksum = metadata.checksum + return when (metadata.checksumAlgorithm) { + ChecksumAlgorithm.CRC32 -> { + Part(metadata.partNumber, metadata.etag, Date(metadata.lastModified), metadata.size, checksumCRC32 = checksum) + } + + ChecksumAlgorithm.CRC32C -> { + Part(metadata.partNumber, metadata.etag, Date(metadata.lastModified), metadata.size, checksumCRC32C = checksum) + } + + ChecksumAlgorithm.CRC64NVME -> { + Part( + metadata.partNumber, + metadata.etag, + Date(metadata.lastModified), + metadata.size, + checksumCRC64NVME = checksum, + ) + } + + ChecksumAlgorithm.SHA1 -> { + Part(metadata.partNumber, metadata.etag, Date(metadata.lastModified), metadata.size, checksumSHA1 = checksum) + } + + ChecksumAlgorithm.SHA256 -> { + Part(metadata.partNumber, metadata.etag, Date(metadata.lastModified), metadata.size, checksumSHA256 = checksum) + } + + null -> { + Part(metadata.partNumber, metadata.etag, Date(metadata.lastModified), metadata.size) + } + } + } + private fun getPartsFolder( bucket: BucketMetadata, uploadId: UUID, @@ -463,6 +563,8 @@ open class MultipartStore( checksum: String?, checksumType: ChecksumType?, checksumAlgorithm: ChecksumAlgorithm?, + bucket: BucketMetadata, + uploadId: UUID, ): String? { val checksumToValidate = checksum ?: uploadInfo.checksum val checksumAlgorithmToValidate = checksumAlgorithm ?: uploadInfo.checksumAlgorithm @@ -471,7 +573,7 @@ open class MultipartStore( } val checksumFor = if (uploadInfo.checksumType == ChecksumType.COMPOSITE) { - checksumFor(partsPaths, uploadInfo) + checksumForComposite(partsPaths, uploadInfo, bucket, uploadId) } else { checksumFor(tempFile, uploadInfo) } @@ -493,12 +595,35 @@ open class MultipartStore( return checksumFor } - private fun checksumFor( + /** + * Computes the COMPOSITE checksum. Uses persisted per-part checksums when all parts have them + * stored (avoids re-reading the binary part files); falls back to computing from files otherwise. + */ + private fun checksumForComposite( paths: List, uploadInfo: MultipartUploadInfo, + bucket: BucketMetadata, + uploadId: UUID, ): String? = uploadInfo.checksumAlgorithm?.let { algo -> - DigestUtil.checksumMultipart(paths, algo.toChecksumAlgorithm()) + val partNumbers = + paths.map { + it.fileName + .toString() + .substringBefore('.') + .toInt() + } + val storedChecksums = + partNumbers + .map { readPartMetadata(bucket, uploadId, it) } + .takeIf { metadataList -> + metadataList.all { it?.checksum != null && it.checksumAlgorithm == uploadInfo.checksumAlgorithm } + }?.mapNotNull { it?.checksum } + if (storedChecksums != null) { + DigestUtil.checksumMultipartFromStoredChecksums(storedChecksums, algo.toChecksumAlgorithm()) + } else { + DigestUtil.checksumMultipart(paths, algo.toChecksumAlgorithm()) + } } private fun checksumFor( @@ -512,6 +637,7 @@ open class MultipartStore( companion object { private val LOG: Logger = LoggerFactory.getLogger(MultipartStore::class.java) private const val PART_SUFFIX = ".part" + private const val PART_METADATA_SUFFIX = ".partMetadata.json" private const val MULTIPART_UPLOAD_META_FILE = "multipartMetadata.json" const val MULTIPARTS_FOLDER: String = "multiparts" diff --git a/server/src/main/kotlin/com/adobe/testing/s3mock/store/PartMetadata.kt b/server/src/main/kotlin/com/adobe/testing/s3mock/store/PartMetadata.kt new file mode 100644 index 000000000..b3f3bdc4f --- /dev/null +++ b/server/src/main/kotlin/com/adobe/testing/s3mock/store/PartMetadata.kt @@ -0,0 +1,33 @@ +/* + * Copyright 2017-2026 Adobe. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.adobe.testing.s3mock.store + +import com.adobe.testing.s3mock.dto.ChecksumAlgorithm + +/** + * Encapsulates the metadata of a single multipart upload part, persisted alongside the part's + * binary data so that ETag, checksum, size, and last-modified do not have to be recomputed from + * the binary file on subsequent reads. + */ +data class PartMetadata( + val partNumber: Int, + val etag: String?, + val checksumAlgorithm: ChecksumAlgorithm?, + val checksum: String?, + val size: Long, + val lastModified: Long, +) diff --git a/server/src/main/kotlin/com/adobe/testing/s3mock/util/DigestUtil.kt b/server/src/main/kotlin/com/adobe/testing/s3mock/util/DigestUtil.kt index be6dc4e99..4f4097a29 100644 --- a/server/src/main/kotlin/com/adobe/testing/s3mock/util/DigestUtil.kt +++ b/server/src/main/kotlin/com/adobe/testing/s3mock/util/DigestUtil.kt @@ -172,6 +172,29 @@ object DigestUtil { algorithm: software.amazon.awssdk.checksums.spi.ChecksumAlgorithm, ): String = "${BinaryUtils.toBase64(checksum(paths, algorithm))}-${paths.size}" + /** + * Calculates the composite checksum from pre-computed per-part checksums encoded as base64 + * strings. This avoids re-reading the part binary files when per-part checksums were already + * persisted during upload. + * + * Each base64-encoded part checksum is decoded to bytes; all decoded bytes are concatenated; + * then the final checksum is computed over that concatenation. + * [API](https://docs.aws.amazon.com/AmazonS3/latest/userguide/checking-object-integrity.html) + */ + @JvmStatic + fun checksumMultipartFromStoredChecksums( + partChecksums: List, + algorithm: software.amazon.awssdk.checksums.spi.ChecksumAlgorithm, + ): String { + val sdkChecksum = SdkChecksum.forAlgorithm(algorithm) + val allChecksumBytes = + partChecksums + .flatMap { Base64.getDecoder().decode(it).toList() } + .toByteArray() + sdkChecksum.update(allChecksumBytes, 0, allChecksumBytes.size) + return "${BinaryUtils.toBase64(sdkChecksum.checksumBytes)}-${partChecksums.size}" + } + @JvmStatic fun hexDigest(bytes: ByteArray): String { val md = MessageDigest.getInstance("MD5") diff --git a/server/src/test/kotlin/com/adobe/testing/s3mock/controller/MultipartControllerTest.kt b/server/src/test/kotlin/com/adobe/testing/s3mock/controller/MultipartControllerTest.kt index f8133e96d..9d390f5bd 100644 --- a/server/src/test/kotlin/com/adobe/testing/s3mock/controller/MultipartControllerTest.kt +++ b/server/src/test/kotlin/com/adobe/testing/s3mock/controller/MultipartControllerTest.kt @@ -1081,7 +1081,7 @@ internal class MultipartControllerTest : BaseControllerTest() { val temp = Files.createTempFile("junie", "part") whenever(multipartService.toTempFile(any(), any())).thenReturn(Pair(temp, null)) whenever( - multipartService.putPart(eq(TEST_BUCKET_NAME), eq("my/key.txt"), eq(uploadId), eq(1), eq(temp), any()), + multipartService.putPart(eq(TEST_BUCKET_NAME), eq("my/key.txt"), eq(uploadId), eq(1), eq(temp), any(), anyOrNull(), anyOrNull()), ).thenReturn("etag-123") val uri = @@ -1423,7 +1423,7 @@ internal class MultipartControllerTest : BaseControllerTest() { } whenever( - multipartService.putPart(eq(TEST_BUCKET_NAME), eq("my/key.txt"), eq(uploadId), eq(1), eq(temp), any()), + multipartService.putPart(eq(TEST_BUCKET_NAME), eq("my/key.txt"), eq(uploadId), eq(1), eq(temp), any(), anyOrNull(), anyOrNull()), ).thenReturn("etag-321") val uri = diff --git a/server/src/test/kotlin/com/adobe/testing/s3mock/store/MultipartStoreTest.kt b/server/src/test/kotlin/com/adobe/testing/s3mock/store/MultipartStoreTest.kt index 4caeb5fa0..e778e8a0b 100644 --- a/server/src/test/kotlin/com/adobe/testing/s3mock/store/MultipartStoreTest.kt +++ b/server/src/test/kotlin/com/adobe/testing/s3mock/store/MultipartStoreTest.kt @@ -1200,6 +1200,185 @@ internal class MultipartStoreTest : StoreTestBase() { assertThat(s).containsExactlyElementsOf((1..10).map { "$it" }) } + @Test + @Throws(IOException::class) + fun `putPart persists part metadata sidecar file`() { + val fileName = "PartFile" + val id = managedId() + val partContent = "Part1" + val tempFile = Files.createTempFile("", "") + partContent.toByteArray().inputStream().transferTo(tempFile.outputStream()) + val checksumAlgorithm = ChecksumAlgorithm.CRC32 + val expectedChecksum = DigestUtil.checksumFor(tempFile, checksumAlgorithm.toChecksumAlgorithm()) + + val bucket = metadataFrom(TEST_BUCKET_NAME) + val multipartUpload = + multipartStore.createMultipartUpload( + bucket, + fileName, + id, + DEFAULT_CONTENT_TYPE, + storeHeaders(), + TEST_OWNER, + TEST_INITIATOR, + NO_USER_METADATA, + NO_ENCRYPTION_HEADERS, + NO_TAGS, + StorageClass.STANDARD, + ChecksumType.COMPOSITE, + checksumAlgorithm, + ) + val uploadId = UUID.fromString(multipartUpload.uploadId) + + multipartStore.putPart( + bucket, + id, + uploadId, + 1, + tempFile, + NO_ENCRYPTION_HEADERS, + checksumAlgorithm, + expectedChecksum, + ) + + assertThat( + Paths + .get( + rootFolder.absolutePath, + TEST_BUCKET_NAME, + MultipartStore.MULTIPARTS_FOLDER, + uploadId.toString(), + "1.partMetadata.json", + ).toFile(), + ).exists() + + multipartStore.abortMultipartUpload(bucket, id, uploadId) + } + + @Test + @Throws(IOException::class) + fun `getMultipartUploadParts returns checksum from persisted metadata`() { + val fileName = "PartFile" + val id = managedId() + val partContent = "Part1" + val tempFile = Files.createTempFile("", "") + partContent.toByteArray().inputStream().transferTo(tempFile.outputStream()) + val checksumAlgorithm = ChecksumAlgorithm.CRC32 + val expectedChecksum = DigestUtil.checksumFor(tempFile, checksumAlgorithm.toChecksumAlgorithm()) + + val bucket = metadataFrom(TEST_BUCKET_NAME) + val multipartUpload = + multipartStore.createMultipartUpload( + bucket, + fileName, + id, + DEFAULT_CONTENT_TYPE, + storeHeaders(), + TEST_OWNER, + TEST_INITIATOR, + NO_USER_METADATA, + NO_ENCRYPTION_HEADERS, + NO_TAGS, + StorageClass.STANDARD, + ChecksumType.COMPOSITE, + checksumAlgorithm, + ) + val uploadId = UUID.fromString(multipartUpload.uploadId) + + multipartStore.putPart( + bucket, + id, + uploadId, + 1, + tempFile, + NO_ENCRYPTION_HEADERS, + checksumAlgorithm, + expectedChecksum, + ) + + multipartStore.getMultipartUploadParts(bucket, id, uploadId).also { + assertThat(it).hasSize(1) + val part = it[0] + assertThat(part.partNumber).isEqualTo(1) + assertThat(part.size).isEqualTo(partContent.toByteArray().size.toLong()) + assertThat(part.checksumCRC32).isEqualTo(expectedChecksum) + assertThat(part.checksumCRC32C).isNull() + assertThat(part.checksumSHA1).isNull() + assertThat(part.checksumSHA256).isNull() + } + + multipartStore.abortMultipartUpload(bucket, id, uploadId) + } + + @Test + @Throws(IOException::class) + fun `completeMultipartUpload reuses stored part checksums for COMPOSITE computation`() { + val fileName = "PartFile" + val id = managedId() + val part1Content = "Part1" + val part2Content = "Part2" + val tempFile1 = Files.createTempFile("", "") + part1Content.toByteArray().inputStream().transferTo(tempFile1.outputStream()) + val tempFile2 = Files.createTempFile("", "") + part2Content.toByteArray().inputStream().transferTo(tempFile2.outputStream()) + + val checksumAlgorithm = ChecksumAlgorithm.CRC32 + val checksum1 = DigestUtil.checksumFor(tempFile1, checksumAlgorithm.toChecksumAlgorithm()) + val checksum2 = DigestUtil.checksumFor(tempFile2, checksumAlgorithm.toChecksumAlgorithm()) + val expectedCompositeChecksum = + DigestUtil.checksumMultipartFromStoredChecksums( + listOf(checksum1, checksum2), + checksumAlgorithm.toChecksumAlgorithm(), + ) + + val bucket = metadataFrom(TEST_BUCKET_NAME) + val multipartUpload = + multipartStore.createMultipartUpload( + bucket, + fileName, + id, + DEFAULT_CONTENT_TYPE, + storeHeaders(), + TEST_OWNER, + TEST_INITIATOR, + NO_USER_METADATA, + NO_ENCRYPTION_HEADERS, + NO_TAGS, + StorageClass.STANDARD, + ChecksumType.COMPOSITE, + checksumAlgorithm, + ) + val uploadId = UUID.fromString(multipartUpload.uploadId) + val multipartUploadInfo = multipartStore.getMultipartUploadInfo(bucket, uploadId) + + multipartStore.putPart(bucket, id, uploadId, 1, tempFile1, NO_ENCRYPTION_HEADERS, checksumAlgorithm, checksum1) + multipartStore.putPart(bucket, id, uploadId, 2, tempFile2, NO_ENCRYPTION_HEADERS, checksumAlgorithm, checksum2) + + val result = + multipartStore.completeMultipartUpload( + bucket, + fileName, + id, + uploadId, + listOf( + CompletedPart(checksum1, null, null, null, null, null, 1), + CompletedPart(checksum2, null, null, null, null, null, 2), + ), + NO_ENCRYPTION_HEADERS, + multipartUploadInfo, + "location", + expectedCompositeChecksum, + ChecksumType.COMPOSITE, + checksumAlgorithm, + ) + + assertThat(result).isNotNull + objectStore.getS3ObjectMetadata(bucket, id, null).also { + assertThat(it!!.checksum).isEqualTo(expectedCompositeChecksum) + assertThat(it.checksumAlgorithm).isEqualTo(checksumAlgorithm) + } + } + private fun managedId(): UUID { val uuid = UUID.randomUUID() idCache.add(uuid)