Repository navigation
Conversation
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>
john-abre
left a comment
There was a problem hiding this comment.
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)で確認済みです。
修正必須(インラインコメント参照)
- 実際のネットワーク障害はリトライされません。 新しい
catch (IOException)は、書き込みチャネルが投げるBigQueryException(RuntimeException)を捕まえられないため、接続リセットなどは 1 回目で即 give up になります。 - リトライで行が二重ロードされる可能性があります。 失敗後も 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) { |
There was a problem hiding this comment.
修正必須: この 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)) { | |||
There was a problem hiding this comment.
修正必須: アップロード途中で失敗しても、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); | |||
There was a problem hiding this comment.
上のコメントに関連: jobId が call() の中で生成されているため、リトライのたびに新しいジョブになります。ruby は load() 1 回につき job_id を 1 つだけ生成し、リトライ間で使い回しています。リトライを残すなら、ここをループの外に出すのが ruby との整合という意味でも正しい形だと思います。
| destinationDataset, | ||
| table, | ||
| e.getMessage()); | ||
| logger.error(msg, e); |
There was a problem hiding this comment.
軽微: ここでリトライ本体の中から毎回フルスタックトレース付きで 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(); |
There was a problem hiding this comment.
修正推奨: 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); |
There was a problem hiding this comment.
修正推奨: 素の 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; |
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
rebase 時の注意: この fixture で動く 2 つのリトライテストは実際の RetryExecutor(初期待機 2 秒)を回すので、./gradlew test 全体に約 4 秒の実スリープが加わります。親 issue の着手順では #113 の後に rebase する想定なので、その際に load() のインラインの RetryExecutor を #113 の buildJobRetryExecutor() に乗せれば、build.gradle の BIGQUERY_OUTPUT_OPTION_JOB_RETRY_* で待機が 1ms になり解消します。この PR 単体で直す必要はありません。
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>
There was a problem hiding this comment.
ありがとうございます。変換エラーを握りつぶさずに即座に失敗させる方針は正しく、同一 ID でのアップロードリトライも ruby の with_network_retry と一致しています。マージ前に修正が必要な点が 2 つあり、どちらもこの PR によって新たに表面化するデータ整合性の問題です。それ以外は任意です。
要修正
-
失敗した実行で中途半端な中間ファイルが残る。
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が有効ならそのファイルを削除する。 -
同一ジョブ ID でのリトライがリトライ不可の 409 で失敗し得る。
uploadOnceではstream.close()が最後のチャンクを送信し、BigQuery がセッションを確定してembulk_load_job_<uuid>を作成します。そのリクエストのレスポンスが失われた場合(connection reset)、例外はリトライ可能と判定されるためupload()は同じ ID で再送します。2 回目の確定では 409Already 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
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>
…/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>
…at/test-infra-request-cursor
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>
There was a problem hiding this comment.
高精度レビュー(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()); |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
[重要度: 中] 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() { |
There was a problem hiding this comment.
[重要度: 中 / 効率] ライブラリのリトライと 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); |
There was a problem hiding this comment.
[重要度: 低〜中] 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); |
There was a problem hiding this comment.
[重要度: 低 / テスト] 常に例外を投げる 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) { |
There was a problem hiding this comment.
[重要度: 低 / 簡素化] if (paths != null) は常に真になった
直前で paths == null のときに getIntermediateFiles(...) を代入しており、この関数は Collectors.toList() の結果を返すので null を返しません。旧実装(task 版)の名残の null チェックなので、外して一段ネストを減らせます。
| if (ex.getCause() instanceof BigqueryException) { | ||
| throw (BigqueryException) ex.getCause(); | ||
| } | ||
| throw new RuntimeException(ex); |
There was a problem hiding this comment.
[重要度: 低 / 重複] RetryGiveupException / InterruptedException の展開処理が 6 箇所目になっている
load()・copy()・runQuery()・executeQuery() 等と同じ「cause が BigqueryException ならそれを投げ直し、そうでなければ RuntimeException に包み、InterruptedException は BigqueryException("interrupted")」というブロックがここにも複製されています。private <T> T runWithRetry(RetryExecutor, Retryable<T>) のようなヘルパーに寄せると、今回のように onGiveup で lastException を投げ直す流儀も一箇所で揃えられます。
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>
|
@john-abre GZIP close leak Retry multiplication Exception overwrite in finally Static map in the test Dead null check File-writer map not cleared between transactions |
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
IOExceptionwas logged at INFO and ignored.Data phase
BigqueryPageOutput#add()andBigqueryFileWriter#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 globbingpath_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())with_network_retry, so a retry never creates a second job.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 byretries.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 alocationthat does not match the dataset's is rejected by BigQuery where before it was ignored.Differences from ruby, on purpose
Follow-up (separate PR)
BigqueryUtil.getFileWriters()between twotransaction()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