diff --git a/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/AcoSpan.java b/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/AcoSpan.java new file mode 100644 index 000000000000..d2972fab8900 --- /dev/null +++ b/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/AcoSpan.java @@ -0,0 +1,170 @@ +/* + * Copyright 2026 Google LLC + * + * 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.google.cloud.storage; + +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.SpanContext; +import io.opentelemetry.api.trace.StatusCode; +import java.util.concurrent.TimeUnit; + +final class AcoSpan implements Span { + private final Span delegate; + private final String bucketName; + private final OtelStorageDecorator parent; + + AcoSpan(Span delegate, String bucketName, OtelStorageDecorator parent) { + this.delegate = delegate; + this.bucketName = bucketName; + this.parent = parent; + } + + private void applyCacheAttributes() { + if (bucketName != null && parent != null) { + BucketMetadataCache.BucketMetadata md = parent.bucketMetadataCache.get(bucketName); + if (md != null && !md.fetchPending) { + delegate.setAttribute("gcp.resource.destination.id", md.resource); + delegate.setAttribute("gcp.resource.destination.location", md.location); + } + } + } + + @Override + public void end() { + applyCacheAttributes(); + delegate.end(); + } + + @Override + public void end(long timestamp, TimeUnit unit) { + applyCacheAttributes(); + delegate.end(timestamp, unit); + } + + @Override + public Span recordException(Throwable exception) { + delegate.recordException(exception); + if (exception instanceof StorageException && parent != null) { + StorageException se = (StorageException) exception; + if (se.getCode() == 404 && se.getMessage() != null) { + String msg = se.getMessage().toLowerCase(java.util.Locale.US); + if (msg.contains("bucket not found") || msg.contains("bucket does not exist")) { + parent.bucketMetadataCache.remove(bucketName); + } + } + } + return this; + } + + @Override + public Span recordException(Throwable exception, Attributes attributes) { + delegate.recordException(exception, attributes); + if (exception instanceof StorageException && parent != null) { + StorageException se = (StorageException) exception; + if (se.getCode() == 404 && se.getMessage() != null) { + String msg = se.getMessage().toLowerCase(java.util.Locale.US); + if (msg.contains("bucket not found") || msg.contains("bucket does not exist")) { + parent.bucketMetadataCache.remove(bucketName); + } + } + } + return this; + } + + @Override + public Span setAttribute(String k, String v) { + delegate.setAttribute(k, v); + return this; + } + + @Override + public Span setAttribute(String k, long v) { + delegate.setAttribute(k, v); + return this; + } + + @Override + public Span setAttribute(String k, double v) { + delegate.setAttribute(k, v); + return this; + } + + @Override + public Span setAttribute(String k, boolean v) { + delegate.setAttribute(k, v); + return this; + } + + @Override + public Span setAttribute(AttributeKey k, T v) { + delegate.setAttribute(k, v); + return this; + } + + @Override + public Span addEvent(String n) { + delegate.addEvent(n); + return this; + } + + @Override + public Span addEvent(String n, Attributes a) { + delegate.addEvent(n, a); + return this; + } + + @Override + public Span addEvent(String n, long t, TimeUnit u) { + delegate.addEvent(n, t, u); + return this; + } + + @Override + public Span addEvent(String n, Attributes a, long t, TimeUnit u) { + delegate.addEvent(n, a, t, u); + return this; + } + + @Override + public Span setStatus(StatusCode c) { + delegate.setStatus(c); + return this; + } + + @Override + public Span setStatus(StatusCode c, String d) { + delegate.setStatus(c, d); + return this; + } + + @Override + public Span updateName(String name) { + delegate.updateName(name); + return this; + } + + @Override + public SpanContext getSpanContext() { + return delegate.getSpanContext(); + } + + @Override + public boolean isRecording() { + return delegate.isRecording(); + } +} diff --git a/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/AcoSpanBuilder.java b/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/AcoSpanBuilder.java new file mode 100644 index 000000000000..1270a61368af --- /dev/null +++ b/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/AcoSpanBuilder.java @@ -0,0 +1,224 @@ +/* + * Copyright 2026 Google LLC + * + * 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.google.cloud.storage; + +import com.google.cloud.Tuple; +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.SpanBuilder; +import io.opentelemetry.api.trace.SpanContext; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.context.Context; +import java.util.Locale; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.logging.Level; +import java.util.logging.Logger; + +final class AcoSpanBuilder implements SpanBuilder { + + private static final Logger LOGGER = Logger.getLogger(AcoSpanBuilder.class.getName()); + + private final SpanBuilder delegate; + private final OtelStorageDecorator parent; + private String bucketName; + + AcoSpanBuilder(SpanBuilder delegate, OtelStorageDecorator parent) { + this.delegate = delegate; + this.parent = parent; + } + + @Override + public SpanBuilder setAttribute(String key, String value) { + delegate.setAttribute(key, value); + if ("gsutil.uri".equals(key) && value != null) { + String name = extractBucketName(value); + if (name != null && !name.isEmpty()) { + this.bucketName = name; + } + } + return this; + } + + @Override + public SpanBuilder setAttribute(AttributeKey key, T value) { + delegate.setAttribute(key, value); + if ("gsutil.uri".equals(key.getKey()) && value instanceof String) { + String name = extractBucketName((String) value); + if (name != null && !name.isEmpty()) { + this.bucketName = name; + } + } + return this; + } + + @Override + public Span startSpan() { + if (bucketName != null && parent != null) { + checkCacheAndTriggerFetch( + parent.delegate, parent.bucketMetadataCache, parent.cacheExecutor, bucketName); + return new AcoSpan(delegate.startSpan(), bucketName, parent); + } + return delegate.startSpan(); + } + + @Override + public SpanBuilder setNoParent() { + delegate.setNoParent(); + return this; + } + + @Override + public SpanBuilder setAttribute(String key, boolean value) { + delegate.setAttribute(key, value); + return this; + } + + @Override + public SpanBuilder setAttribute(String key, double value) { + delegate.setAttribute(key, value); + return this; + } + + @Override + public SpanBuilder setAttribute(String key, long value) { + delegate.setAttribute(key, value); + return this; + } + + @Override + public SpanBuilder setSpanKind(SpanKind k) { + delegate.setSpanKind(k); + return this; + } + + @Override + public SpanBuilder setStartTimestamp(long t, TimeUnit u) { + delegate.setStartTimestamp(t, u); + return this; + } + + @Override + public SpanBuilder setParent(Context c) { + delegate.setParent(c); + return this; + } + + @Override + public SpanBuilder addLink(SpanContext c) { + delegate.addLink(c); + return this; + } + + @Override + public SpanBuilder addLink(SpanContext c, Attributes a) { + delegate.addLink(c, a); + return this; + } + + static ExecutorService newCacheExecutor() { + return Executors.newFixedThreadPool( + 4, + r -> { + Thread t = new Thread(r); + t.setDaemon(true); + t.setName("gcs-aco-metadata-cache-pool"); + return t; + }); + } + + static String extractBucketName(String uri) { + if (uri == null || !uri.startsWith("gs://")) { + return null; + } + String remainder = uri.substring(5); + int firstSlash = remainder.indexOf('/'); + if (firstSlash == -1) { + return remainder; + } + return remainder.substring(0, firstSlash); + } + + static Tuple fetch(Storage delegate, String bucketName) { + Bucket bucket = delegate.get(bucketName); + if (bucket == null) { + return null; + } + + String projectId = bucket.getProject() != null ? bucket.getProject().toString() : null; + String resource; + if (projectId != null && !projectId.isEmpty()) { + resource = "projects/" + projectId + "/buckets/" + bucketName; + } else { + resource = "projects/_/buckets/" + bucketName; + } + + String location = + bucket.getLocation() != null ? bucket.getLocation().toLowerCase(Locale.US) : "global"; + String locationType = + bucket.getLocationType() != null + ? bucket.getLocationType().toLowerCase(Locale.US) + : "region"; + + if ("multi-region".equals(locationType) || "dual-region".equals(locationType)) { + location = "global"; + } + + return Tuple.of(resource, location); + } + + static void checkCacheAndTriggerFetch( + Storage delegate, + BucketMetadataCache bucketMetadataCache, + ExecutorService cacheExecutor, + String bucketName) { + if (bucketMetadataCache.containsKey(bucketName)) { + return; + } + + // Put fetchPending placeholder synchronously to block concurrent stampedes + bucketMetadataCache.put(bucketName, "projects/_/buckets/" + bucketName, "global", true); + + cacheExecutor.submit( + () -> { + try { + Tuple layout = fetch(delegate, bucketName); + if (layout != null) { + bucketMetadataCache.put(bucketName, layout, false); + } else { + // Bucket does not exist (fetch returned null) -> Evict cache entry + bucketMetadataCache.remove(bucketName); + } + } catch (StorageException e) { + if (e.getCode() == 404) { + // Bucket not found -> Evict cache entry + bucketMetadataCache.remove(bucketName); + } else if (e.getCode() == 403) { + // Permission Denied -> Retain fallback values with fetchPending=false (Do Not Retry) + bucketMetadataCache.put( + bucketName, "projects/_/buckets/" + bucketName, "global", false); + } else { + LOGGER.log(Level.WARNING, "Background GetBucket failed", e); + } + } catch (Exception e) { + LOGGER.log(Level.WARNING, "Background GetBucket failed", e); + } + }); + } +} diff --git a/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/BucketMetadataCache.java b/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/BucketMetadataCache.java new file mode 100644 index 000000000000..c9d48dcfe374 --- /dev/null +++ b/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/BucketMetadataCache.java @@ -0,0 +1,96 @@ +/* + * Copyright 2026 Google LLC + * + * 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.google.cloud.storage; + +import com.google.cloud.Tuple; +import java.util.LinkedHashMap; +import java.util.Map; + +final class BucketMetadataCache { + + private static final int DEFAULT_CAPACITY = 10000; + private final Object lock = new Object(); + private final Map cache; + + BucketMetadataCache(int capacity) { + this.cache = + new LinkedHashMap(16, 0.75f, true) { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > capacity; + } + }; + } + + static BucketMetadataCache getbucketmetadatacache() { + return new BucketMetadataCache(DEFAULT_CAPACITY); + } + + BucketMetadata get(String bucketName) { + synchronized (lock) { + return cache.get(bucketName); + } + } + + void put(String bucketName, BucketMetadata metadata) { + synchronized (lock) { + cache.put(bucketName, metadata); + } + } + + void put(String bucketName, String resource, String location, boolean pending) { + synchronized (lock) { + cache.put(bucketName, new BucketMetadata(resource, location, pending)); + } + } + + void put(String bucketName, Tuple layout, boolean pending) { + synchronized (lock) { + cache.put(bucketName, new BucketMetadata(layout.x(), layout.y(), pending)); + } + } + + void remove(String bucketName) { + synchronized (lock) { + cache.remove(bucketName); + } + } + + void clear() { + synchronized (lock) { + cache.clear(); + } + } + + boolean containsKey(String bucketName) { + synchronized (lock) { + return cache.containsKey(bucketName); + } + } + + static final class BucketMetadata { + final String resource; + final String location; + final boolean fetchPending; + + BucketMetadata(String resource, String location, boolean fetchPending) { + this.resource = resource; + this.location = location; + this.fetchPending = fetchPending; + } + } +} diff --git a/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/OtelMultipartUploadClientDecorator.java b/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/OtelMultipartUploadClientDecorator.java index f5e7080fed75..a3832b2e9529 100644 --- a/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/OtelMultipartUploadClientDecorator.java +++ b/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/OtelMultipartUploadClientDecorator.java @@ -53,7 +53,7 @@ private OtelMultipartUploadClientDecorator( this.delegate = delegate; this.tracer = OtelStorageDecorator.TracerDecorator.decorate( - null, otel, baseAttributes, MultipartUploadClient.class.getName() + "/"); + null, null, otel, baseAttributes, MultipartUploadClient.class.getName() + "/"); } @Override diff --git a/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/OtelStorageDecorator.java b/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/OtelStorageDecorator.java index 291db00ae5d3..eb0edcb52705 100644 --- a/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/OtelStorageDecorator.java +++ b/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/OtelStorageDecorator.java @@ -58,6 +58,7 @@ import java.nio.file.Path; import java.util.List; import java.util.Locale; +import java.util.concurrent.ExecutorService; import java.util.concurrent.TimeUnit; import java.util.function.UnaryOperator; import org.checkerframework.checker.nullness.qual.NonNull; @@ -75,13 +76,17 @@ final class OtelStorageDecorator implements Storage { private final OpenTelemetry otel; private final Attributes baseAttributes; private final Tracer tracer; + final BucketMetadataCache bucketMetadataCache; + final ExecutorService cacheExecutor; private OtelStorageDecorator(Storage delegate, OpenTelemetry otel, Attributes baseAttributes) { this.delegate = delegate; this.otel = otel; this.baseAttributes = baseAttributes; this.tracer = - TracerDecorator.decorate(null, otel, baseAttributes, Storage.class.getName() + "/"); + TracerDecorator.decorate(this, null, otel, baseAttributes, Storage.class.getName() + "/"); + this.bucketMetadataCache = BucketMetadataCache.getbucketmetadatacache(); + this.cacheExecutor = AcoSpanBuilder.newCacheExecutor(); } @Override @@ -1423,7 +1428,12 @@ public boolean deleteNotification(String bucket, String notificationId) { @Override public void close() throws Exception { - delegate.close(); + try { + bucketMetadataCache.clear(); + cacheExecutor.shutdown(); + } finally { + delegate.close(); + } } @Override @@ -1562,16 +1572,19 @@ static UnaryOperator retryContextDecorator(OpenTelemetry otel) { } static final class TracerDecorator implements Tracer { + @Nullable private final OtelStorageDecorator parentDecorator; @Nullable private final Context parentContextOverride; private final Tracer delegate; private final Attributes baseAttributes; private final String spanNamePrefix; TracerDecorator( + @Nullable OtelStorageDecorator parentDecorator, @Nullable Context parentContextOverride, Tracer delegate, Attributes baseAttributes, String spanNamePrefix) { + this.parentDecorator = parentDecorator; this.parentContextOverride = parentContextOverride; this.delegate = delegate; this.baseAttributes = baseAttributes; @@ -1579,6 +1592,7 @@ static final class TracerDecorator implements Tracer { } static TracerDecorator decorate( + @Nullable OtelStorageDecorator parentDecorator, @Nullable Context parentContextOverride, OpenTelemetry otel, Attributes baseAttributes, @@ -1588,7 +1602,8 @@ static TracerDecorator decorate( requireNonNull(spanNamePrefix, "spanNamePrefix must be non null"); Tracer tracer = otel.getTracer(OTEL_SCOPE_NAME, StorageOptions.getDefaultInstance().getLibraryVersion()); - return new TracerDecorator(parentContextOverride, tracer, baseAttributes, spanNamePrefix); + return new TracerDecorator( + parentDecorator, parentContextOverride, tracer, baseAttributes, spanNamePrefix); } @Override @@ -1598,7 +1613,7 @@ public SpanBuilder spanBuilder(String spanName) { if (parentContextOverride != null) { spanBuilder.setParent(parentContextOverride); } - return spanBuilder; + return new AcoSpanBuilder(spanBuilder, parentDecorator); } } @@ -1671,6 +1686,7 @@ public OtelDecoratedBlobWriteSession(BlobWriteSession delegate, Span sessionSpan this.sessionSpan = sessionSpan; this.tracer = TracerDecorator.decorate( + OtelStorageDecorator.this, Context.current(), otel, OtelStorageDecorator.this.baseAttributes, @@ -1794,6 +1810,7 @@ public OtelDecoratedCopyWriter(CopyWriter copyWriter, Span span) { this.parentContext = Context.current(); this.tracer = TracerDecorator.decorate( + OtelStorageDecorator.this, Context.current(), otel, OtelStorageDecorator.this.baseAttributes, @@ -2127,6 +2144,7 @@ private OtelDecoratingBlobAppendableUpload(BlobAppendableUpload delegate, Span u this.uploadSpan = uploadSpan; this.tracer = TracerDecorator.decorate( + OtelStorageDecorator.this, Context.current(), otel, OtelStorageDecorator.this.baseAttributes, @@ -2163,6 +2181,7 @@ private OtelDecoratingAppendableUploadWriteableByteChannel( this.openSpan = openSpan; this.tracer = TracerDecorator.decorate( + OtelStorageDecorator.this, Context.current(), otel, OtelStorageDecorator.this.baseAttributes, diff --git a/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/ITOpenTelemetryTest.java b/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/ITOpenTelemetryTest.java index 3b8957bbac64..392cb4349df9 100644 --- a/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/ITOpenTelemetryTest.java +++ b/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/ITOpenTelemetryTest.java @@ -63,20 +63,97 @@ public void checkInstrumentation() throws Exception { storage.getOptions().toBuilder().setOpenTelemetry(openTelemetrySdk).build(); try (Storage storage = storageOptions.getService()) { storage.create(BlobInfo.newBuilder(bucket, generator.randomObjectName()).build()); + Thread.sleep(800); + storage.create(BlobInfo.newBuilder(bucket, generator.randomObjectName()).build()); } - SpanData spanData = exporter.getExportedSpans().get(0); + assertThat(exporter.getExportedSpans().size()).isAtLeast(2); + SpanData span1 = exporter.getExportedSpans().get(0); + SpanData span2 = exporter.getExportedSpans().get(1); + assertAll( - () -> assertThat(getAttributeValue(spanData, "gcp.client.service")).isEqualTo("Storage"), + () -> assertThat(getAttributeValue(span1, "gcp.client.service")).isEqualTo("Storage"), + () -> + assertThat(getAttributeValue(span1, "rpc.system")) + .isEqualTo(transport.name().toLowerCase()), + () -> assertThat(getAttributeValue(span2, "gcp.client.service")).isEqualTo("Storage"), () -> - assertThat(getAttributeValue(spanData, "gcp.client.repo")) - .isEqualTo("googleapis/java-storage"), + assertThat(getAttributeValue(span2, "gcp.resource.destination.id")) + .contains("buckets/" + bucket.getName()), + () -> + assertThat(getAttributeValue(span2, "gcp.resource.destination.location")) + .isNotEqualTo("global")); + } + + @Test + public void testAcoNonExistentBucketNoAttributes() throws Exception { + TestExporter exporter = new TestExporter(); + OpenTelemetrySdk openTelemetrySdk = + OpenTelemetrySdk.builder() + .setTracerProvider( + SdkTracerProvider.builder() + .addSpanProcessor(SimpleSpanProcessor.create(exporter)) + .build()) + .build(); + StorageOptions storageOptions = + storage.getOptions().toBuilder().setOpenTelemetry(openTelemetrySdk).build(); + String nonExistentBucket = "non-existent-bucket-" + generator.randomBucketName(); + + try (Storage storage = storageOptions.getService()) { + storage.get(nonExistentBucket); + Thread.sleep(800); + storage.get(nonExistentBucket); + } + + // We should have at least 2 get spans + assertThat(exporter.getExportedSpans().size()).isAtLeast(2); + SpanData getSpan1 = exporter.getExportedSpans().get(0); + SpanData getSpan2 = exporter.getExportedSpans().get(1); + + assertAll( + () -> assertThat(getAttributeValue(getSpan2, "gcp.resource.destination.id")).isNull(), + () -> + assertThat(getAttributeValue(getSpan2, "gcp.resource.destination.location")).isNull()); + } + + @Test + public void testAcoForbiddenBucketFallbackAttributes() throws Exception { + TestExporter exporter = new TestExporter(); + OpenTelemetrySdk openTelemetrySdk = + OpenTelemetrySdk.builder() + .setTracerProvider( + SdkTracerProvider.builder() + .addSpanProcessor(SimpleSpanProcessor.create(exporter)) + .build()) + .build(); + StorageOptions storageOptions = + storage.getOptions().toBuilder().setOpenTelemetry(openTelemetrySdk).build(); + + try (Storage storage = storageOptions.getService()) { + try { + storage.get("test"); + } catch (StorageException e) { + // Expected 403 Forbidden + } + Thread.sleep(800); + try { + storage.get("test"); + } catch (StorageException e) { + // Expected 403 Forbidden + } + } + + assertThat(exporter.getExportedSpans().size()).isAtLeast(2); + SpanData getSpan1 = exporter.getExportedSpans().get(0); + SpanData getSpan2 = exporter.getExportedSpans().get(1); + + assertAll( () -> - assertThat(getAttributeValue(spanData, "gcp.client.artifact")) - .isEqualTo("com.google.cloud:google-cloud-storage"), + assertThat(getAttributeValue(getSpan2, "gcp.resource.destination.id")) + .isEqualTo("projects/_/buckets/test"), () -> - assertThat(getAttributeValue(spanData, "rpc.system")) - .isEqualTo(transport.name().toLowerCase())); + assertThat(getAttributeValue(getSpan2, "gcp.resource.destination.location")) + .isEqualTo("global")); } @Test diff --git a/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/OtelStorageDecoratorAcoUnitTest.java b/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/OtelStorageDecoratorAcoUnitTest.java new file mode 100644 index 000000000000..fb3e6eee402c --- /dev/null +++ b/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/OtelStorageDecoratorAcoUnitTest.java @@ -0,0 +1,220 @@ +/* + * Copyright 2026 Google LLC + * + * 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.google.cloud.storage; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.mockito.Mockito.mock; + +import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.SpanBuilder; +import io.opentelemetry.api.trace.Tracer; +import java.math.BigInteger; +import java.util.concurrent.TimeUnit; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mockito; + +public class OtelStorageDecoratorAcoUnitTest { + + private OpenTelemetry mockOtel; + + @Before + public void setUp() { + mockOtel = mock(OpenTelemetry.class); + Tracer mockTracer = mock(Tracer.class); + SpanBuilder mockSpanBuilder = mock(SpanBuilder.class); + Span mockSpan = mock(Span.class); + + Mockito.when(mockOtel.getTracer(Mockito.anyString(), Mockito.anyString())) + .thenReturn(mockTracer); + Mockito.when(mockTracer.spanBuilder(Mockito.anyString())).thenReturn(mockSpanBuilder); + Mockito.when(mockSpanBuilder.setAllAttributes(Mockito.any())).thenReturn(mockSpanBuilder); + Mockito.when(mockSpanBuilder.startSpan()).thenReturn(mockSpan); + } + + @Test + public void testAcoSuccessFlow() throws Exception { + Storage mockStorage = mock(Storage.class); + Bucket mockBucket = mock(Bucket.class); + + Mockito.when(mockStorage.get("success-bucket")).thenReturn(mockBucket); + Mockito.when(mockBucket.getProject()).thenReturn(BigInteger.valueOf(12345)); + Mockito.when(mockBucket.getLocation()).thenReturn("us-east1"); + Mockito.when(mockBucket.getLocationType()).thenReturn("region"); + + Storage decoratedStorage = + OtelStorageDecorator.decorate(mockStorage, mockOtel, TransportCompatibility.Transport.HTTP); + OtelStorageDecorator osd = (OtelStorageDecorator) decoratedStorage; + + AcoSpanBuilder.checkCacheAndTriggerFetch( + osd.delegate, osd.bucketMetadataCache, osd.cacheExecutor, "success-bucket"); + + // Wait for background task to finish cleanly + osd.cacheExecutor.shutdown(); + osd.cacheExecutor.awaitTermination(5, TimeUnit.SECONDS); + + BucketMetadataCache.BucketMetadata meta = osd.bucketMetadataCache.get("success-bucket"); + assertNotNull(meta); + assertEquals("projects/12345/buckets/success-bucket", meta.resource); + assertEquals("us-east1", meta.location); + assertFalse(meta.fetchPending); + } + + @Test + public void testAco404NotFoundFlowWithException() throws Exception { + Storage mockStorage = mock(Storage.class); + StorageException ex = new StorageException(404, "Bucket not found"); + Mockito.when(mockStorage.get("nonexistent-bucket")).thenThrow(ex); + + Storage decoratedStorage = + OtelStorageDecorator.decorate(mockStorage, mockOtel, TransportCompatibility.Transport.HTTP); + OtelStorageDecorator osd = (OtelStorageDecorator) decoratedStorage; + + AcoSpanBuilder.checkCacheAndTriggerFetch( + osd.delegate, osd.bucketMetadataCache, osd.cacheExecutor, "nonexistent-bucket"); + + // Wait for background task to finish + osd.cacheExecutor.shutdown(); + osd.cacheExecutor.awaitTermination(5, TimeUnit.SECONDS); + + // Verified not found -> Entry must be cleanly evicted (null) + BucketMetadataCache.BucketMetadata meta = osd.bucketMetadataCache.get("nonexistent-bucket"); + assertNull(meta); + } + + @Test + public void testAco404NotFoundFlowWithNull() throws Exception { + Storage mockStorage = mock(Storage.class); + Mockito.when(mockStorage.get("nonexistent-bucket")).thenReturn(null); + + Storage decoratedStorage = + OtelStorageDecorator.decorate(mockStorage, mockOtel, TransportCompatibility.Transport.HTTP); + OtelStorageDecorator osd = (OtelStorageDecorator) decoratedStorage; + + AcoSpanBuilder.checkCacheAndTriggerFetch( + osd.delegate, osd.bucketMetadataCache, osd.cacheExecutor, "nonexistent-bucket"); + + // Wait for background task to finish + osd.cacheExecutor.shutdown(); + osd.cacheExecutor.awaitTermination(5, TimeUnit.SECONDS); + + // Verified not found -> Entry must be cleanly evicted (null) + BucketMetadataCache.BucketMetadata meta = osd.bucketMetadataCache.get("nonexistent-bucket"); + assertNull(meta); + } + + @Test + public void testAco403ForbiddenFlow() throws Exception { + Storage mockStorage = mock(Storage.class); + StorageException ex = new StorageException(403, "Access Denied"); + Mockito.when(mockStorage.get("forbidden-bucket")).thenThrow(ex); + + Storage decoratedStorage = + OtelStorageDecorator.decorate(mockStorage, mockOtel, TransportCompatibility.Transport.HTTP); + OtelStorageDecorator osd = (OtelStorageDecorator) decoratedStorage; + + AcoSpanBuilder.checkCacheAndTriggerFetch( + osd.delegate, osd.bucketMetadataCache, osd.cacheExecutor, "forbidden-bucket"); + + // Wait for background task to finish + osd.cacheExecutor.shutdown(); + osd.cacheExecutor.awaitTermination(5, TimeUnit.SECONDS); + + // Forbidden -> Fallback values retained with pending = false (Do Not Retry) + BucketMetadataCache.BucketMetadata meta = osd.bucketMetadataCache.get("forbidden-bucket"); + assertNotNull(meta); + assertEquals("projects/_/buckets/forbidden-bucket", meta.resource); + assertEquals("global", meta.location); + assertFalse(meta.fetchPending); + } + + @Test + public void testAcoThunderingHerdProtection() throws Exception { + Storage mockStorage = mock(Storage.class); + Mockito.when(mockStorage.get("concurrent-bucket")) + .thenAnswer( + invocation -> { + Thread.sleep(100); + return null; + }); + + Storage decoratedStorage = + OtelStorageDecorator.decorate(mockStorage, mockOtel, TransportCompatibility.Transport.HTTP); + OtelStorageDecorator osd = (OtelStorageDecorator) decoratedStorage; + + // Trigger twice concurrently + AcoSpanBuilder.checkCacheAndTriggerFetch( + osd.delegate, osd.bucketMetadataCache, osd.cacheExecutor, "concurrent-bucket"); + AcoSpanBuilder.checkCacheAndTriggerFetch( + osd.delegate, osd.bucketMetadataCache, osd.cacheExecutor, "concurrent-bucket"); + + // Wait for background tasks + osd.cacheExecutor.shutdown(); + osd.cacheExecutor.awaitTermination(5, TimeUnit.SECONDS); + + // Verify get was called exactly once (no duplicate fetches) + Mockito.verify(mockStorage, Mockito.times(1)).get("concurrent-bucket"); + } + + @Test + public void testAcoAcoSpanEndSkipsPending() throws Exception { + Storage mockStorage = mock(Storage.class); + Storage decoratedStorage = + OtelStorageDecorator.decorate(mockStorage, mockOtel, TransportCompatibility.Transport.HTTP); + OtelStorageDecorator osd = (OtelStorageDecorator) decoratedStorage; + + // Manually put a pending placeholder entry + osd.bucketMetadataCache.put( + "pending-bucket", "projects/_/buckets/pending-bucket", "global", true); + + Span mockSpan = mock(Span.class); + AcoSpan acoSpan = new AcoSpan(mockSpan, "pending-bucket", osd); + + // Call end() while pending + acoSpan.end(); + + // Verify OTel span setAttribute was never called since cache entry was pending + Mockito.verify(mockSpan, Mockito.never()) + .setAttribute(Mockito.anyString(), Mockito.anyString()); + } + + @Test + public void testAcoAcoSpanEndAppliesResolved() throws Exception { + Storage mockStorage = mock(Storage.class); + Storage decoratedStorage = + OtelStorageDecorator.decorate(mockStorage, mockOtel, TransportCompatibility.Transport.HTTP); + OtelStorageDecorator osd = (OtelStorageDecorator) decoratedStorage; + + // Manually put a resolved non-pending entry + osd.bucketMetadataCache.put( + "resolved-bucket", "projects/123/buckets/resolved-bucket", "us-east1", false); + + Span mockSpan = mock(Span.class); + AcoSpan acoSpan = new AcoSpan(mockSpan, "resolved-bucket", osd); + + acoSpan.end(); + + // Verify OTel span attributes were set successfully + Mockito.verify(mockSpan) + .setAttribute("gcp.resource.destination.id", "projects/123/buckets/resolved-bucket"); + Mockito.verify(mockSpan).setAttribute("gcp.resource.destination.location", "us-east1"); + } +}