Skip to content

feat(inkless:storage): bound and shorten the GCS upload path - #783

Merged
giuseppelillo merged 4 commits into
mainfrom
jeqo/gcs-timeouts
Sep 8, 2026
Merged

giuseppelillo merged 4 commits into
mainfrom
jeqo/gcs-timeouts

Conversation

@jeqo

@jeqo jeqo commented Sep 3, 2026 •

Copy link
Copy Markdown
Contributor

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 through FileCommitWaitTime. 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 - adds gcs.connect.timeout and gcs.read.timeout, defaulting to 2s and 1s; maps a timed out request to StorageBackendTimeoutException; 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 - uses storage.create for objects up to 1 MiB, keeps the resumable path above that, and adds an object-upload counter so per-request upload activity stays visible.
  • feat(inkless:storage): count GCS error responses by type - adds throttling-errors, server-errors, and other-errors under 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.timeout and s3.api.call.attempt.timeout knobs, 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:

  • The read value bounds each socket read, not a whole request. Connection setup falls under gcs.connect.timeout, request-body writes are not covered, and a response arriving in several reads can exceed it.
  • Whether it bounds the operation depends on the client's idempotency classification. A single-request upload and a resumable session initiate are non-idempotent, and the client's non-idempotent handler retries nothing, so their ceiling is ours to set: an object at or below 1 MiB ends its attempt on the first stalled read, making the upload budget produce.max.upload.attempts stalled 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.
  • Both values apply to every GCS role. SharedState builds four independent backends (fetch, lagging fetch, produce, background) from the same inkless.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 StorageBackendTimeoutException from upload, single-key delete, and a fetch's metadata request. A fetch's body is streamed from the channel fetch returns, so a stall mid-download reaches the caller as a plain IOException instead -- ReadableByteChannel.read cannot throw StorageBackendException. S3Storage classifies 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 gets KAFKA_STORAGE_ERROR. That is a RefreshRetriableException at 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-initiate and resumable-chunk-upload stop 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, as SlowDown is 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.bytes at 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 every produce.commit.interval.ms saves 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 FileUploadTime maxima 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.read reopens 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 inside file.cleaner.interval.ms. FileCleaner already 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 same false its 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. Driving Storage.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-delete counts confirmed deletions rather than attempted ones, so it can read lower than before on a bucket that is refusing deletes.

Testing

GcsStorageConfigTest covers the new defaults, that both values parse into Duration objects, and that a non-positive value is rejected. GcsStorageMetricsTest asserts the upload counters on both sides of the 1 MiB threshold. GcsStorageTest covers a delete spanning several batches, which the shared suite's ten-key case does not reach, and overrides testUploadOversizeStream, 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.

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. 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-errors and io-errors are 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 an IOException, HttpIOExceptionHandler receives no exception, NetHttpTransport is 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 from FileUploadTime.
  • A stalled GCS object download is neither classified nor bounded. The body is streamed after fetch returns, so a stall reaches the caller as a plain IOException, 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, around ObjectFetcher.readToByteBuffer, which can throw StorageBackendException. Shared across backends, so it belongs in its own change.
  • Above 1 MiB an upload is retried twice over: by the client's idempotent handler on the resumable PUT and by FileUploadJob on 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, since SharedState builds all four backends from the same inkless.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 fast KAFKA_STORAGE_ERROR, and how often a stalled PUT recovers on a retry is the number that decides whether that trade is worth making.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 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 null to restore the client default, but Range.between rejects an explicitly configured null before getDuration runs. This makes the documented opt-out unusable. Either add nullable validation and skip the corresponding transport setter when the value is null, or remove the null claim 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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 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

jeqo added 2 commits September 4, 2026 17:47
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.
@jeqo
jeqo force-pushed the jeqo/gcs-timeouts branch from 3b778fc to 8b4ab80 Compare September 4, 2026 15:47
@jeqo
jeqo marked this pull request as ready for review September 4, 2026 15:50

@giuseppelillo giuseppelillo left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. I'm fixing this by using StorageBatch API to handle the response properly.

Comment on lines +128 to +130
} else {
blob = storage.createFrom(blobInfo, inputStream);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +246 to +250
} 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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

jeqo added 2 commits September 7, 2026 19:18
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.
@giuseppelillo
giuseppelillo merged commit f7e7412 into main Sep 8, 2026
8 checks passed
@giuseppelillo
giuseppelillo deleted the jeqo/gcs-timeouts branch September 8, 2026 09:07
jeqo added a commit that referenced this pull request Sep 9, 2026
* 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.
jeqo added a commit that referenced this pull request Sep 9, 2026
* 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.
jeqo added a commit that referenced this pull request Sep 9, 2026
* 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants