Skip to content

[ruby-compat/07] Fail fast instead of swallowing data-loss errors - #123

Open
chikamura wants to merge 72 commits into
feature/ruby-compatibilityfrom
ruby-compat/07-fail-fast-on-data-loss
Open

chikamura wants to merge 72 commits into
feature/ruby-compatibilityfrom
ruby-compat/07-fail-fast-on-data-loss

Conversation

@chikamura

@chikamura chikamura commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

Summary

The java port swallowed two data-loss errors that ruby raises: a record conversion/write error truncated the intermediate file without failing the run, and an upload IOException was logged at INFO and ignored.

Data phase

  • BigqueryPageOutput#add() and BigqueryFileWriter#write() no longer swallow errors; the run fails at once.
  • BigqueryFileWriter#close() still swallows (ruby: io.close rescue nil) but logs at WARN, creates no file for a writer that never wrote, and always releases the file descriptor (GZIP leaked it when the trailer or the header write failed).
  • transaction() loads the files this run's writers wrote instead of globbing path_prefix*file_ext. The cleanup runs every step and closes every writer even if one throws, and keeps the run's own failure as the reported one, with cleanup failures attached as suppressed.

Upload (BigqueryClient#load())

  • The job id is fixed for the whole upload, like ruby's with_network_retry, so a retry never creates a second job.
  • The library retries each request inside the session; load() re-sends the whole file only for a socket error under TLS, which the library doesn't retry. Re-sending on top of the library's attempts multiplied them by retries.
  • The write channel is closed only after the whole file is written, so a failed attempt no longer finalizes a partial upload into a load job.
  • A 409 on the retry waits for the existing job instead of failing; the lookup carries the job's location (location, else the dataset's). After any other transient failure, the upload asks for its job id once before failing and waits for the job if it exists.

Behavior change

  • location, when configured, now goes into every job reference (as ruby does), so a location that does not match the dataset's is rejected by BigQuery where before it was ignored.

Differences from ruby, on purpose

  • A failed run's partial file is cleaned up; a job a failed upload left behind is waited for; a cleanup failure doesn't replace the run's failure; a failed close is logged; an empty page leaves no file.

Follow-up (separate PR)

  • Clearing BigqueryUtil.getFileWriters() between two transaction() calls in one JVM (same shape in ruby; the NPE part is ruby-compat/14).

Test plan

  • ./gradlew test (188 tests, 1 skipped)
  • ./gradlew spotlessApply

🤖 Generated with Claude Code

Ruby's file_writer#add() has no rescue at all around record
conversion/write, and load()'s upload step logs an ERROR and raises
immediately on failure. Java silently swallowed both: a record
conversion error truncated the intermediate file without failing the
job, and an upload IOException was logged at INFO and ignored (only
surfacing later, if at all, via the row-count mismatch check, with
the root cause buried).

- BigqueryPageOutput#add(): remove the catch-and-swallow around the
  per-record conversion/write loop, so any error there fails the run
  immediately.
- BigqueryFileWriter#write(): rethrow IOExceptions (with an ERROR
  log) instead of swallowing them. close() keeps swallowing errors,
  matching ruby's file_writer#close (`io.close rescue nil`); a file
  truncated by a failed flush/close is still caught by the load job
  error or the row-count check.
- BigqueryClient#load(): log at ERROR and throw BigqueryUploadException
  on an upload IOException instead of swallowing it at INFO. The
  exception is retryable, so a transient network failure during upload
  is retried up to `retries` times like ruby's with_network_retry,
  then propagated. writeToStream() and waitForLoad() are made
  package-private so tests can stub the network steps directly via a
  mocked BigqueryClient, instead of relying on a real upload failing
  against a directory; a test covering the retry-then-succeed path is
  added.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@chikamura
chikamura marked this pull request as ready for review October 1, 2026 06:27
@chikamura
chikamura requested a review from john-abre October 1, 2026 06:27

@john-abre john-abre left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

ruby-compat/07(親 issue の PR7 = 2-4, 2-5)のレビューです。方向性は分割案どおりで、BigqueryPageOutput#add() の握りつぶし除去と BigqueryFileWriter#write() の再スローは問題ありません。ただし、目玉であるアップロード周りのリトライ経路に 2 点、修正が必要な問題があります。いずれもライブラリのバイトコード(google-cloud-bigquery 2.14.0 / google-cloud-core 2.8.0)で確認済みです。

修正必須(インラインコメント参照)

  1. 実際のネットワーク障害はリトライされません。 新しい catch (IOException) は、書き込みチャネルが投げる BigQueryException(RuntimeException)を捕まえられないため、接続リセットなどは 1 回目で即 give up になります。
  2. リトライで行が二重ロードされる可能性があります。 失敗後も try-with-resources がチャネルを close し、その close が部分アップロードを finalize してロードジョブを開始します。その後のリトライは新しい job id で完全なファイルをロードするため、append_direct では両方が追記されます。ruby は job_id を 1 回だけ生成してリトライ間で使い回し、まさにこの重複を防いでいます(bigquery_client.rb のコメント参照)。

親 issue の文脈について

  • 分割案では 2-5 は「ERROR ログ + 即時失敗」であり、リトライの追加は計画外です。追加するなら ruby と同じく job_id をリトライループの外で生成する必要がありますし、計画どおり即時失敗にするのも選択肢です。
  • 後続で予定されている 429 リトライのラッパー(bigquery.* 呼び出しを包むもの)では 1. は解決しません。障害は bigquery.writer() ではなくストリームの write / close から出るためです。この PR で直す必要があります。
  • テストで実際に 2 秒スリープしている点は、#113 の後に rebase して buildJobRetryExecutor() に乗せれば(build.gradle の環境変数で 1ms になるため)解消します。rebase 時の注意点としてだけ挙げておきます。

