From 51f9c28f6e4828f44083fe3392f2a2a47e1c2de3 Mon Sep 17 00:00:00 2001 From: Aliaksandr Stsiapanay Date: Mon, 14 Sep 2026 12:57:00 +0300 Subject: [PATCH] fix: NPE in ResourceService when blob contentLength is null #1971 Guard the nullable contentLength returned by blob-store metadata in toFileMetadata and getResourceStream, instead of unboxing it directly into a primitive long. Reachable whenever a FILE/SKILL resource's Redis cache entry has expired, forcing a cold blob-store metadata read (e.g. the complex-resource sweep's deliberately long grace period guarantees this on every reclaim). --- .../core/storage/service/ResourceService.java | 5 +- .../storage/service/ResourceServiceTest.java | 63 +++++++++++++++++++ 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/storage/src/main/java/com/epam/aidial/core/storage/service/ResourceService.java b/storage/src/main/java/com/epam/aidial/core/storage/service/ResourceService.java index 474bc7df3..f4ad885d6 100644 --- a/storage/src/main/java/com/epam/aidial/core/storage/service/ResourceService.java +++ b/storage/src/main/java/com/epam/aidial/core/storage/service/ResourceService.java @@ -501,7 +501,8 @@ private static ResourceItemMetadata toResourceItemMetadata( private static FileMetadata toFileMetadata( ResourceDescriptor resource, Result result) { - return (FileMetadata) new FileMetadata(resource, result.contentLength(), result.contentType()) + long contentLength = result.contentLength() == null ? 0L : result.contentLength(); + return (FileMetadata) new FileMetadata(resource, contentLength, result.contentType()) .setCreatedAt(result.createdAt) .setUpdatedAt(result.updatedAt) .setAuthor(result.author) @@ -606,7 +607,7 @@ public ResourceStream getResourceStream(ResourceDescriptor resource, EtagHeader String contentType = metadata.getContentMetadata().getContentType(); Long length = metadata.getContentMetadata().getContentLength(); - if (length <= maxSizeToCache) { + if (length == null || length <= maxSizeToCache) { result = blobToResult(blob, metadata); redisPut(key, result); return ResourceStream.fromResult(result, etagHeader); diff --git a/storage/src/test/java/com/epam/aidial/core/storage/service/ResourceServiceTest.java b/storage/src/test/java/com/epam/aidial/core/storage/service/ResourceServiceTest.java index 4061b1a5a..21b4de09f 100644 --- a/storage/src/test/java/com/epam/aidial/core/storage/service/ResourceServiceTest.java +++ b/storage/src/test/java/com/epam/aidial/core/storage/service/ResourceServiceTest.java @@ -3,12 +3,16 @@ import com.epam.aidial.core.storage.FileUtil; import com.epam.aidial.core.storage.blobstore.BlobStorage; import com.epam.aidial.core.storage.blobstore.Storage; +import com.epam.aidial.core.storage.data.FileMetadata; import com.epam.aidial.core.storage.data.ResourceItemMetadata; import com.epam.aidial.core.storage.resource.ResourceDescriptor; import com.epam.aidial.core.storage.resource.ResourceTypes; import com.epam.aidial.core.storage.util.EtagHeader; import com.fasterxml.jackson.databind.ObjectMapper; import org.apache.commons.lang3.tuple.Pair; +import org.jclouds.blobstore.domain.Blob; +import org.jclouds.blobstore.domain.MutableBlobMetadata; +import org.jclouds.io.MutableContentMetadata; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -309,6 +313,65 @@ public void testLoadPropagatesBlobFailure() { assertEquals("blob is down", root.getMessage()); } + /** + * The blob store's own metadata may not report a content length (observed in production + * against GCS: https://github.com/epam/ai-dial-core/issues/1971). getResourceMetadata must + * not NPE when unboxing it into FileMetadata's primitive field. + */ + @Test + public void testGetResourceMetadataToleratesNullContentLengthFromBlobMetadata() { + ResourceDescriptor descriptor = fileResource("null_content_length"); + storage.store(descriptor.getAbsoluteFilePath(), "application/octet-stream", null, + Map.of("author", "user"), "body".getBytes()); + + BlobStorage spy = blobStorageWithNullContentLength(descriptor.getAbsoluteFilePath()); + ResourceService spied = serviceWithBlobStorage(spy, "null-content-length-meta"); + + ResourceItemMetadata metadata = spied.getResourceMetadata(descriptor); + + assertNotNull(metadata); + assertEquals(0L, ((FileMetadata) metadata).getContentLength()); + } + + /** + * Same null-content-length hazard, but on the getResourceStream path, which reads the blob's + * metadata directly rather than through getResourceMetadata. + */ + @Test + public void testGetResourceStreamToleratesNullContentLengthFromBlobMetadata() throws IOException { + ResourceDescriptor descriptor = fileResource("null_content_length_stream"); + storage.store(descriptor.getAbsoluteFilePath(), "application/octet-stream", null, + Map.of("author", "user"), "body".getBytes()); + + BlobStorage spy = blobStorageWithNullContentLength(descriptor.getAbsoluteFilePath()); + ResourceService spied = serviceWithBlobStorage(spy, "null-content-length-stream"); + + try (ResourceService.ResourceStream stream = spied.getResourceStream(descriptor, EtagHeader.ANY)) { + assertNotNull(stream); + assertEquals(4L, stream.contentLength()); + } + } + + private BlobStorage blobStorageWithNullContentLength(String blobKey) { + Blob realBlob = storage.load(blobKey); + MutableBlobMetadata spiedMeta = Mockito.spy(realBlob.getMetadata()); + MutableContentMetadata spiedContent = Mockito.spy(spiedMeta.getContentMetadata()); + Mockito.when(spiedContent.getContentLength()).thenReturn(null); + Mockito.when(spiedMeta.getContentMetadata()).thenReturn(spiedContent); + + Blob spiedBlob = Mockito.spy(realBlob); + Mockito.when(spiedBlob.getMetadata()).thenReturn(spiedMeta); + + BlobStorage spy = Mockito.spy(storage); + Mockito.when(spy.meta(blobKey)).thenReturn(spiedMeta); + Mockito.when(spy.load(blobKey)).thenReturn(spiedBlob); + return spy; + } + + private static ResourceDescriptor fileResource(String name) { + return new ResourceDescriptor(ResourceTypes.FILE, name, List.of(), "bucket", "bucket/", false); + } + private static Map load(ResourceService target, List descriptors) { List> loaded = new ArrayList<>(); target.load(descriptors.stream().map(ResourceItemMetadata::new).toList(), loaded);