Repository navigation
feat(inkless:storage): bound and shorten the GCS upload path - #783
Conversation
There was a problem hiding this comment.
🟡 Changes recommended
The client-default fallback isn't operator-configurable, the timeout documentation overstates its bound, and key behavior lacks coverage.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR reduces GCS produce-path latency by configuring shorter transport timeouts and using direct uploads for objects up to 1 MiB.
Changes:
- Adds configurable GCS connect and read timeouts.
- Selects direct or resumable uploads based on object size.
- Adds direct-upload metrics and supporting tests and documentation.
File summaries
| File | Description |
|---|---|
GcsStorage.java |
Configures timeouts and selects the upload method. |
GcsStorageConfig.java |
Defines timeout configuration. |
MetricCollector.java |
Records direct-upload requests. |
MetricRegistry.java |
Registers direct-upload metrics. |
GcsStorageTest.java |
Updates oversized-stream coverage. |
GcsStorageConfigTest.java |
Tests timeout parsing and defaults. |
docs/inkless/configs.rst |
Documents timeout settings. |
docs/inkless/metrics.rst |
Documents upload metrics. |
Review details
- Files reviewed: 8/8 changed files
- Comments generated: 4
- Review effort level: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
eb42b25 to
bb339fc
Compare
There was a problem hiding this comment.
🔵 Needs a closer look
The advertised nullable timeout opt-out is rejected by the implementation.
Review details
Suppressed comments (1)
storage/inkless/src/main/java/io/aiven/inkless/storage_backend/gcs/GcsStorageConfig.java:90
- The PR description says both timeout configs accept
nullto restore the client default, butRange.betweenrejects an explicitly configurednullbeforegetDurationruns. This makes the documented opt-out unusable. Either add nullable validation and skip the corresponding transport setter when the value isnull, or remove thenullclaim from the PR contract.
.define(
GCS_CONNECT_TIMEOUT_CONFIG,
ConfigDef.Type.LONG,
GCS_CONNECT_TIMEOUT_DEFAULT,
ConfigDef.Range.between(1, Integer.MAX_VALUE),
- Files reviewed: 10/10 changed files
- Comments generated: 0 new
- Review effort level: Balanced
There was a problem hiding this comment.
🟡 Changes recommended
Streaming payload read timeouts bypass the new StorageBackendTimeoutException classification.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 10/10 changed files
- Comments generated: 1
- Review effort level: Balanced
bb339fc to
3b778fc
Compare
There was a problem hiding this comment.
🟢 Approval recommended
The implementation is coherent with the storage contracts and includes focused integration and configuration coverage.
Review details
- Files reviewed: 10/10 changed files
- Comments generated: 0 new
- Review effort level: Balanced
GCS requests were bounded only by the google-cloud client defaults (20s connect, 20s read), and neither was configurable. GCS can stall for several seconds on response time-to-first-byte even on an established connection, with no retry and no TLS handshake involved, and with produce.max.upload.attempts=3 a single stalled upload could hold an uploader thread for a minute. That shows up as FileUploadTime maxima and cascades into produce latency through FileCommitWaitTime. Add gcs.connect.timeout and gcs.read.timeout, mirroring the existing s3.api.call.timeout and s3.api.call.attempt.timeout knobs, and default them to 2s and 1s. Connect keeps the wider bound on purpose: it only applies on a connection-pool miss, so a generous ceiling costs nothing in steady state and leaves headroom for the connection churn that accompanies a slowdown, while the read bound applies to every stalled request and is the one worth keeping tight. The read value bounds each socket read of a response rather than a whole attempt, so connection setup falls under gcs.connect.timeout, request-body writes are not covered, and a response arriving in several reads can exceed it. An upload response is one small object, so in practice the attempt ends on the first stalled read. Whether a timed out read is retried depends on the operation. An object insert carries no IF_GENERATION_MATCH precondition, so the client classifies it non-idempotent and its non-idempotent handler retries nothing (HttpRetryAlgorithmManager.getForObjectsCreate, DefaultStorageRetryStrategy, and BaseServiceException.isRetryable, which gates every retryable IOException on idempotency). An upload's total is therefore produce.max.upload.attempts stalled reads: about 3s, down from about 60s. A read is idempotent and the client does retry it, under its default settings of 6 attempts and a 50s total timeout, so a stalled fetch is bounded by that budget rather than by this value alone. Batch deletes need the read bound applied differently. The client splits a delete into HTTP batches of at most 100 sub-requests and the server answers only once it has processed all of them, so the response wait grows with the batch and a batch is not retried by the client either. Deleting in chunks of half that maximum keeps the wait clear of the timeout, and returning the keys confirmed so far means a slow batch costs one chunk rather than a whole cleaner cycle. FileCleaner already treats deletion as partial, so this is the contract it expects and matches what the S3 backend does. Timeouts surface as StorageBackendTimeoutException from upload, single-key delete, and a fetch's metadata request. A fetch's body is streamed from the channel it returns and read after the method exits, so a stall mid-download reaches the caller as a plain IOException instead; ReadableByteChannel.read cannot throw StorageBackendException, which does not extend IOException. S3Storage covers its whole fetch only because it materializes the response into a buffer. The batch delete stops the pass rather than classifying, as S3 does. FileUploadJob's existing timeout handling therefore applies to GCS as it already does to S3. Exhausting the attempts fails the file, and every produce request in it gets KAFKA_STORAGE_ERROR, which is retriable at the client. Testing ------- GcsStorageConfigTest covers the new defaults, that both values parse into Durations, and that a non-positive value is rejected. GcsErrorHandlingTest drives a delayed response through WireMock and asserts an upload surfaces StorageBackendTimeoutException with SocketTimeoutException as its root cause, which pins the cause-chain walk the classification depends on. GcsStorageTest covers a delete spanning several batches, which the shared suite's ten-key case does not reach.
storage.createFrom always opens a resumable session: a POST to create it, then a PUT to upload and finalize. Both round trips are paid on every file and either one can stall. Measured in isolation on ~2 KiB objects, that fixed cost is ~100ms per upload on GCS against ~25ms for the equivalent single PUT on S3, and it does not vary with payload size. Use storage.create for objects that fit in one array. It sends a single request, and it has the client supply MD5 and CRC32C so the server verifies content rather than only the length checked here. The hashing costs a few milliseconds per megabyte, well below the round trip it replaces. The threshold is 1 MiB because the trade inverts with size. A single request saves a fixed round trip but cannot stream, so its cost per megabyte is higher, and the measured break-even is near 2 MiB. Raising the threshold without re-measuring makes large uploads slower, not faster. Objects above it keep the resumable path, which also keeps an arbitrarily large one from being buffered whole. The single-request API takes a byte array, so the direct path copies the payload once more than streaming it would, bounded by the threshold. createFrom read the stream to EOF, so the existing size check caught a stream that did not match its declared length in either direction. Reading a fixed count drops that, so the upload now probes for a trailing byte the way S3Storage already does, and a stream longer than its length is reported instead of being silently truncated. Add an object-upload counter: resumable-upload-initiate and resumable-chunk-upload stop advancing for direct uploads, so without it per-request upload activity would no longer be visible. The upload-path interceptor also stops assuming the uploadType parameter is present, which it is not for a direct upload. Testing ------- GcsStorageTest overrides testUploadOversizeStream, since the surplus is now reported by the probe rather than by the size check, the way S3StorageTest already does.
3b778fc to
8b4ab80
Compare
giuseppelillo
left a comment
There was a problem hiding this comment.
Raise some issues related to the already existing behavior, but I think it's still worth to fix those right now.
| // failure surfaces as a thrown BaseServiceException rather than a per-blob flag, so a | ||
| // batch stays all-or-nothing: on success every key in it is gone (idempotent), and on | ||
| // failure none of it is confirmed. | ||
| storage.delete(ids); |
There was a problem hiding this comment.
Storage.delete(Iterable) does not throw for individual failed subrequests, but ti returns false when deletion fails or access is denied. Because the result is ignored and the entire batch is added to deleted at line 200, FileCleaner can dereference objects that were never deleted, leaving permanent orphans.
There was a problem hiding this comment.
Good catch. I'm fixing this by using StorageBatch API to handle the response properly.
| } else { | ||
| blob = storage.createFrom(blobInfo, inputStream); | ||
| } |
There was a problem hiding this comment.
This resumable branch still has internal SDK retries. After session initiation, GCS client retries chunk and finalization requests as idempotent operations under its retry budget. Therefore uploads above 1 MiB are not bounded to one gcs.read.timeout per FileUploadJob attempt. Either enforce an overall retry budget for resumable uploads or narrow the documented latency guarantee, and test a stalled final PUT after successful session initiation.
There was a problem hiding this comment.
Right. Let's fix the docs for now. I'm amending the commit as well.
Motivated by this, but not actually resolving the issue here, is the handling of error metrics as it's done in S3 I'm adding on the last commit. It feels enough the scope of this PR to add it here.
| } else if (isTimeout(e)) { | ||
| // Reaches only the metadata request. The body is streamed from the channel this | ||
| // method returns, so a stall mid-download surfaces to the caller as a plain | ||
| // IOException; ReadableByteChannel.read cannot throw StorageBackendException. | ||
| throw new StorageBackendTimeoutException("Timed out to fetch " + key, e); |
There was a problem hiding this comment.
A persistent body-read timeout may not surface as an IOException. ApiaryUnbufferedReadableByteChannel.read handles a retryable failure by reopening the stream in a loop, with each reopen starting a fresh retry budget. Repeated zero-byte stalls can therefore keep a fetch thread occupied indefinitely. Add an overall fetch deadline or cancellation boundary and cover repeated body stalls.
There was a problem hiding this comment.
Good catch -- I think this needs to be solved at the caller so it's solved for all backends. Adding it as a follow-up 2 on the PR body.
feat(inkless:storage): bound each GCS request attempt by default GCS requests were bounded only by the google-cloud client defaults (20s connect, 20s read), and neither was configurable. GCS can stall for several seconds on response time-to-first-byte even on an established connection, with no retry and no TLS handshake involved, and a single stalled upload could hold an uploader thread for minutes. That shows up as FileUploadTime maxima and cascades into produce latency through FileCommitWaitTime. Add gcs.connect.timeout and gcs.read.timeout, mirroring the existing s3.api.call.timeout and s3.api.call.attempt.timeout knobs, and default them to 2s and 1s. Connect keeps the wider bound on purpose: it only applies on a connection-pool miss, so a generous ceiling costs nothing in steady state and leaves headroom for the connection churn that accompanies a slowdown, while the read bound applies to every stalled request and is the one worth keeping tight. The read value bounds each socket read of a response rather than a whole attempt, so connection setup falls under gcs.connect.timeout, request-body writes are not covered, and a response arriving in several reads can exceed it. Whether a timed out read ends the operation depends on how the client classifies the request, and for an upload the answer is not the one the shape of the code suggests. Creating a resumable session carries no IF_GENERATION_MATCH precondition, so the client classifies it non-idempotent and its non-idempotent handler retries nothing (HttpRetryAlgorithmManager.getForResumableUploadSessionCreate, DefaultStorageRetryStrategy, and BaseServiceException.isRetryable, which gates every retryable IOException on idempotency). Writing to a session that is already active is classified idempotent whether or not the session started that way (getForResumableUploadSessionWrite), so the PUT is retried under the client's default settings of 6 attempts and a 50s total timeout. Since createFrom buffers 15 MiB by default, a file below that size is one such PUT, and its ceiling is that budget rather than produce.max.upload.attempts stalled reads. Lowering this value changes how many attempts fit inside the 50s, not how long the upload can take. A read is idempotent for the same reason and carries the same budget. What the bound buys on every path is that a stalled request fails its attempt in a bounded, operator-set time instead of holding a connection for 20s. Batch deletes need the read bound applied differently. The client splits a delete into HTTP batches of at most 100 sub-requests and the server answers only once it has processed all of them, so the response wait grows with the batch and a batch is not retried by the client either. Deleting in chunks of half that maximum keeps the wait clear of the timeout, and returning the keys confirmed so far means a slow batch costs one chunk rather than a whole cleaner cycle. FileCleaner already treats deletion as partial, so this is the contract it expects and matches what the S3 backend does. Chunking alone would still confirm the wrong keys. Storage.delete(Iterable) reports a failed sub-request as the same false its callback uses for an absent blob (StorageImpl.delete, whose per-blob error callback records Boolean.FALSE without rethrowing), so the return value cannot distinguish them and ignoring it confirmed every key in the batch. A 403 or 5xx on one key then dereferenced a live file in the control plane and left an orphan nothing would collect. Driving Storage.batch() directly keeps the three outcomes apart, because StorageBatch maps a 404 to false and anything else to a thrown StorageException: deleted and already-absent are confirmed, a failure is not and is counted by response code, at INFO for throttling and WARN otherwise as in the S3 backend. object-delete now counts confirmed deletions rather than attempted ones. Timeouts surface as StorageBackendTimeoutException from upload, single-key delete, and a fetch's metadata request. A fetch's body is streamed from the channel it returns and read after the method exits, so a stall mid-download reaches the caller as a plain IOException instead; ReadableByteChannel.read cannot throw StorageBackendException, which does not extend IOException. S3Storage covers its whole fetch only because it materializes the response into a buffer. The batch delete stops the pass rather than classifying, as S3 does. FileUploadJob's existing timeout handling therefore applies to GCS as it already does to S3. Exhausting the attempts fails the file, and every produce request in it gets KAFKA_STORAGE_ERROR, which is retriable at the client. Testing ------- GcsStorageConfigTest covers the new defaults, that both values parse into Durations, and that a non-positive value is rejected. GcsStorageTest covers a delete spanning several batches, which the shared suite's ten-key case does not reach. GcsErrorHandlingTest uses WireMock for the failures fake-gcs-server cannot produce. A delayed response pins the timeout classification: an upload surfaces StorageBackendTimeoutException with SocketTimeoutException as its root cause, which exercises the cause-chain walk every classification site depends on. A hand-built multipart batch response pins the delete confirmation on both sides: with one deleted sub-response and two refused, only one key comes back confirmed, and with every sub-response a 404, both keys do.
The GCS backend reported how many requests it made and nothing about how many failed. The S3 backend counts failed attempts by type, and those counters are what let an operator tell a throttled or faulting provider from a slow one. Without them on GCS, a bucket that starts refusing requests shows up only as latency and as FileCleaner drain arithmetic. Add throttling-errors, server-errors, and other-errors to gcs-client-metrics under the S3 backend's names, so dashboards built for one backend apply to the other. Each counts per request attempt, so a failure the client retries is counted once per attempt, as the AWS SDK does for S3. The response interceptor the request counters already use is the hook: it runs once per attempt for every request the client builds with its initializer, including the resumable upload's PUT and the object download's GET, and it sees non-2xx responses as well as successes. 429 and 503 count as throttling, as SlowDown does for S3, since GCS returns 503 under load and asks the client to back off; other 5xx are server errors; other non-2xx, such as the 404 for a missing key, are other errors. The batch request is built without the request initializer (AbstractGoogleClient.batch passes null), so the interceptor never sees it. GcsStorage.delete(Set) reports its failure directly; a batch is one request and one attempt, so the count is exact there too. The comment on recordBatchDeleteObjects said the interceptor saw the batch request but could not identify its operations, which was the wrong half of the explanation. Requests that fail without a response, a configured timeout or a connection reset, are not counted. The AWS SDK classifies and publishes those itself; the GCS client offers no once-per-attempt hook that carries the exception. The response interceptor never runs on an IOException, HttpIOExceptionHandler receives the request but not the exception, the transport cannot be wrapped because NetHttpTransport is final, and the retry strategy is consulted up to three times per attempt by gax, so counting there needs a dedupe that does not belong in a metrics collector. configured-timeout-errors and io-errors are left for a change that settles where that hook should live. Testing ------- GcsErrorHandlingTest asserts each sensor end to end through WireMock, which pins the interceptor against the client's real call pattern: a 429 on upload counts one throttling error, a 500 one server error, a 404 on fetch one other error, and a 503 on the batch endpoint one throttling error through the caller-side path. Every assertion is an exact 1.0, so a client upgrade that starts retrying at the HTTP layer fails the suite instead of skewing the counters.
* feat(inkless:storage): bound each GCS request attempt by default GCS requests were bounded only by the google-cloud client defaults (20s connect, 20s read), and neither was configurable. GCS can stall for several seconds on response time-to-first-byte even on an established connection, with no retry and no TLS handshake involved, and with produce.max.upload.attempts=3 a single stalled upload could hold an uploader thread for a minute. That shows up as FileUploadTime maxima and cascades into produce latency through FileCommitWaitTime. Add gcs.connect.timeout and gcs.read.timeout, mirroring the existing s3.api.call.timeout and s3.api.call.attempt.timeout knobs, and default them to 2s and 1s. Connect keeps the wider bound on purpose: it only applies on a connection-pool miss, so a generous ceiling costs nothing in steady state and leaves headroom for the connection churn that accompanies a slowdown, while the read bound applies to every stalled request and is the one worth keeping tight. The read value bounds each socket read of a response rather than a whole attempt, so connection setup falls under gcs.connect.timeout, request-body writes are not covered, and a response arriving in several reads can exceed it. An upload response is one small object, so in practice the attempt ends on the first stalled read. Whether a timed out read is retried depends on the operation. An object insert carries no IF_GENERATION_MATCH precondition, so the client classifies it non-idempotent and its non-idempotent handler retries nothing (HttpRetryAlgorithmManager.getForObjectsCreate, DefaultStorageRetryStrategy, and BaseServiceException.isRetryable, which gates every retryable IOException on idempotency). An upload's total is therefore produce.max.upload.attempts stalled reads: about 3s, down from about 60s. A read is idempotent and the client does retry it, under its default settings of 6 attempts and a 50s total timeout, so a stalled fetch is bounded by that budget rather than by this value alone. Batch deletes need the read bound applied differently. The client splits a delete into HTTP batches of at most 100 sub-requests and the server answers only once it has processed all of them, so the response wait grows with the batch and a batch is not retried by the client either. Deleting in chunks of half that maximum keeps the wait clear of the timeout, and returning the keys confirmed so far means a slow batch costs one chunk rather than a whole cleaner cycle. FileCleaner already treats deletion as partial, so this is the contract it expects and matches what the S3 backend does. Timeouts surface as StorageBackendTimeoutException from upload, single-key delete, and a fetch's metadata request. A fetch's body is streamed from the channel it returns and read after the method exits, so a stall mid-download reaches the caller as a plain IOException instead; ReadableByteChannel.read cannot throw StorageBackendException, which does not extend IOException. S3Storage covers its whole fetch only because it materializes the response into a buffer. The batch delete stops the pass rather than classifying, as S3 does. FileUploadJob's existing timeout handling therefore applies to GCS as it already does to S3. Exhausting the attempts fails the file, and every produce request in it gets KAFKA_STORAGE_ERROR, which is retriable at the client. Testing ------- GcsStorageConfigTest covers the new defaults, that both values parse into Durations, and that a non-positive value is rejected. GcsErrorHandlingTest drives a delayed response through WireMock and asserts an upload surfaces StorageBackendTimeoutException with SocketTimeoutException as its root cause, which pins the cause-chain walk the classification depends on. GcsStorageTest covers a delete spanning several batches, which the shared suite's ten-key case does not reach. * feat(inkless:storage): upload GCS objects in a single request storage.createFrom always opens a resumable session: a POST to create it, then a PUT to upload and finalize. Both round trips are paid on every file and either one can stall. Measured in isolation on ~2 KiB objects, that fixed cost is ~100ms per upload on GCS against ~25ms for the equivalent single PUT on S3, and it does not vary with payload size. Use storage.create for objects that fit in one array. It sends a single request, and it has the client supply MD5 and CRC32C so the server verifies content rather than only the length checked here. The hashing costs a few milliseconds per megabyte, well below the round trip it replaces. The threshold is 1 MiB because the trade inverts with size. A single request saves a fixed round trip but cannot stream, so its cost per megabyte is higher, and the measured break-even is near 2 MiB. Raising the threshold without re-measuring makes large uploads slower, not faster. Objects above it keep the resumable path, which also keeps an arbitrarily large one from being buffered whole. The single-request API takes a byte array, so the direct path copies the payload once more than streaming it would, bounded by the threshold. createFrom read the stream to EOF, so the existing size check caught a stream that did not match its declared length in either direction. Reading a fixed count drops that, so the upload now probes for a trailing byte the way S3Storage already does, and a stream longer than its length is reported instead of being silently truncated. Add an object-upload counter: resumable-upload-initiate and resumable-chunk-upload stop advancing for direct uploads, so without it per-request upload activity would no longer be visible. The upload-path interceptor also stops assuming the uploadType parameter is present, which it is not for a direct upload. Testing ------- GcsStorageTest overrides testUploadOversizeStream, since the surplus is now reported by the probe rather than by the size check, the way S3StorageTest already does. * amend! feat(inkless:storage): bound each GCS request attempt by default feat(inkless:storage): bound each GCS request attempt by default GCS requests were bounded only by the google-cloud client defaults (20s connect, 20s read), and neither was configurable. GCS can stall for several seconds on response time-to-first-byte even on an established connection, with no retry and no TLS handshake involved, and a single stalled upload could hold an uploader thread for minutes. That shows up as FileUploadTime maxima and cascades into produce latency through FileCommitWaitTime. Add gcs.connect.timeout and gcs.read.timeout, mirroring the existing s3.api.call.timeout and s3.api.call.attempt.timeout knobs, and default them to 2s and 1s. Connect keeps the wider bound on purpose: it only applies on a connection-pool miss, so a generous ceiling costs nothing in steady state and leaves headroom for the connection churn that accompanies a slowdown, while the read bound applies to every stalled request and is the one worth keeping tight. The read value bounds each socket read of a response rather than a whole attempt, so connection setup falls under gcs.connect.timeout, request-body writes are not covered, and a response arriving in several reads can exceed it. Whether a timed out read ends the operation depends on how the client classifies the request, and for an upload the answer is not the one the shape of the code suggests. Creating a resumable session carries no IF_GENERATION_MATCH precondition, so the client classifies it non-idempotent and its non-idempotent handler retries nothing (HttpRetryAlgorithmManager.getForResumableUploadSessionCreate, DefaultStorageRetryStrategy, and BaseServiceException.isRetryable, which gates every retryable IOException on idempotency). Writing to a session that is already active is classified idempotent whether or not the session started that way (getForResumableUploadSessionWrite), so the PUT is retried under the client's default settings of 6 attempts and a 50s total timeout. Since createFrom buffers 15 MiB by default, a file below that size is one such PUT, and its ceiling is that budget rather than produce.max.upload.attempts stalled reads. Lowering this value changes how many attempts fit inside the 50s, not how long the upload can take. A read is idempotent for the same reason and carries the same budget. What the bound buys on every path is that a stalled request fails its attempt in a bounded, operator-set time instead of holding a connection for 20s. Batch deletes need the read bound applied differently. The client splits a delete into HTTP batches of at most 100 sub-requests and the server answers only once it has processed all of them, so the response wait grows with the batch and a batch is not retried by the client either. Deleting in chunks of half that maximum keeps the wait clear of the timeout, and returning the keys confirmed so far means a slow batch costs one chunk rather than a whole cleaner cycle. FileCleaner already treats deletion as partial, so this is the contract it expects and matches what the S3 backend does. Chunking alone would still confirm the wrong keys. Storage.delete(Iterable) reports a failed sub-request as the same false its callback uses for an absent blob (StorageImpl.delete, whose per-blob error callback records Boolean.FALSE without rethrowing), so the return value cannot distinguish them and ignoring it confirmed every key in the batch. A 403 or 5xx on one key then dereferenced a live file in the control plane and left an orphan nothing would collect. Driving Storage.batch() directly keeps the three outcomes apart, because StorageBatch maps a 404 to false and anything else to a thrown StorageException: deleted and already-absent are confirmed, a failure is not and is counted by response code, at INFO for throttling and WARN otherwise as in the S3 backend. object-delete now counts confirmed deletions rather than attempted ones. Timeouts surface as StorageBackendTimeoutException from upload, single-key delete, and a fetch's metadata request. A fetch's body is streamed from the channel it returns and read after the method exits, so a stall mid-download reaches the caller as a plain IOException instead; ReadableByteChannel.read cannot throw StorageBackendException, which does not extend IOException. S3Storage covers its whole fetch only because it materializes the response into a buffer. The batch delete stops the pass rather than classifying, as S3 does. FileUploadJob's existing timeout handling therefore applies to GCS as it already does to S3. Exhausting the attempts fails the file, and every produce request in it gets KAFKA_STORAGE_ERROR, which is retriable at the client. Testing ------- GcsStorageConfigTest covers the new defaults, that both values parse into Durations, and that a non-positive value is rejected. GcsStorageTest covers a delete spanning several batches, which the shared suite's ten-key case does not reach. GcsErrorHandlingTest uses WireMock for the failures fake-gcs-server cannot produce. A delayed response pins the timeout classification: an upload surfaces StorageBackendTimeoutException with SocketTimeoutException as its root cause, which exercises the cause-chain walk every classification site depends on. A hand-built multipart batch response pins the delete confirmation on both sides: with one deleted sub-response and two refused, only one key comes back confirmed, and with every sub-response a 404, both keys do. * feat(inkless:storage): count GCS error responses by type The GCS backend reported how many requests it made and nothing about how many failed. The S3 backend counts failed attempts by type, and those counters are what let an operator tell a throttled or faulting provider from a slow one. Without them on GCS, a bucket that starts refusing requests shows up only as latency and as FileCleaner drain arithmetic. Add throttling-errors, server-errors, and other-errors to gcs-client-metrics under the S3 backend's names, so dashboards built for one backend apply to the other. Each counts per request attempt, so a failure the client retries is counted once per attempt, as the AWS SDK does for S3. The response interceptor the request counters already use is the hook: it runs once per attempt for every request the client builds with its initializer, including the resumable upload's PUT and the object download's GET, and it sees non-2xx responses as well as successes. 429 and 503 count as throttling, as SlowDown does for S3, since GCS returns 503 under load and asks the client to back off; other 5xx are server errors; other non-2xx, such as the 404 for a missing key, are other errors. The batch request is built without the request initializer (AbstractGoogleClient.batch passes null), so the interceptor never sees it. GcsStorage.delete(Set) reports its failure directly; a batch is one request and one attempt, so the count is exact there too. The comment on recordBatchDeleteObjects said the interceptor saw the batch request but could not identify its operations, which was the wrong half of the explanation. Requests that fail without a response, a configured timeout or a connection reset, are not counted. The AWS SDK classifies and publishes those itself; the GCS client offers no once-per-attempt hook that carries the exception. The response interceptor never runs on an IOException, HttpIOExceptionHandler receives the request but not the exception, the transport cannot be wrapped because NetHttpTransport is final, and the retry strategy is consulted up to three times per attempt by gax, so counting there needs a dedupe that does not belong in a metrics collector. configured-timeout-errors and io-errors are left for a change that settles where that hook should live. Testing ------- GcsErrorHandlingTest asserts each sensor end to end through WireMock, which pins the interceptor against the client's real call pattern: a 429 on upload counts one throttling error, a 500 one server error, a 404 on fetch one other error, and a 503 on the batch endpoint one throttling error through the caller-side path. Every assertion is an exact 1.0, so a client upgrade that starts retrying at the HTTP layer fails the suite instead of skewing the counters.
* feat(inkless:storage): bound each GCS request attempt by default GCS requests were bounded only by the google-cloud client defaults (20s connect, 20s read), and neither was configurable. GCS can stall for several seconds on response time-to-first-byte even on an established connection, with no retry and no TLS handshake involved, and with produce.max.upload.attempts=3 a single stalled upload could hold an uploader thread for a minute. That shows up as FileUploadTime maxima and cascades into produce latency through FileCommitWaitTime. Add gcs.connect.timeout and gcs.read.timeout, mirroring the existing s3.api.call.timeout and s3.api.call.attempt.timeout knobs, and default them to 2s and 1s. Connect keeps the wider bound on purpose: it only applies on a connection-pool miss, so a generous ceiling costs nothing in steady state and leaves headroom for the connection churn that accompanies a slowdown, while the read bound applies to every stalled request and is the one worth keeping tight. The read value bounds each socket read of a response rather than a whole attempt, so connection setup falls under gcs.connect.timeout, request-body writes are not covered, and a response arriving in several reads can exceed it. An upload response is one small object, so in practice the attempt ends on the first stalled read. Whether a timed out read is retried depends on the operation. An object insert carries no IF_GENERATION_MATCH precondition, so the client classifies it non-idempotent and its non-idempotent handler retries nothing (HttpRetryAlgorithmManager.getForObjectsCreate, DefaultStorageRetryStrategy, and BaseServiceException.isRetryable, which gates every retryable IOException on idempotency). An upload's total is therefore produce.max.upload.attempts stalled reads: about 3s, down from about 60s. A read is idempotent and the client does retry it, under its default settings of 6 attempts and a 50s total timeout, so a stalled fetch is bounded by that budget rather than by this value alone. Batch deletes need the read bound applied differently. The client splits a delete into HTTP batches of at most 100 sub-requests and the server answers only once it has processed all of them, so the response wait grows with the batch and a batch is not retried by the client either. Deleting in chunks of half that maximum keeps the wait clear of the timeout, and returning the keys confirmed so far means a slow batch costs one chunk rather than a whole cleaner cycle. FileCleaner already treats deletion as partial, so this is the contract it expects and matches what the S3 backend does. Timeouts surface as StorageBackendTimeoutException from upload, single-key delete, and a fetch's metadata request. A fetch's body is streamed from the channel it returns and read after the method exits, so a stall mid-download reaches the caller as a plain IOException instead; ReadableByteChannel.read cannot throw StorageBackendException, which does not extend IOException. S3Storage covers its whole fetch only because it materializes the response into a buffer. The batch delete stops the pass rather than classifying, as S3 does. FileUploadJob's existing timeout handling therefore applies to GCS as it already does to S3. Exhausting the attempts fails the file, and every produce request in it gets KAFKA_STORAGE_ERROR, which is retriable at the client. Testing ------- GcsStorageConfigTest covers the new defaults, that both values parse into Durations, and that a non-positive value is rejected. GcsErrorHandlingTest drives a delayed response through WireMock and asserts an upload surfaces StorageBackendTimeoutException with SocketTimeoutException as its root cause, which pins the cause-chain walk the classification depends on. GcsStorageTest covers a delete spanning several batches, which the shared suite's ten-key case does not reach. * feat(inkless:storage): upload GCS objects in a single request storage.createFrom always opens a resumable session: a POST to create it, then a PUT to upload and finalize. Both round trips are paid on every file and either one can stall. Measured in isolation on ~2 KiB objects, that fixed cost is ~100ms per upload on GCS against ~25ms for the equivalent single PUT on S3, and it does not vary with payload size. Use storage.create for objects that fit in one array. It sends a single request, and it has the client supply MD5 and CRC32C so the server verifies content rather than only the length checked here. The hashing costs a few milliseconds per megabyte, well below the round trip it replaces. The threshold is 1 MiB because the trade inverts with size. A single request saves a fixed round trip but cannot stream, so its cost per megabyte is higher, and the measured break-even is near 2 MiB. Raising the threshold without re-measuring makes large uploads slower, not faster. Objects above it keep the resumable path, which also keeps an arbitrarily large one from being buffered whole. The single-request API takes a byte array, so the direct path copies the payload once more than streaming it would, bounded by the threshold. createFrom read the stream to EOF, so the existing size check caught a stream that did not match its declared length in either direction. Reading a fixed count drops that, so the upload now probes for a trailing byte the way S3Storage already does, and a stream longer than its length is reported instead of being silently truncated. Add an object-upload counter: resumable-upload-initiate and resumable-chunk-upload stop advancing for direct uploads, so without it per-request upload activity would no longer be visible. The upload-path interceptor also stops assuming the uploadType parameter is present, which it is not for a direct upload. Testing ------- GcsStorageTest overrides testUploadOversizeStream, since the surplus is now reported by the probe rather than by the size check, the way S3StorageTest already does. * amend! feat(inkless:storage): bound each GCS request attempt by default feat(inkless:storage): bound each GCS request attempt by default GCS requests were bounded only by the google-cloud client defaults (20s connect, 20s read), and neither was configurable. GCS can stall for several seconds on response time-to-first-byte even on an established connection, with no retry and no TLS handshake involved, and a single stalled upload could hold an uploader thread for minutes. That shows up as FileUploadTime maxima and cascades into produce latency through FileCommitWaitTime. Add gcs.connect.timeout and gcs.read.timeout, mirroring the existing s3.api.call.timeout and s3.api.call.attempt.timeout knobs, and default them to 2s and 1s. Connect keeps the wider bound on purpose: it only applies on a connection-pool miss, so a generous ceiling costs nothing in steady state and leaves headroom for the connection churn that accompanies a slowdown, while the read bound applies to every stalled request and is the one worth keeping tight. The read value bounds each socket read of a response rather than a whole attempt, so connection setup falls under gcs.connect.timeout, request-body writes are not covered, and a response arriving in several reads can exceed it. Whether a timed out read ends the operation depends on how the client classifies the request, and for an upload the answer is not the one the shape of the code suggests. Creating a resumable session carries no IF_GENERATION_MATCH precondition, so the client classifies it non-idempotent and its non-idempotent handler retries nothing (HttpRetryAlgorithmManager.getForResumableUploadSessionCreate, DefaultStorageRetryStrategy, and BaseServiceException.isRetryable, which gates every retryable IOException on idempotency). Writing to a session that is already active is classified idempotent whether or not the session started that way (getForResumableUploadSessionWrite), so the PUT is retried under the client's default settings of 6 attempts and a 50s total timeout. Since createFrom buffers 15 MiB by default, a file below that size is one such PUT, and its ceiling is that budget rather than produce.max.upload.attempts stalled reads. Lowering this value changes how many attempts fit inside the 50s, not how long the upload can take. A read is idempotent for the same reason and carries the same budget. What the bound buys on every path is that a stalled request fails its attempt in a bounded, operator-set time instead of holding a connection for 20s. Batch deletes need the read bound applied differently. The client splits a delete into HTTP batches of at most 100 sub-requests and the server answers only once it has processed all of them, so the response wait grows with the batch and a batch is not retried by the client either. Deleting in chunks of half that maximum keeps the wait clear of the timeout, and returning the keys confirmed so far means a slow batch costs one chunk rather than a whole cleaner cycle. FileCleaner already treats deletion as partial, so this is the contract it expects and matches what the S3 backend does. Chunking alone would still confirm the wrong keys. Storage.delete(Iterable) reports a failed sub-request as the same false its callback uses for an absent blob (StorageImpl.delete, whose per-blob error callback records Boolean.FALSE without rethrowing), so the return value cannot distinguish them and ignoring it confirmed every key in the batch. A 403 or 5xx on one key then dereferenced a live file in the control plane and left an orphan nothing would collect. Driving Storage.batch() directly keeps the three outcomes apart, because StorageBatch maps a 404 to false and anything else to a thrown StorageException: deleted and already-absent are confirmed, a failure is not and is counted by response code, at INFO for throttling and WARN otherwise as in the S3 backend. object-delete now counts confirmed deletions rather than attempted ones. Timeouts surface as StorageBackendTimeoutException from upload, single-key delete, and a fetch's metadata request. A fetch's body is streamed from the channel it returns and read after the method exits, so a stall mid-download reaches the caller as a plain IOException instead; ReadableByteChannel.read cannot throw StorageBackendException, which does not extend IOException. S3Storage covers its whole fetch only because it materializes the response into a buffer. The batch delete stops the pass rather than classifying, as S3 does. FileUploadJob's existing timeout handling therefore applies to GCS as it already does to S3. Exhausting the attempts fails the file, and every produce request in it gets KAFKA_STORAGE_ERROR, which is retriable at the client. Testing ------- GcsStorageConfigTest covers the new defaults, that both values parse into Durations, and that a non-positive value is rejected. GcsStorageTest covers a delete spanning several batches, which the shared suite's ten-key case does not reach. GcsErrorHandlingTest uses WireMock for the failures fake-gcs-server cannot produce. A delayed response pins the timeout classification: an upload surfaces StorageBackendTimeoutException with SocketTimeoutException as its root cause, which exercises the cause-chain walk every classification site depends on. A hand-built multipart batch response pins the delete confirmation on both sides: with one deleted sub-response and two refused, only one key comes back confirmed, and with every sub-response a 404, both keys do. * feat(inkless:storage): count GCS error responses by type The GCS backend reported how many requests it made and nothing about how many failed. The S3 backend counts failed attempts by type, and those counters are what let an operator tell a throttled or faulting provider from a slow one. Without them on GCS, a bucket that starts refusing requests shows up only as latency and as FileCleaner drain arithmetic. Add throttling-errors, server-errors, and other-errors to gcs-client-metrics under the S3 backend's names, so dashboards built for one backend apply to the other. Each counts per request attempt, so a failure the client retries is counted once per attempt, as the AWS SDK does for S3. The response interceptor the request counters already use is the hook: it runs once per attempt for every request the client builds with its initializer, including the resumable upload's PUT and the object download's GET, and it sees non-2xx responses as well as successes. 429 and 503 count as throttling, as SlowDown does for S3, since GCS returns 503 under load and asks the client to back off; other 5xx are server errors; other non-2xx, such as the 404 for a missing key, are other errors. The batch request is built without the request initializer (AbstractGoogleClient.batch passes null), so the interceptor never sees it. GcsStorage.delete(Set) reports its failure directly; a batch is one request and one attempt, so the count is exact there too. The comment on recordBatchDeleteObjects said the interceptor saw the batch request but could not identify its operations, which was the wrong half of the explanation. Requests that fail without a response, a configured timeout or a connection reset, are not counted. The AWS SDK classifies and publishes those itself; the GCS client offers no once-per-attempt hook that carries the exception. The response interceptor never runs on an IOException, HttpIOExceptionHandler receives the request but not the exception, the transport cannot be wrapped because NetHttpTransport is final, and the retry strategy is consulted up to three times per attempt by gax, so counting there needs a dedupe that does not belong in a metrics collector. configured-timeout-errors and io-errors are left for a change that settles where that hook should live. Testing ------- GcsErrorHandlingTest asserts each sensor end to end through WireMock, which pins the interceptor against the client's real call pattern: a 429 on upload counts one throttling error, a 500 one server error, a 404 on fetch one other error, and a 503 on the batch endpoint one throttling error through the caller-side path. Every assertion is an exact 1.0, so a client upgrade that starts retrying at the HTTP layer fails the suite instead of skewing the counters.
* feat(inkless:storage): bound each GCS request attempt by default GCS requests were bounded only by the google-cloud client defaults (20s connect, 20s read), and neither was configurable. GCS can stall for several seconds on response time-to-first-byte even on an established connection, with no retry and no TLS handshake involved, and with produce.max.upload.attempts=3 a single stalled upload could hold an uploader thread for a minute. That shows up as FileUploadTime maxima and cascades into produce latency through FileCommitWaitTime. Add gcs.connect.timeout and gcs.read.timeout, mirroring the existing s3.api.call.timeout and s3.api.call.attempt.timeout knobs, and default them to 2s and 1s. Connect keeps the wider bound on purpose: it only applies on a connection-pool miss, so a generous ceiling costs nothing in steady state and leaves headroom for the connection churn that accompanies a slowdown, while the read bound applies to every stalled request and is the one worth keeping tight. The read value bounds each socket read of a response rather than a whole attempt, so connection setup falls under gcs.connect.timeout, request-body writes are not covered, and a response arriving in several reads can exceed it. An upload response is one small object, so in practice the attempt ends on the first stalled read. Whether a timed out read is retried depends on the operation. An object insert carries no IF_GENERATION_MATCH precondition, so the client classifies it non-idempotent and its non-idempotent handler retries nothing (HttpRetryAlgorithmManager.getForObjectsCreate, DefaultStorageRetryStrategy, and BaseServiceException.isRetryable, which gates every retryable IOException on idempotency). An upload's total is therefore produce.max.upload.attempts stalled reads: about 3s, down from about 60s. A read is idempotent and the client does retry it, under its default settings of 6 attempts and a 50s total timeout, so a stalled fetch is bounded by that budget rather than by this value alone. Batch deletes need the read bound applied differently. The client splits a delete into HTTP batches of at most 100 sub-requests and the server answers only once it has processed all of them, so the response wait grows with the batch and a batch is not retried by the client either. Deleting in chunks of half that maximum keeps the wait clear of the timeout, and returning the keys confirmed so far means a slow batch costs one chunk rather than a whole cleaner cycle. FileCleaner already treats deletion as partial, so this is the contract it expects and matches what the S3 backend does. Timeouts surface as StorageBackendTimeoutException from upload, single-key delete, and a fetch's metadata request. A fetch's body is streamed from the channel it returns and read after the method exits, so a stall mid-download reaches the caller as a plain IOException instead; ReadableByteChannel.read cannot throw StorageBackendException, which does not extend IOException. S3Storage covers its whole fetch only because it materializes the response into a buffer. The batch delete stops the pass rather than classifying, as S3 does. FileUploadJob's existing timeout handling therefore applies to GCS as it already does to S3. Exhausting the attempts fails the file, and every produce request in it gets KAFKA_STORAGE_ERROR, which is retriable at the client. Testing ------- GcsStorageConfigTest covers the new defaults, that both values parse into Durations, and that a non-positive value is rejected. GcsErrorHandlingTest drives a delayed response through WireMock and asserts an upload surfaces StorageBackendTimeoutException with SocketTimeoutException as its root cause, which pins the cause-chain walk the classification depends on. GcsStorageTest covers a delete spanning several batches, which the shared suite's ten-key case does not reach. * feat(inkless:storage): upload GCS objects in a single request storage.createFrom always opens a resumable session: a POST to create it, then a PUT to upload and finalize. Both round trips are paid on every file and either one can stall. Measured in isolation on ~2 KiB objects, that fixed cost is ~100ms per upload on GCS against ~25ms for the equivalent single PUT on S3, and it does not vary with payload size. Use storage.create for objects that fit in one array. It sends a single request, and it has the client supply MD5 and CRC32C so the server verifies content rather than only the length checked here. The hashing costs a few milliseconds per megabyte, well below the round trip it replaces. The threshold is 1 MiB because the trade inverts with size. A single request saves a fixed round trip but cannot stream, so its cost per megabyte is higher, and the measured break-even is near 2 MiB. Raising the threshold without re-measuring makes large uploads slower, not faster. Objects above it keep the resumable path, which also keeps an arbitrarily large one from being buffered whole. The single-request API takes a byte array, so the direct path copies the payload once more than streaming it would, bounded by the threshold. createFrom read the stream to EOF, so the existing size check caught a stream that did not match its declared length in either direction. Reading a fixed count drops that, so the upload now probes for a trailing byte the way S3Storage already does, and a stream longer than its length is reported instead of being silently truncated. Add an object-upload counter: resumable-upload-initiate and resumable-chunk-upload stop advancing for direct uploads, so without it per-request upload activity would no longer be visible. The upload-path interceptor also stops assuming the uploadType parameter is present, which it is not for a direct upload. Testing ------- GcsStorageTest overrides testUploadOversizeStream, since the surplus is now reported by the probe rather than by the size check, the way S3StorageTest already does. * amend! feat(inkless:storage): bound each GCS request attempt by default feat(inkless:storage): bound each GCS request attempt by default GCS requests were bounded only by the google-cloud client defaults (20s connect, 20s read), and neither was configurable. GCS can stall for several seconds on response time-to-first-byte even on an established connection, with no retry and no TLS handshake involved, and a single stalled upload could hold an uploader thread for minutes. That shows up as FileUploadTime maxima and cascades into produce latency through FileCommitWaitTime. Add gcs.connect.timeout and gcs.read.timeout, mirroring the existing s3.api.call.timeout and s3.api.call.attempt.timeout knobs, and default them to 2s and 1s. Connect keeps the wider bound on purpose: it only applies on a connection-pool miss, so a generous ceiling costs nothing in steady state and leaves headroom for the connection churn that accompanies a slowdown, while the read bound applies to every stalled request and is the one worth keeping tight. The read value bounds each socket read of a response rather than a whole attempt, so connection setup falls under gcs.connect.timeout, request-body writes are not covered, and a response arriving in several reads can exceed it. Whether a timed out read ends the operation depends on how the client classifies the request, and for an upload the answer is not the one the shape of the code suggests. Creating a resumable session carries no IF_GENERATION_MATCH precondition, so the client classifies it non-idempotent and its non-idempotent handler retries nothing (HttpRetryAlgorithmManager.getForResumableUploadSessionCreate, DefaultStorageRetryStrategy, and BaseServiceException.isRetryable, which gates every retryable IOException on idempotency). Writing to a session that is already active is classified idempotent whether or not the session started that way (getForResumableUploadSessionWrite), so the PUT is retried under the client's default settings of 6 attempts and a 50s total timeout. Since createFrom buffers 15 MiB by default, a file below that size is one such PUT, and its ceiling is that budget rather than produce.max.upload.attempts stalled reads. Lowering this value changes how many attempts fit inside the 50s, not how long the upload can take. A read is idempotent for the same reason and carries the same budget. What the bound buys on every path is that a stalled request fails its attempt in a bounded, operator-set time instead of holding a connection for 20s. Batch deletes need the read bound applied differently. The client splits a delete into HTTP batches of at most 100 sub-requests and the server answers only once it has processed all of them, so the response wait grows with the batch and a batch is not retried by the client either. Deleting in chunks of half that maximum keeps the wait clear of the timeout, and returning the keys confirmed so far means a slow batch costs one chunk rather than a whole cleaner cycle. FileCleaner already treats deletion as partial, so this is the contract it expects and matches what the S3 backend does. Chunking alone would still confirm the wrong keys. Storage.delete(Iterable) reports a failed sub-request as the same false its callback uses for an absent blob (StorageImpl.delete, whose per-blob error callback records Boolean.FALSE without rethrowing), so the return value cannot distinguish them and ignoring it confirmed every key in the batch. A 403 or 5xx on one key then dereferenced a live file in the control plane and left an orphan nothing would collect. Driving Storage.batch() directly keeps the three outcomes apart, because StorageBatch maps a 404 to false and anything else to a thrown StorageException: deleted and already-absent are confirmed, a failure is not and is counted by response code, at INFO for throttling and WARN otherwise as in the S3 backend. object-delete now counts confirmed deletions rather than attempted ones. Timeouts surface as StorageBackendTimeoutException from upload, single-key delete, and a fetch's metadata request. A fetch's body is streamed from the channel it returns and read after the method exits, so a stall mid-download reaches the caller as a plain IOException instead; ReadableByteChannel.read cannot throw StorageBackendException, which does not extend IOException. S3Storage covers its whole fetch only because it materializes the response into a buffer. The batch delete stops the pass rather than classifying, as S3 does. FileUploadJob's existing timeout handling therefore applies to GCS as it already does to S3. Exhausting the attempts fails the file, and every produce request in it gets KAFKA_STORAGE_ERROR, which is retriable at the client. Testing ------- GcsStorageConfigTest covers the new defaults, that both values parse into Durations, and that a non-positive value is rejected. GcsStorageTest covers a delete spanning several batches, which the shared suite's ten-key case does not reach. GcsErrorHandlingTest uses WireMock for the failures fake-gcs-server cannot produce. A delayed response pins the timeout classification: an upload surfaces StorageBackendTimeoutException with SocketTimeoutException as its root cause, which exercises the cause-chain walk every classification site depends on. A hand-built multipart batch response pins the delete confirmation on both sides: with one deleted sub-response and two refused, only one key comes back confirmed, and with every sub-response a 404, both keys do. * feat(inkless:storage): count GCS error responses by type The GCS backend reported how many requests it made and nothing about how many failed. The S3 backend counts failed attempts by type, and those counters are what let an operator tell a throttled or faulting provider from a slow one. Without them on GCS, a bucket that starts refusing requests shows up only as latency and as FileCleaner drain arithmetic. Add throttling-errors, server-errors, and other-errors to gcs-client-metrics under the S3 backend's names, so dashboards built for one backend apply to the other. Each counts per request attempt, so a failure the client retries is counted once per attempt, as the AWS SDK does for S3. The response interceptor the request counters already use is the hook: it runs once per attempt for every request the client builds with its initializer, including the resumable upload's PUT and the object download's GET, and it sees non-2xx responses as well as successes. 429 and 503 count as throttling, as SlowDown does for S3, since GCS returns 503 under load and asks the client to back off; other 5xx are server errors; other non-2xx, such as the 404 for a missing key, are other errors. The batch request is built without the request initializer (AbstractGoogleClient.batch passes null), so the interceptor never sees it. GcsStorage.delete(Set) reports its failure directly; a batch is one request and one attempt, so the count is exact there too. The comment on recordBatchDeleteObjects said the interceptor saw the batch request but could not identify its operations, which was the wrong half of the explanation. Requests that fail without a response, a configured timeout or a connection reset, are not counted. The AWS SDK classifies and publishes those itself; the GCS client offers no once-per-attempt hook that carries the exception. The response interceptor never runs on an IOException, HttpIOExceptionHandler receives the request but not the exception, the transport cannot be wrapped because NetHttpTransport is final, and the retry strategy is consulted up to three times per attempt by gax, so counting there needs a dedupe that does not belong in a metrics collector. configured-timeout-errors and io-errors are left for a change that settles where that hook should live. Testing ------- GcsErrorHandlingTest asserts each sensor end to end through WireMock, which pins the interceptor against the client's real call pattern: a 429 on upload counts one throttling error, a 500 one server error, a 404 on fetch one other error, and a 503 on the batch endpoint one throttling error through the caller-side path. Every assertion is an exact 1.0, so a client upgrade that starts retrying at the HTTP layer fails the suite instead of skewing the counters.
GCS requests were bounded only by the google-cloud client defaults (20s connect, 20s read), neither of which was configurable, and every upload opened a resumable session and paid two round trips. Both costs land on the produce path: a stalled or slow upload holds an uploader thread, shows up as
FileUploadTime, and cascades into produce latency throughFileCommitWaitTime. This makes the timeouts configurable, bounds each response read by default, and drops one round trip for small objects. See the commit messages for the reasoning behind each step.Commits
feat(inkless:storage): bound each GCS request attempt by default- addsgcs.connect.timeoutandgcs.read.timeout, defaulting to 2s and 1s; maps a timed out request toStorageBackendTimeoutException; and reshapes the batch delete so it chunks its work, reports per-key failures, and no longer confirms a key the server refused to delete.feat(inkless:storage): upload GCS objects in a single request- usesstorage.createfor objects up to 1 MiB, keeps the resumable path above that, and adds anobject-uploadcounter so per-request upload activity stays visible.feat(inkless:storage): count GCS error responses by type- addsthrottling-errors,server-errors, andother-errorsunder the S3 backend's names, counted per request attempt from the response interceptor.Config changes
gcs.connect.timeout(new): connection establishment timeout in milliseconds. Default 2000.gcs.read.timeout(new): response read timeout in milliseconds. Default 1000.Both mirror the existing
s3.api.call.timeoutands3.api.call.attempt.timeoutknobs, except that these carry real defaults rather than deferring to the client's. An operator who wants the old behavior sets the client's values explicitly (20000 each); there is no null or sentinel, because a plain number already expresses it.Three properties of these bounds are worth knowing before tuning them:
gcs.connect.timeout, request-body writes are not covered, and a response arriving in several reads can exceed it.produce.max.upload.attemptsstalled reads, about 3s. A read and a resumable upload's chunk write are idempotent and run up to 6 client attempts under a 50s total timeout, so above 1 MiB an upload attempt is bounded by that budget rather than by this value, and lowering the read timeout changes how many attempts fit in the 50s rather than shortening it. Removing the client's retry on the produce path is left as a follow-up.SharedStatebuilds four independent backends (fetch, lagging fetch, produce, background) from the sameinkless.storage.*config. Connect keeps the wider bound on purpose: it applies only on a connection-pool miss, so a generous ceiling costs nothing in steady state and leaves headroom for the connection churn that accompanies a slowdown, while the read bound applies to every stalled request and is the one worth keeping tight.A timed out request surfaces as
StorageBackendTimeoutExceptionfromupload, single-keydelete, and a fetch's metadata request. A fetch's body is streamed from the channelfetchreturns, so a stall mid-download reaches the caller as a plainIOExceptioninstead --ReadableByteChannel.readcannot throwStorageBackendException.S3Storageclassifies its whole fetch only because it materializes the response into a buffer. The batch delete stops the pass rather than classifying, as S3 does. When an upload exhausts its attempts the file fails, and every produce request in it getsKAFKA_STORAGE_ERROR. That is aRefreshRetriableExceptionat the client, so producers retry after a metadata refresh rather than losing data.Metric changes
object-upload-rate/object-upload-total(new): single-request object uploads.resumable-upload-initiateandresumable-chunk-uploadstop advancing for objects at or below the threshold, so dashboards that track upload activity through those two need this one added.throttling-errors,server-errors,other-errors(new, each-rate/-total): error responses per request attempt, under the S3 backend's names so one dashboard serves both. 429 and 503 are throttling, asSlowDownis on S3; other 5xx are server errors; other non-2xx, including the 404 for a missing key, are other errors. A request that fails without a response (a configured timeout, a connection reset) is not counted; see the follow-ups.Operator impact
Objects up to 1 MiB now issue one request instead of two. With
produce.buffer.max.bytesat 8 MiB, that is the small-file end of the distribution: a broker rotating full buffers under load keeps streaming, while a low-throughput topic flushing a few kilobytes everyproduce.commit.interval.mssaves a round trip, which is where the fixed cost dominates. The threshold sits below the measured break-even near 2 MiB, above which a single request costs more per megabyte than the round trip it saves.A GCS request that stalls beyond the new timeouts fails its attempt rather than holding a connection for 20s. For an object at or below 1 MiB, where nothing is retried inside the client, three upload attempts now complete in about 3s instead of a minute. Above the threshold the client's own 50s retry budget dominates and the read timeout does not shorten the upload, so expect
FileUploadTimemaxima to drop at the small end of the distribution and stay where they are at the large end.A stalled object download is a second place the client's budget wins.
ApiaryUnbufferedReadableByteChannel.readreopens the stream on a retryable failure and each reopen starts a fresh budget, so a shorter read timeout makes it reopen more often against a backend that is already slow rather than giving up sooner. Bounding a stalled fetch needs a deadline above the channel; see the follow-ups.Batch deletes change shape rather than budget. The client splits a delete into HTTP batches of at most 100 sub-requests and answers only after processing all of them, so the response wait scales with the batch, and a batch delete is not retried by the client.
GcsStorage.delete(Set)now deletes in chunks of half that maximum and returns the keys confirmed so far, so a slow batch costs one chunk instead of a whole cleaner pass. A default cycle of 20000 files becomes 400 round trips, well insidefile.cleaner.interval.ms.FileCleaneralready treats deletion as partial, so this matches the contract it expects and the S3 backend's shape.The same rewrite fixes a confirmation bug the previous shape had.
Storage.delete(Iterable)reports a failed sub-request as the samefalseits callback uses for an absent blob, and the old code ignored the result and confirmed every key in the batch, so a 403 or 5xx on one key still dereferenced its file in the control plane and left a permanent orphan. DrivingStorage.batch()directly keeps the three outcomes apart: deleted and already-absent are confirmed, a failure is not, and failures are now logged by response code the way the S3 backend logs them.object-deletecounts confirmed deletions rather than attempted ones, so it can read lower than before on a bucket that is refusing deletes.Testing
GcsStorageConfigTestcovers the new defaults, that both values parse intoDurationobjects, and that a non-positive value is rejected.GcsStorageMetricsTestasserts the upload counters on both sides of the 1 MiB threshold.GcsStorageTestcovers a delete spanning several batches, which the shared suite's ten-key case does not reach, and overridestestUploadOversizeStream, since a stream longer than its declared length is now reported by the trailing-byte probe rather than by the size check, matching the S3 backend.GcsErrorHandlingTestuses WireMock for the failures fake-gcs-server cannot produce. A delayed response pins the timeout classification: an upload surfacesStorageBackendTimeoutExceptionwithSocketTimeoutExceptionas its root cause, which exercises the cause-chain walk every classification site depends on. A hand-built multipart batch response pins the delete confirmation on both sides: with one deleted sub-response and two refused, only one key comes back confirmed, and with every sub-response a 404, both keys do. Each error sensor is asserted at exactly one count end to end: a 429 and a 500 on upload, a 404 on fetch, and a 503 on the batch endpoint, which the interceptor never sees and the caller reports.Follow-ups
configured-timeout-errorsandio-errorsare still missing on GCS, so nothing reports how often these timeouts fire per attempt. The AWS SDK publishes those itself; the GCS client has no once-per-attempt hook that carries the exception (the response interceptor never runs on anIOException,HttpIOExceptionHandlerreceives no exception,NetHttpTransportis final, and gax consults the retry strategy up to three times per attempt). Settling where that hook lives is its own change, and it is what would let these defaults be validated in production rather than inferred fromFileUploadTime.fetchreturns, so a stall reaches the caller as a plainIOException, and the client's read channel reopens the stream on each retryable failure with a fresh retry budget, so no number of read timeouts ends the fetch. Both need a wall-clock deadline above the channel, aroundObjectFetcher.readToByteBuffer, which can throwStorageBackendException. Shared across backends, so it belongs in its own change.FileUploadJobon top, while a fetch has only the client's retry. The fix is no client retry on the produce backend and the default on the fetch backend, which needs per-role storage config, sinceSharedStatebuilds all four backends from the sameinkless.storage.*map.produce.upload.backoff.ms(10ms, unjittered) has to be re-sized in the same change: it is masked by the client's backoff today and becomes the only wait between attempts once that is gone. Both should follow the error counters, because removing the client's retry trades a slow success for a fastKAFKA_STORAGE_ERROR, and how often a stalled PUT recovers on a retry is the number that decides whether that trade is worth making.