修正推奨: close() の flush 失敗時のストリームリーク、write() の素の RuntimeException。

軽微: スタックトレースの重複出力、BUFFER_SIZE の public 化。中間ファイルが存在しない場合の return null は既存挙動で 2-4 / 2-5 の範囲外なので、issue にメモして別 PR でよいと思います。

try (OutputStream stream = Channels.newOutputStream(writer)) {
Files.copy(loadFile, stream);
writeToStream(loadFile, stream);
} catch (IOException e) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

修正必須: この catch (IOException) は、実際のネットワーク障害を捕まえられません。

Channels.newOutputStream(writer).write() → BaseWriteChannel.write() → TableDataWriteChannel.flushBuffer() という経路で、flushBuffer は RetryHelperException を catch して BigQueryException.translateAndThrow で BigQueryException(RuntimeException) に変換して投げます(2.14.0 のバイトコードで確認)。そのため接続リセット等はこの catch を素通りし、isRetryableException() も false を返すので、1 回目で「Give up retrying」となり retries の回数に関わらずリトライされません。PR 説明の「ruby の with_network_retry と同様に retries 回リトライ」は現状成り立っていません。

逆に、この catch に入る IOException は Files.copy のローカル側読み込みエラー(ファイル消失・読み取り不可など)だけで、これらは一過性ではないため 2〜10 秒のバックオフ付きリトライは無駄になります。

アップロードと close を囲む範囲で BigQueryException も catch(必要なら isRetryable() で絞る)して BigqueryUploadException に包んでください。なお、後続で予定されている 429 リトライのラッパーは bigquery.* の呼び出し(writer() など)を包むものなので、ストリームの write / close で発生するこの例外はカバーされません。この PR で直す必要があります。

@@ -380,9 +386,18 @@ public JobStatistics.LoadStatistics call() {
bigquery.writer(JobId.of(jobId), writeChannelConfiguration);

try (OutputStream stream = Channels.newOutputStream(writer)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

修正必須: アップロード途中で失敗しても、try-with-resources がチャネルを close します。BaseWriteChannel.close() は isOpen なら flushBuffer(length, lastChunk=true) を呼ぶ(バイトコードで確認)ので、部分的なバイト列で resumable セッションが finalize され、BigQuery 側で部分ファイルのロードジョブ(job id = uuid1)が開始されます。その後 BigqueryUploadException で RetryExecutor がリトライし、call() が再実行されて新しい uuid2 で完全なファイルがロードされます。

append_direct モードでは両方のジョブが宛先テーブルに追記され、num_output_rows は成功した uuid2 の LoadStatistics しか合計しないため、行数チェックでは uuid1 の重複行を検出できません。この PR 以前は握りつぶした後に 1 つの部分ジョブを waitForLoad() していたので、少なくとも 2 つ目のロードは発行されませんでした。

ruby 版はこの点を明示的に防いでいます: load() で job_id を リトライの外で 1 回だけ 生成し(# we should generate job_id in client code, otherwise, retrying would cause duplication)、with_network_retry の中で同じ job_id を使い回すので、リトライ時に重複ジョブが作られることはありません。

対応案: (a) ruby と同じく uuid / jobId の生成をリトライループ(call())の外に出し、リトライでは同じ job id を再利用する(finalize 済みなら BigQuery が重複 job id を拒否するので、重複ロードではなく失敗になります)。(b) 分割案(2-5 は「ERROR ログ + 即時失敗」)どおりアップロードのリトライ自体をやめる。いずれにせよ失敗後にチャネルを close して finalize してしまう点は避ける必要があります。

@@ -380,9 +386,18 @@ public JobStatistics.LoadStatistics call() {
bigquery.writer(JobId.of(jobId), writeChannelConfiguration);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

上のコメントに関連: jobId が call() の中で生成されているため、リトライのたびに新しいジョブになります。ruby は load() 1 回につき job_id を 1 つだけ生成し、リトライ間で使い回しています。リトライを残すなら、ここをループの外に出すのが ruby との整合という意味でも正しい形だと思います。

destinationDataset,
table,
e.getMessage());
logger.error(msg, e);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

軽微: ここでリトライ本体の中から毎回フルスタックトレース付きで ERROR を出し、さらに onRetry が retries 回ごとに WARN でスタック付き、最後に例外が伝播して Embulk も出力するので、retries: 5 で障害が継続すると同じトレースが最大 8 回出てログに埋もれます。ループ内はメッセージのみ(throwable なし)にして、スタックは伝播する例外に任せるのがよさそうです。

// Swallowed to match ruby (file_writer#close does `io.close rescue nil`). A file truncated by
// a failed flush/close is still caught later, by the load job error or the row-count check.
try {
this.outputStream().flush();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

修正推奨: flush() と close() が 1 つの try に入っているため、flush() が失敗すると close() が呼ばれず、FileOutputStream がリークします(GZIP の場合はトレーラーも書かれません)。

またコメントの「行数チェックで後から検出される」は常には成り立ちません。abort_on_error のデフォルトは max_bad_records == 0 なので、max_bad_records > 0 か is_skip_job_result_check: true の設定では num_input_rows / num_output_rows の比較がスキップされ、末尾の最大 32KB 分のレコードが欠けたままジョブが成功します。まさにこの PR が塞ごうとしているデータ欠損のクラスです。close() は finally(または try-with-resources)で必ず呼び、ログは少なくとも WARN / ERROR にしてください。

logger.info(e.getMessage());
} catch (IOException e) {
logger.error("embulk-output-bigquery: failed to write an intermediate file", e);
throw new RuntimeException(e);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

修正推奨: 素の RuntimeException(e) ではなく、この PR で追加した BigqueryException(String, Throwable) を使ってください。現状だと Embulk の PartialExecutionException には java.lang.RuntimeException: java.io.IOException: No space left on device のようにプラグインの文脈なしで出ますし、ログにしかない「failed to write an intermediate file」のメッセージが例外に乗りません。モジュール内の他の失敗(BigqueryClient / BigqueryJobWaiter)はすべて BigqueryException 系なので、型も揃います。


public class BigqueryFileWriter {
// embulk default page size
public static final int BUFFER_SIZE = 1024 * 32;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

nit: BUFFER_SIZE は同一パッケージの TestBigqueryFileWriter から参照するためだけに public になっています。package-private で十分なので、プラグインの公開 API を広げないほうがよいと思います。

// upload hardcodes the real BigQuery host (see BigqueryTestHostSupport). Instead, BigqueryClient
// is a Mockito mock whose load() runs for real, with its collaborators injected by reflection
// and its network steps (writeToStream / waitForLoad) left to the mock.
private static class LoadFixture {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

rebase 時の注意: この fixture で動く 2 つのリトライテストは実際の RetryExecutor(初期待機 2 秒)を回すので、./gradlew test 全体に約 4 秒の実スリープが加わります。親 issue の着手順では #113 の後に rebase する想定なので、その際に load() のインラインの RetryExecutor を #113 の buildJobRetryExecutor() に乗せれば、build.gradle の BIGQUERY_OUTPUT_OPTION_JOB_RETRY_* で待機が 1ms になり解消します。この PR 単体で直す必要はありません。

chikamura and others added 8 commits October 8, 2026 10:14
Pin down the review findings before fixing them:

- TableDataWriteChannel reports a failed upload as BigQueryException
  (RuntimeException), so the catch (IOException) in load() never retries a
  real network failure.
- Closing the channel after a failed upload finalizes the resumable session
  with the partial bytes, starting a load job on top of the one issued by
  the retry.
- The job id is generated per attempt instead of once per upload, unlike
  ruby's with_network_retry. A guard test pins the other half of the ruby
  behavior: a job that ran and failed is retried as a new job.
- The per-attempt ERROR inside the retry loop logs the stack trace that
  onRetry() and Embulk already log.
- BigqueryFileWriter#close() skips close() when flush() fails, and write()
  throws a bare RuntimeException instead of BigqueryException.

The channel tests run the real BigQueryImpl/TableDataWriteChannel on top of a
mocked BigQueryRpc, since write()/close() are final and can't be mocked.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
It is only referenced from the same-package test, so it doesn't need to be
part of the plugin's public API.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A bare RuntimeException(e) surfaces in Embulk's error output as
"java.lang.RuntimeException: java.io.IOException: ..." with no plugin
context; the message that was only logged now travels with the exception,
and the type matches the rest of the plugin's failures.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
flush() and close() shared one try block, so a failed flush skipped close()
and leaked the FileOutputStream. Use try-with-resources instead.

The IOException is now swallowed silently, matching ruby's
`io.close rescue nil` exactly: verified against the ruby plugin that a
failed close logs nothing and still releases the file descriptor. The old
comment claiming the truncated file is caught later by the row-count check
was wrong: that check is skipped when abort_on_error is false
(max_bad_records > 0) or is_skip_job_result_check is true, in both ruby and
java, so the job then succeeds with the missing rows.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
try-with-resources closed the TableDataWriteChannel on failure too, and
BaseWriteChannel#close() sends the buffered bytes as the last chunk of the
resumable session. That created a load job for the partial file, which
nobody waited for; the retry then issued a second job for the full file, so
in append_direct mode both got appended without the row-count check noticing.
Close the stream only after the whole file has been written.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
TableDataWriteChannel reports a failed upload as BigQueryException (a
RuntimeException): BigQueryRpc#write translates the IOException and
flushBuffer() rethrows it via BigQueryException.translateAndThrow(). The
catch (IOException) therefore never saw a connection reset, and load() gave
up on the first attempt regardless of `retries`.

Catch BigQueryException as well and wrap it in BigqueryUploadException when
the library itself considers it retryable (connection reset, 5xx, ...). A
non-retryable one (4xx) is wrapped in BigqueryException so it keeps the
"failed to upload" context but fails right away.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The per-attempt ERROR carried the full stack trace, which onRetry() logs
again and Embulk logs once more when the exception propagates. With
retries: 5 the same trace appeared up to 8 times. The message already
includes the cause's message, so log only that per attempt.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Mirror ruby's with_network_retry inside with_job_retry: the upload now runs
in its own retry loop (upload()/uploadOnce()) that keeps the job id fixed, so
a retried upload re-sends the file under the same id as a new resumable
session instead of creating a second job. Retries are immediate, like ruby's.
BigqueryUploadException is no longer retried by the outer executor, which
keeps regenerating the job id for jobs that ran and failed (BackendError
etc.), again like ruby.

bigquery.writer() moved inside the try so a failure to open the session is
retried the same way. The helpers are private on purpose: the mocked
BigqueryClient in tests must run them for real.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

@john-abre john-abre left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

ありがとうございます。変換エラーを握りつぶさずに即座に失敗させる方針は正しく、同一 ID でのアップロードリトライも ruby の with_network_retry と一致しています。マージ前に修正が必要な点が 2 つあり、どちらもこの PR によって新たに表面化するデータ整合性の問題です。それ以外は任意です。

要修正

  1. 失敗した実行で中途半端な中間ファイルが残る。 add() が例外を投げるようになったため、BigqueryJavaOutputPlugin.transaction() 内の control.run() から例外が抜けます。writer の close と getIntermediateFiles() はどちらもその呼び出しの後にあり、finally は paths != null の場合にしか削除しないため、書きかけの <path_prefix>.<pid>.<tid>.jsonl が開いたままディスク上に残ります。getIntermediateFiles は path_prefix*file_ext で glob するので、次回の実行で古いファイルも自分のファイルと一緒に投入され、途中までの行がロードされてしまいます。abort_on_error は行数の不一致をロード後にしか検知しないため、一時テーブルを使うモードは守られますが append_direct は守られません。修正案:finally 内で、paths が設定されたかどうかに関わらず、登録済みのすべての writer を close し、delete_from_local_when_job_end が有効ならそのファイルを削除する。

  2. 同一ジョブ ID でのリトライがリトライ不可の 409 で失敗し得る。 uploadOnce では stream.close() が最後のチャンクを送信し、BigQuery がセッションを確定して embulk_load_job_<uuid> を作成します。そのリクエストのレスポンスが失われた場合(connection reset)、例外はリトライ可能と判定されるため upload() は同じ ID で再送します。2 回目の確定では 409 Already Exists: Job が返り、isRetryable() は false なので、最初のロードジョブが存在して実行されているにも関わらず実行全体が失敗します。append_direct では行が追加済みの状態となり、ユーザーが再実行すると重複が発生します。修正案:同一ジョブ ID での 409 を成功として扱い、bigquery.getJob(JobId.of(jobId)) で取得したジョブを waitForLoad に渡す。取得したジョブが失敗していた場合は、外側のジョブリトライが新しい ID で処理するので問題ありません。

任意のコメント

  • BigqueryFileWriter.close() は outputStream() を呼び出しており、これがファイルを遅延オープンします。setFileWriter で登録されたがレコードを一件も書かなかった writer(空ページ)は 0 バイトのファイルを残し、同じ glob によってロードジョブとして投入されます。既存の問題ですが、今回触るついでに if (os == null) return; のガードを入れる価値があります。
  • uploadOnce 内の IOException は(コメントの通り)ローカルファイルの読み込みからしか発生しませんが、BigqueryUploadException に変換されて retries 回リトライされます。ruby はソケット/接続エラーのみリトライするので、ファイルが存在しない・読めない場合は即座に失敗させるべきです。
  • TLS レベルのリセット(javax.net.ssl.SSLException: Connection reset)は google-cloud-core 2.8.0 では isRetryable() にならないため、新しいリトライを素通りします。ruby でもリトライされないのでパリティの破綻ではなく、目的に対するギャップですが、ruby のようなメッセージベースのチェックでカバーできます。
  • 失敗した各試行が uploadOnce で ERROR として、さらに upload() で WARN/ERROR としてログ出力されるため、一時的なエラーから回復しても ERROR 行が残り、リトライを使い切った場合は同じ文言が 3 回出力されます。ruby はリトライ時に WARN、諦めた時にのみ ERROR を出力するので、upload() で一度だけログを出せば一致します。
  • load() はすでに RetryExecutor を使っているのに、upload() は独自のリトライループを実装しています。RetryExecutor.builder().withRetryLimit(task.getRetries()).withInitialRetryWaitMillis(0).withMaxRetryWaitMillis(0) に isRetryableException = e instanceof BigqueryUploadException を組み合わせれば、同じ書き方で即時リトライを再現できます。
  • BigqueryFileWriter.close() の try-with-resources 内での明示的な stream.flush() は冗長です。BufferedOutputStream/GZIPOutputStream の close() がすでに flush します。

🤖 Generated with Claude Code

@chikamura
chikamura marked this pull request as draft October 10, 2026 02:36
@chikamura chikamura changed the title [ruby-compat/07] Fail fast instead of swallowing data-loss errors> [WIP] [ruby-compat/07] Fail fast instead of swallowing data-loss errors Oct 10, 2026
chikamura and others added 5 commits October 13, 2026 12:28
google-cloud-bigquery 2.14.0 hardcodes the googleapis.com host for the
request that opens a resumable upload session (HttpBigQueryRpc#open()), so
BigqueryClient#load() used to take a test-only jobs.insert path under
test_host, leaving the actual upload code untested.

Add TestHostBigQueryRpc, an HttpBigQueryRpc subclass that builds that one
URL from the configured host (what upstream does since
googleapis/java-bigquery#2696), install it through the test_host
BigQueryOptions, and drop the jobs.insert bypass. The two test-host classes
move to their own package (testhost.TestHostBigQueryOptions / Rpc).

The MockWebServer tests now see the real session-open POST and file PUT and
assert on the uploaded bytes as well. loadAttemptResponses() /
assertLoadAttempt() describe one load attempt (open, PUT, jobs.get) so retry
scenarios list attempts instead of individual responses, and responses()
flattens those arrays among single responses.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- Replace the Object-varargs responses() with the type-safe MockResponses
  builder (of/add/loadAttempt/toArray).
- assertUploadLoadFile() now states that it models a single-chunk upload and
  fails with a clear message for files over the library's chunk size.
- uploadedLines() detects gzip by its magic bytes instead of assuming it.
- Update comments that still described the old jobs.insert load path.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
add(MockResponse...) already takes the array, so the shortcut only
duplicated loadAttemptResponses().

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…g it

Auto-detecting gzip would quietly accept a plain upload under
compression: GZIP; stating the expectation turns it into a check.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The e2e MockWebServer tests addressed requests as requests.get(n), so every
step that gained or lost a request forced renumbering of everything after
it; that bit when #112 and #119 were merged and again when the load path
became two requests.

Add RequestCursor (next() / assertNoMore()) and walk the requests in order.
Requests referenced twice get a named variable (createTempTable, openUpload,
...). testRunExpandsStrftimeInTableName now also asserts the three requests
it used to skip, since a cursor can't jump over them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
chikamura and others added 8 commits October 13, 2026 15:51
…/07-fail-fast-on-data-loss

# Conflicts:
#	src/main/java/org/embulk/output/bigquery_java/BigqueryClient.java
#	src/test/java/org/embulk/output/bigquery_java/TestBigqueryClient.java
A request for which no response was enqueued used to block until the
client's read timeout (300 + 300 s with #113's defaults), so a test that
listed one response too few hung for 10 minutes before failing. The
MockWebServer now answers such requests immediately with a 599, and
test_host.yml sets 1 s open/read timeouts so a server that deliberately
stays silent (SocketPolicy.NO_RESPONSE) fails the test in a second as well.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…b ids

Groundwork for tests of BigqueryClient#load()'s failure handling at the HTTP
level: startResumableUploadResponse() is public and loadAttemptResponses()
is split into uploadLoadFileResponse() / waitForLoadJobResponse(), so a test
can put failing PUTs between the session open and the final chunk;
disconnectDuringUploadResponse() is the one socket policy the library sees
as a retryable network failure; uploadChunkAcceptedResponse(),
assertUploadChunk() and writeLoadFile() cover files over the 15 MB chunk
size; jobIdOf() reads the job id from the session-open POST.

Tests pin what the library does on its own today: a 5xx or a connection
reset during a chunk PUT is re-sent `retries` times on the same session, a
4xx is not, a 16 MB file goes up as two chunks, and a job that failed is
retried under a new job id. An e2e run with compression: NONE checks the
plain upload body.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- unansweredResponse() (SocketPolicy.NO_RESPONSE) and a test that a chunk
  PUT nobody answers fails after read_timeout_sec, retried once like any
  network failure: the timeout from #113 applies to the upload too. Costs
  about 2 s of real waiting.
- A test of the harness itself: a request beyond the enqueued responses is
  answered with a 599 at once, and the run is reported as having made more
  requests than expected.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The test came in from the upload-over-http branch in the positional form,
which no longer compiles here since the e2e wrappers return a RequestCursor.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The client-level tests still addressed requests by position; now every
runWithMockServer*() hands back a RequestCursor and all MockWebServer tests
walk it, so no test depends on request indices any more. The List + offset
assertLoadAttempt() goes away; the cursor one returns the POST that opened
the upload session, so a test that needs its body (job id, load config)
takes it from the return value instead of expanding the three asserts.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

@john-abre john-abre left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

高精度レビュー(recall 重視)で 8 件のインラインコメントを付けました。重要度順: 409 救済パスの location 欠落(高)、GZIP close 時のリーク(中)、409 後の getJob 例外の取りこぼし(中)、リトライの掛け算(中/効率)、finally での例外上書き(低〜中)、テストの static マップ汚染(低)、dead な null チェック(低)、リトライ展開の重複(低)。


追記 (10/16): 投稿時の手違いでインラインコメントの本文が空になっていたため、削除して個別に投稿し直しました。409 救済パス関連の 2 件(location 欠落、getJob 例外の取りこぼし)は 0cc6780 で対応済みと確認できたので再投稿していません。残りの 6 件に加え、BigqueryUtil.getFileWriters() が transaction 間でクリアされない件を 1 件追加しています。

logger.info(e.getMessage());
throw new RuntimeException(e);
}
paths = BigqueryUtil.getIntermediateFiles(this.writers.values());

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

BigqueryUtil.getFileWriters() は JVM 全体で共有される static な map ですが、transaction() の開始時にも終了時にもクリアされておらず、BigqueryFileWriter.close() も os / path をリセットしません。このため、同一 JVM 内で transaction() が 2 回以上呼ばれる場合(embedded Embulk や、プロセスを使い回すホスト)に、前回の run の writer がそのまま残ります。

  • delete_from_local_when_job_end: true の場合: 前回の path はもう存在しないので load() が「does not exist, skipped」分岐で null を返し、statistics.add(null) → getTransactionReport の LoadStatistics::getOutputRows で NPE になります。
  • false の場合: 前回のファイルがもう一度ロードされ、行が重複します。
  • また、ThreadLocal の writer を再利用したスレッドは close 済みの stream に書き込むことになり、write() が「Stream Closed」で失敗します。

base ブランチでも map 自体はリークしていましたが、そこでは行数カウントにしか使われていませんでした。この PR でロード対象ファイルの決定にも使われるようになったので影響範囲が広がっています(テストは @Before で getFileWriters().clear() しているため通っています)。

transaction() の先頭で map をクリアする(あわせて writer の状態もリセットする)のが根本的な対処になると思います。

…ookup

The 409 rescue in uploadOnce() looked the job up with JobId.of(jobId), which
has no location, so outside the US and EU multi-regions jobs.get answered 404
and the rescue silently fell back to the plain failure (and the rows the
running job loaded were appended again on the rerun). The lookup now uses
`location`, or the destination dataset's location when none is configured, as
ruby's wait_load takes it from the job reference.

The configured location also goes into BigQueryOptions, which the library
fills into every jobs.get / jobs.insert / query, and into the upload's job
reference (the one the library doesn't fill), as ruby puts it into every
job_reference. The explicit builders on the query jobs go with that.

A lookup that fails is retried on its own with the job-retry waits instead of
re-sending the whole file to reach the same 409; one that still fails ends the
load with a message that says so and carries the 409 as a suppressed
exception. A lookup that finds no job logs a WARN naming the job id and the
location before the upload fails. BigqueryJobWaiter reports a job that
jobs.get can't find instead of dereferencing null.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
if (this.os == null) {
return;
}
// close() flushes, and (FilterOutputStream / DeflaterOutputStream) still closes the

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[重要度: 中] GZIP の場合、close() 中の flush 失敗で FileOutputStream と Deflater がリークする(コメントの前提が成り立たない)

コメントでは「DeflaterOutputStream は flush が失敗しても下層のファイルを閉じる」とありますが、DeflaterOutputStream#close() は

if (!closed) { finish(); if (usesDefaultDeflater) def.end(); out.close(); closed = true; }

という実装で、finish()(gzip トレーラの書き込み)が IOException を投げると out.close() も def.end() も実行されません。ディスクフルなどで close 時に失敗すると、compression: GZIP のときは FileOutputStream の fd とネイティブの Deflater が GC までリークします(finally 側の delete() 自体は POSIX では通りますが、ハンドルは開いたままです)。

testCloseClosesStreamEvenIfFlushFails は自作の OutputStream で検証しているため、この GZIP 経路はカバーされていません。open() で生の FileOutputStream を別フィールドに保持しておき、close() の finally でそれも閉じる、あるいは GZIPOutputStream の close 失敗時に明示的に下層を閉じる形にすると安全です。

// the library won't retry (a socket error under TLS, see isRetryableUploadFailure()) or gave up
// on, by starting a new session. No wait between attempts: the library's backoff has already
// been spent by the time this runs, and ruby's with_network_retry retries at once as well.
private RetryExecutor buildUploadRetryExecutor() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[重要度: 中 / 効率] ライブラリのリトライと upload() のリトライが掛け算になり、最悪で (retries+1)² 回の送信と retries+1 倍の total timeout になる

buildRetrySettings() でライブラリ側に maxAttempts = retries + 1(+ バックオフ、retry_total_timeout_sec 上限)を設定した上で、upload() がセッションを開き直してさらに retries 回やり直します。新しいセッションではライブラリのバックオフ予算もリセットされるので、「ライブラリのバックオフはもう消化済み」というコメントは各セッション単位でしか成り立たず、retries: 5(デフォルト)で恒常的に 5xx を返すエンドポイントに対しては 6 セッション × 6 回 = 36 回の PUT、待ち時間は最大で retry_total_timeout_sec の 6 倍になります。大きなファイルでは毎回フルで再送するので転送量も同様に増えます。

ruby 側は google-api-client のリトライが 0 で with_network_retry が唯一のリトライ層なので、こちらの方がかなり多くリトライしています。upload() 側を TLS 配下のソケットエラー(ライブラリが拾わないもの)と 409 の救済に限定し、ライブラリが既に retries 回試した失敗(e.isRetryable() が真のもの)は外側では再送しない、あるいは外側の回数を別設定にする、のいずれかで揃える方が意図に近いように思います。

if (task.getTempTable().isPresent()) {
client.deleteTable(task.getTempTable().get());
}
this.writers.values().forEach(BigqueryFileWriter::close);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[重要度: 低〜中] finally 内の close() / deleteTable() が投げた例外が、本来の失敗原因(データ変換エラーなど)を上書きする

Java では try 側の例外が伝播中に finally 側で別の例外が投げられると、元の例外は捨てられます。ここでは control.run() が「hello cannot be converted to INTEGER」で失敗した後、finally の close()(RuntimeException の場合)や deleteTable()(BigQuery のネットワークエラー)が投げると、ユーザーに見えるのは後者だけになります。deleteTable については以前からの挙動ですが、今回 close の層が一つ増えました。testRunStillCleansUpWhenClosingAWriterThrows は最初の close と finally の close が同じメッセージを投げるため、この上書きを検出できていません。

finally 内の各ステップは try/catch で握ってログに出す(または元の例外に addSuppressed する)形にして、元の失敗原因が必ず伝播するようにすると良さそうです。

};
brokenWriter.setCompression("NONE");
brokenWriter.open(brokenFile.getPath());
BigqueryUtil.getFileWriters().put(-1L, brokenWriter);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[重要度: 低 / テスト] 常に例外を投げる writer を JVM 全体の static マップに入れたまま削除していない

brokenWriter は close() のたびに RuntimeException を投げる実装で、-1L キーで BigqueryUtil.getFileWriters() に残り続けます。現状はこのクラスの @Before clearFileWriters() が次のテストの前に消してくれるので事故になりませんが、同じ JVM で後に実行される別クラスが transaction() を呼ぶ(あるいは @Before が外れる)と、無関係なテストが boom: close failed で落ちます。

try { ... } finally { BigqueryUtil.getFileWriters().remove(-1L); } のようにこのテスト自身で片付けるのが安全です。

if (paths == null) {
paths = BigqueryUtil.getIntermediateFiles(this.writers.values());
}
if (paths != null) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[重要度: 低 / 簡素化] if (paths != null) は常に真になった

直前で paths == null のときに getIntermediateFiles(...) を代入しており、この関数は Collectors.toList() の結果を返すので null を返しません。旧実装(task 版)の名残の null チェックなので、外して一段ネストを減らせます。

if (ex.getCause() instanceof BigqueryException) {
throw (BigqueryException) ex.getCause();
}
throw new RuntimeException(ex);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[重要度: 低 / 重複] RetryGiveupException / InterruptedException の展開処理が 6 箇所目になっている

load()・copy()・runQuery()・executeQuery() 等と同じ「cause が BigqueryException ならそれを投げ直し、そうでなければ RuntimeException に包み、InterruptedException は BigqueryException("interrupted")」というブロックがここにも複製されています。private <T> T runWithRetry(RetryExecutor, Retryable<T>) のようなヘルパーに寄せると、今回のように onGiveup で lastException を投げ直す流儀も一箇所で揃えられます。

chikamura and others added 19 commits October 16, 2026 10:02
buildRetrySettings() gives the library maxAttempts = retries + 1 for each
request of an upload, and upload() then opened a new session and re-sent the
whole file `retries` more times, so a 5xx endpoint cost (retries + 1)^2 PUTs
and as many times the bytes, with the library's backoff budget reset on every
session.

upload() now re-sends only a socket error under TLS, which the library judges
by the outer SSLException and won't retry at all; what it does retry (a 5xx,
a plain connection reset) it has already re-sent inside the session. That
leaves one retry layer per failure, as in ruby, where the client library is
configured with no retries, with_network_retry re-sends the same socket
errors, and the Error a 5xx becomes is not one of the three classes
with_job_retry rescues.

The jobs.get after a 409 keeps retrying both: it is a few hundred bytes, and
giving up fails a load whose job may have run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
DeflaterOutputStream#close() runs finish(), end() and out.close() in a row
with no finally, so with compression: GZIP a failure writing the gzip trailer
(a full disk, say) left the FileOutputStream open until GC. The writer now
keeps the file it opened and closes it after closing the stream, whatever
that did. The comment claiming the stream closed the file on its own was
wrong for the GZIP case.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The writer whose close() throws stayed in BigqueryUtil.getFileWriters() under
key -1 after the test, where only this class's @before clear kept it from
failing an unrelated test that calls transaction() later in the same JVM. The
test now removes it itself, through a helper that registers it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The line above assigns getIntermediateFiles(), which collects to a list and
never returns null; the check is a leftover from the version that read the
paths from the task.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
open() creates the FileOutputStream before the wrappers over it, and with
compression: GZIP the GZIPOutputStream constructor writes the gzip header and
can fail, which leaves the file open with no stream over it. close() returned
at its `os == null` guard in that state, so the descriptor stayed open until
GC. It now closes the file there too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Relying on BigQueryOptions.setLocation() for the created jobs was wrong:
google-cloud-bigquery 2.14.0 applies the options-level location in jobs.get,
jobs.cancel and getQueryResults only, never in create(JobInfo), which is what
both bigquery.create() and query() insert through. The copy, merge,
before_load/after_load and INFORMATION_SCHEMA jobs therefore went out with no
jobReference.location, leaving BigQuery to infer it from the datasets a
statement happens to reference and to default to US when it cannot. They now
carry the configured location, as every job ruby inserts does, and the
options-level one stays for the lookups it does cover.

Resolving the job location also moved inside the retried lookup after a 409:
when `location` isn't configured it asks the dataset, and a failure there used
to escape as a bare dataset error, losing the one thing worth saying, that the
load job may have run. It is answered once now, null included, so the warning
that follows costs no second request.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The cleanup's forEach stopped at the first writer whose close() threw, so the
writers after it kept their buffers unflushed and their files open while the
run went on to delete those files. Each close() is now guarded and logged, so
the cleanup reaches them all, and a failure here no longer replaces the one
that is bringing the run down. The data phase closes the writers first and
still lets a failure there end the run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…er it

An exception from the cleanup in the finally replaced the one propagating out
of the try, so a run that failed on `ABORT: num_input_rows ... does not match`
was reported as a temp table that could not be deleted, and the row count
mismatch was lost: nothing logs it, it is only thrown. The cleanup now
collects what its steps throw and attaches it to the failure that is already
ending the run; with no such failure the first one still becomes the run's, as
an unfinished cleanup did before. Every step runs either way, as the nested
finally used to ensure.

ruby has the same overwrite in its nested ensure. This goes one step further
on purpose, like the rest of the cleanup here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Widening the expected type of testLoadFailsOnReadTimeoutDuringUpload left
nothing asserting that a read timeout, which reaches the client as a bare
SocketTimeoutException rather than wrapped in an SSLException, is retried by
the library inside the session and not re-sent on top of that.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Narrowing the resend to what the library never retries left no way to reach
the 409 that says the job exists: only a resend under the same job id is
refused as a duplicate, and for a failure the library does retry, the resend
is now the library's own, inside the session. When it uses up its attempts
(or the total timeout fires) on an attempt that did create the job and only
lost its response, the load used to be rescued by the next session's 409 and
now failed instead, so a rerun loaded the rows a second time. append_direct
and append have no temp table to hide that.

Before giving up, the upload now asks for its job id once, and waits for the
job if BigQuery has it. One request, where the rescue it replaces re-sent the
whole file. Only for failures the library retries itself: anything it refused
outright never got an answer that could have created a job.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
closeQuietly() swallowed Error as well as RuntimeException, so the writer
step was the one cleanup step that could never report a failure, and an
OutOfMemoryError there became a WARN. An Error now reaches cleanUpStep(),
which treats it like the other steps' failures.

Also fold the test's two broken-writer helpers into one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
cleanUpStep() and reportCleanupFailures() collected failures from all three
cleanup steps, but only the temp table delete (a BigQuery call) can fail for
real: the writers' close is quiet and the file delete doesn't throw. The
nested finally is back, with one catch around the delete that attaches its
failure to the one already ending the run, or throws it when there is none.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Instead of close() covering an open() that created the file and then failed
on the gzip header, open() closes the file and forgets it on that path, so
close() has nothing to do when os was never set. The test that staged the
half-open state goes with it: the state can no longer arise, and the header
write can't be made to fail without a seam.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The reason (the library retries inside the session but judges only the outer
exception, so a socket error under TLS is the one thing left to re-send) was
repeated at buildUploadRetryExecutor(), hasSocketErrorCause(), upload()'s
catch and the lookup's call(); it now lives at shouldResendUpload() and the
others point there. The two warnings around a refused or left-behind job are
one string each.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The immediate retry, the job a lost response leaves behind, and "keep the
failure that is ending the run" were each still said twice.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
theLibraryGaveUp() asked only about failures the library retries, which by
construction excludes the socket error under TLS that upload() re-sends for:
with retries: 0, or when every resend dies the same way before the last
chunk, no 409 came and no lookup ran either, so a load whose job had been
created failed and the rerun loaded its rows again. The question is really
whether the failure could have lost an answer, which is what the lookup's own
retry predicate already asks, so both now share it.

A lookup that gave up asked that question already; saying so with its own
exception type stops the upload asking a second time on its way out, which
spent the lookup budget again and ended in a warning contradicting the
duplicate-refusal error it was about to report.

A cancellation during the lookup no longer comes out as a warning with the
interrupt flag cleared, and the warning carries the exception rather than its
message.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Narrowing closeQuietly() to RuntimeException left an Error aborting the
cleanup's pass over the writers, so the ones after it stayed open and
unflushed while the step that follows deleted their files. The cleanup now
closes every writer and raises the first Error afterwards, rather than
swallowing it as it did before or stopping at it.

A temp table that can't be deleted is also logged now, not only attached to
the failure that is ending the run: whether a suppressed exception is ever
shown is up to the runner, and the table is billed until someone drops it.
The attach skips an exception that is the failure itself, which addSuppressed
refuses.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…un's

closeWriters() raised the first Error it met from inside the finally, which
discards the exception already on its way out: a run that failed on a record
that could not be converted would report the Error instead, and the record is
only ever named there. The delete step right after it was careful about this
and the close step was not; both now go through report(), which raises a
cleanup failure only when the run was otherwise fine and attaches it to the
failure otherwise. A RuntimeException from a close is placed that way too
rather than only logged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
mayHaveLeftAJob() judged the final exception alone, so an attempt that lost
the answer to its last chunk went unnoticed when a later attempt failed some
other way: a socket error under TLS creates the job and is re-sent, the next
attempt's session-start is refused with a 403, and the load fails while that
job runs, which a rerun then loads again. upload() now remembers that an
attempt ended in a lost answer and asks for the job whatever the last one
did.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@chikamura
chikamura requested a review from john-abre October 16, 2026 03:32
@chikamura

Copy link
Copy Markdown
Contributor Author

@john-abre
Thanks for the second round. Addressed as follows.

GZIP close leak
Fixed (2095446, f57540d). The writer keeps the FileOutputStream it opened and closes it after closing the stream, whatever that did; open() releases it itself when wrapping it fails. The comment claiming the stream closed the file on its own was wrong for GZIP.

Retry multiplication
Fixed (d4516d2). upload() re-sends the file only for what the library never retries (a socket error under TLS); what it does retry it has already re-sent inside the session. Since that removes the resend that used to run into the 409, the upload now asks for its job id once after a transient failure and waits for the job if it exists (db6021a, 32062ec, 312ffac).

Exception overwrite in finally
Fixed (6413745, 2dc15d4, c8be5cd, f78dbcf, 6135929). A cleanup failure (a writer that can't be closed, a temp table that can't be deleted) is attached to the run's own failure as suppressed, and logged; with no such failure it still fails the run. Every writer is closed even if one throws.

Static map in the test
Fixed (01485d0). The test removes the writer in a finally.

Dead null check
Removed (5c76b93).

File-writer map not cleared between transactions
Not in this PR. ruby has the same shape (@file_writers is never reset in transaction either). The NPE part is the load() null for a missing file, which ruby-compat/14 turns into ruby's 0-row report; the map clear and writer reset will come in a separate PR with a test that runs two transactions in one JVM.

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.

2 participants