<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en">
    <title>/var/log/laughingman7743.log</title>
    <subtitle>A personal blog about software and other interests.</subtitle>
    <link rel="self" type="application/atom+xml" href="https://blog.laughingman7743.org/atom.xml"/>
    <link rel="alternate" type="text/html" href="https://blog.laughingman7743.org"/>
    <generator uri="https://www.getzola.org/">Zola</generator>
    <updated>2026-09-27T00:00:00+00:00</updated>
    <id>https://blog.laughingman7743.org/atom.xml</id>
    <entry xml:lang="en">
        <title>Releasing flink-connector-gcp 1.1.0: Bigtable write modes, checkpointed sinks, and lineage</title>
        <published>2026-09-26T23:47:14+09:00</published>
        <updated>2026-09-26T23:47:14+09:00</updated>
        
        <author>
          <name>Unknown</name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://blog.laughingman7743.org/posts/flink-connector-gcp-1-1-0/"/>
        <id>https://blog.laughingman7743.org/posts/flink-connector-gcp-1-1-0/</id>
        
        <content type="html" xml:base="https://blog.laughingman7743.org/posts/flink-connector-gcp-1-1-0/">&lt;p&gt;I have released &lt;strong&gt;flink-connector-gcp 1.1.0&lt;/strong&gt;. This release adds several ways to write Bigtable rows, experimental checkpoint-owned delivery for Bigtable and Cloud Tasks, and FLIP-314 lineage metadata across all five connectors. It also extends BigQuery and Spanner filter pushdown and fixes several source and sink edge cases.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;external&quot; href=&quot;https://github.com/flink-gcp/flink-connector-gcp/releases/tag/v1.1.0&quot;&gt;Release and SQL connector downloads&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;external&quot; href=&quot;https://flink-gcp.github.io/flink-connector-gcp/1.1/&quot;&gt;Version 1.1 documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;external&quot; href=&quot;https://github.com/flink-gcp/flink-connector-gcp/tree/v1.1.0&quot;&gt;Source&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The Maven coordinates remain under &lt;code&gt;io.github.flink-gcp&lt;/code&gt;. Use version &lt;code&gt;1.1.0&lt;/code&gt; with Flink 2.2 or 2.3, and &lt;code&gt;1.1.0-1.20&lt;/code&gt; with Flink 1.20. For SQL jobs, install the matching &lt;code&gt;flink-sql-connector-gcp-*&lt;/code&gt; jar from the release downloads in Flink&#39;s &lt;code&gt;lib/&lt;/code&gt; directory. The supported Flink versions have not changed since 1.0.0. No public API or configuration option was removed or renamed, and the new write and delivery modes are opt-in. An existing job therefore keeps its 1.0.0 behavior when its connector dependency is upgraded.&lt;/p&gt;
&lt;h2 id=&quot;bigtable-writes-beyond-upsert&quot;&gt;Bigtable writes beyond upsert&lt;/h2&gt;
&lt;p&gt;Bigtable&#39;s Table API sink now selects its operation through &lt;code&gt;sink.write-mode&lt;/code&gt;. The default remains &lt;code&gt;upsert&lt;/code&gt;. The new choices select different operations for each input row:&lt;/p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Mode&lt;/th&gt;&lt;th&gt;Operation&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;insert-if-absent&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Insert cells atomically only if the row has no stored cells.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;keep-latest&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Delete all versions of each targeted cell and write its replacement in one row operation.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;append&lt;/code&gt; and &lt;code&gt;increment&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Apply read-modify-write rules to the latest stored cell values.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;aggregate&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Contribute values to typed INT64 SUM, MIN, MAX, or HLL aggregate families.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;conditional&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Use a DDL-defined predicate and mutation branches for &lt;code&gt;CheckAndMutateRow&lt;/code&gt;.&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;The &lt;code&gt;conditional&lt;/code&gt; mode needs the most configuration because the DDL defines a complete row command. This example changes a user&#39;s status only if the latest stored status is &lt;code&gt;pending&lt;/code&gt;. On a match it also adds one to an aggregate counter; on a mismatch it writes an audit reason.&lt;/p&gt;
&lt;pre class=&quot;giallo z-l-code z-d-code&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;CREATE&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; TABLE&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt; conditional_updates&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  row_key STRING,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  expected_status BYTES,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  new_status BYTES,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  activation_delta &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;BIGINT&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  mismatch_reason BYTES&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;) &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;WITH&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;connector&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;bigtable&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;project&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;my-project&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;instance&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;my-instance&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;table&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;users&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;sink.app-profile-id&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;single-cluster&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;sink.write-mode&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;conditional&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;sink.conditional.row-key-column&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;row_key&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;sink.conditional.predicate&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;latest-cell-value-equals&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;sink.conditional.predicate.family&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;profile&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;sink.conditional.predicate.qualifier&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;status&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;sink.conditional.predicate.value-column&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;expected_status&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;sink.conditional.then.0.operation&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;set-cell&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;sink.conditional.then.0.family&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;profile&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;sink.conditional.then.0.qualifier&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;status&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;sink.conditional.then.0.value-column&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;new_status&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;sink.conditional.then.1.operation&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;add-to-cell&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;sink.conditional.then.1.family&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;stats&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;sink.conditional.then.1.qualifier&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;activated&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;sink.conditional.then.1.timestamp-micros&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;sink.conditional.then.1.value-column&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;activation_delta&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;sink.conditional.otherwise.0.operation&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;set-cell&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;sink.conditional.otherwise.0.family&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;audit&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;sink.conditional.otherwise.0.qualifier&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;reason&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;sink.conditional.otherwise.0.value-column&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;mismatch_reason&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;INSERT INTO&lt;/span&gt;&lt;span&gt; conditional_updates &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;VALUES&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;u1&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;  CAST&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;pending&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; AS&lt;/span&gt;&lt;span&gt; BYTES),&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;  CAST&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;active&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; AS&lt;/span&gt;&lt;span&gt; BYTES),&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;  1&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;  CAST&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;status mismatch&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; AS&lt;/span&gt;&lt;span&gt; BYTES)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is a write-only &lt;strong&gt;command-input table&lt;/strong&gt;. Its five physical columns supply arguments to the command; they are not a description of the stored Bigtable row. &lt;code&gt;sink.conditional.row-key-column&lt;/code&gt; selects the input column containing the row key, and each &lt;code&gt;value-column&lt;/code&gt; names another top-level input column exactly. The &lt;code&gt;CAST(... AS BYTES)&lt;/code&gt; expressions supply the byte values compared with or written to Bigtable.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;predicate&lt;/code&gt; settings compare the latest &lt;code&gt;profile:status&lt;/code&gt; cell with &lt;code&gt;expected_status&lt;/code&gt;. If they match, &lt;code&gt;then.0&lt;/code&gt; writes &lt;code&gt;new_status&lt;/code&gt; to that cell and &lt;code&gt;then.1&lt;/code&gt; adds &lt;code&gt;activation_delta&lt;/code&gt; to &lt;code&gt;stats:activated&lt;/code&gt;. The numeric branch indexes set the order of those operations. If the predicate misses, &lt;code&gt;otherwise.0&lt;/code&gt; writes &lt;code&gt;mismatch_reason&lt;/code&gt; to &lt;code&gt;audit:reason&lt;/code&gt; instead. The explicit timestamp &lt;code&gt;0&lt;/code&gt; is required for &lt;code&gt;add-to-cell&lt;/code&gt; and addresses the aggregate cell at that timestamp.&lt;/p&gt;
&lt;p&gt;Create the &lt;code&gt;profile&lt;/code&gt; and &lt;code&gt;audit&lt;/code&gt; families and an INT64 SUM &lt;code&gt;stats&lt;/code&gt; family before running the statement. The application profile must use single-cluster routing with single-row transactions enabled. All referenced inputs must be non-null, including the value used by the branch the service does not select. The mode accepts insert-only input and has at-least-once delivery: recovery may repeat an applied request and select a different branch. The sink discards the Boolean match result. A query that needs it can use &lt;code&gt;BigtableCheckAndMutateFunction&lt;/code&gt; on Flink 2.x, with a separate named-request configuration.&lt;/p&gt;
&lt;p&gt;The other modes cover simpler cases. &lt;code&gt;insert-if-absent&lt;/code&gt; checks whether the entire row has any cell before inserting. &lt;code&gt;keep-latest&lt;/code&gt; replaces the versions of the targeted cells immediately, whereas a one-version garbage-collection rule removes old versions asynchronously. &lt;code&gt;append&lt;/code&gt; and &lt;code&gt;increment&lt;/code&gt; operate on raw cells and can repeat their effect after replay. For counters stored in Bigtable aggregate families, &lt;code&gt;aggregate&lt;/code&gt; exposes SUM, MIN, MAX, and HLL contributions through SQL. The &lt;a rel=&quot;external&quot; href=&quot;https://flink-gcp.github.io/flink-connector-gcp/1.1/docs/connectors/table/bigtable/&quot;&gt;Bigtable Table reference&lt;/a&gt; has complete, planner-checked examples for every mode and their type and replay rules.&lt;/p&gt;
&lt;p&gt;DataStream jobs can also send &lt;code&gt;CheckAndMutateRow&lt;/code&gt; and &lt;code&gt;ReadModifyWriteRow&lt;/code&gt; requests through the new single-row request sinks. When a SQL query needs the outcome of a conditional write or the cells returned by a read-modify-write request, Flink 2.x has two new asynchronous functions: &lt;code&gt;BigtableCheckAndMutateFunction&lt;/code&gt; and &lt;code&gt;BigtableReadModifyWriteFunction&lt;/code&gt;. These functions are unavailable in the &lt;code&gt;-1.20&lt;/code&gt; artifacts; the &lt;a rel=&quot;external&quot; href=&quot;https://flink-gcp.github.io/flink-connector-gcp/1.1/docs/connectors/table/bigtable-functions/&quot;&gt;async SQL function guide&lt;/a&gt; shows their registration and result types.&lt;/p&gt;
&lt;h2 id=&quot;checkpoint-owned-bigtable-delivery&quot;&gt;Checkpoint-owned Bigtable delivery&lt;/h2&gt;
&lt;p&gt;Bigtable now has an &lt;strong&gt;experimental &lt;code&gt;EXACTLY_ONCE&lt;/code&gt; delivery mode&lt;/strong&gt;. Set &lt;code&gt;sink.delivery-guarantee = &#39;exactly-once&#39;&lt;/code&gt; for a Table sink using &lt;code&gt;upsert&lt;/code&gt;, &lt;code&gt;keep-latest&lt;/code&gt;, or &lt;code&gt;aggregate&lt;/code&gt;. The other new write modes reject that setting. The equivalent DataStream choice is &lt;code&gt;BigtableDeliveryGuarantee.EXACTLY_ONCE&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The writer holds mutations until their owning Flink checkpoint completes. A committer then applies each mutation with a retained marker that prevents a restored checkpoint from applying that mutation again. This matters for aggregate contributions: a stable cell timestamp alone does not prevent a replayed SUM contribution from being added twice. Recover from the latest completed checkpoint with all committer state; if a stop-with-savepoint produced a savepoint, follow the guide&#39;s rule for restoring it. Discarding that state after the source has checkpointed records but before their staged mutations reach Bigtable can lose those writes.&lt;/p&gt;
&lt;p&gt;The delay is part of the mode&#39;s cost. A row becomes readable no earlier than one checkpoint interval later, followed by the time needed to commit the staged writes. Each staged mutation uses a conditional request instead of sharing a bulk &lt;code&gt;MutateRows&lt;/code&gt; request, so the drain costs more per row than the default sink. The &lt;a rel=&quot;external&quot; href=&quot;https://flink-gcp.github.io/flink-connector-gcp/1.1/docs/connectors/datastream/bigtable/#checkpoint-owned-writes&quot;&gt;checkpoint-owned delivery guide&lt;/a&gt; describes the required marker family, transactional routing, recovery conditions, and measured latency. The mode ships as experimental; the assessment did not establish a supported workload for its performance gate.&lt;/p&gt;
&lt;h2 id=&quot;checkpointed-cloud-tasks-creation&quot;&gt;Checkpointed Cloud Tasks creation&lt;/h2&gt;
&lt;p&gt;Cloud Tasks has a separate &lt;strong&gt;experimental &lt;code&gt;EXACTLY_ONCE&lt;/code&gt; task-creation mode&lt;/strong&gt;. It checkpoints a named task before calling &lt;code&gt;CreateTask&lt;/code&gt; and reuses the same name and serialized task on recovery. Within the documented recovery window, this protects creation of each staged envelope against a replay. The default remains at-least-once creation.&lt;/p&gt;
&lt;p&gt;The Table API selects the mode through &lt;code&gt;sink.delivery-guarantee&lt;/code&gt;:&lt;/p&gt;
&lt;pre class=&quot;giallo z-l-code z-d-code&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;SET&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;execution.runtime-mode&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;STREAMING&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;SET&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;execution.checkpointing.interval&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;1 s&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;SET&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;execution.checkpointing.mode&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;EXACTLY_ONCE&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;SET&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;execution.checkpointing.checkpoints-after-tasks-finish&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;true&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;SET&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;execution.checkpointing.storage&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;filesystem&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;SET&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;execution.checkpointing.dir&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;file:///shared/flink/checkpoints/cloudtasks&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;SET&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;execution.checkpointing.externalized-checkpoint-retention&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;RETAIN_ON_CANCELLATION&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;SET&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;execution.checkpointing.timeout&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;5 min&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;SET&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;restart-strategy.type&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;fixed-delay&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;SET&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;restart-strategy.fixed-delay.attempts&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;3&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;SET&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;restart-strategy.fixed-delay.delay&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;1 s&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;CREATE&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; TABLE&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt; generated_tasks&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  payload STRING&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;) &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;WITH&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;connector&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;datagen&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;rows-per-second&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;10&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;CREATE&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; TABLE&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt; checkpointed_tasks&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  payload STRING&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;) &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;WITH&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;connector&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;cloud-tasks&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;project&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;my-project&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;location&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;asia-northeast1&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;queue&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;webhooks&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;http.url&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;https://api.example.com/tasks&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;format&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;json&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;sink.delivery-guarantee&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;exactly-once&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;sink.staged.max-tasks&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;1000&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;INSERT INTO&lt;/span&gt;&lt;span&gt; checkpointed_tasks &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; payload &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; generated_tasks;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Replace the sample endpoint and checkpoint directory, provision the queue, and use a durable checkpoint filesystem shared by the job&#39;s processes. The queue must retain task names through the configured recovery window; the creating identity also needs permission to read queue settings for the default retention check. The synthetic &lt;code&gt;datagen&lt;/code&gt; input shows the configuration, not event deduplication. The &lt;a rel=&quot;external&quot; href=&quot;https://flink-gcp.github.io/flink-connector-gcp/1.1/docs/connectors/table/cloudtasks/#checkpointed-task-creation&quot;&gt;Cloud Tasks checkpointed-creation guide&lt;/a&gt; covers the recovery prerequisites and the matching DataStream options. Recovery must retain the latest checkpoint and the sink&#39;s mapped committer state. Dropping that state, including by restoring with &lt;code&gt;allowNonRestoredState&lt;/code&gt;, can lose checkpointed tasks that have not yet been created.&lt;/p&gt;
&lt;p&gt;This mode protects &lt;strong&gt;task creation&lt;/strong&gt;, not handler execution. Cloud Tasks can dispatch a created task more than once, so the handler still needs an idempotent operation or its own deduplication record. Tasks become visible after their owning checkpoint completes; the measured DataStream runs kept up at the rates tested but added most of a checkpoint interval to visibility latency. If records already have stable task IDs, the default writer can collapse duplicate creations within the queue&#39;s name-retention window without waiting for a checkpoint.&lt;/p&gt;
&lt;h2 id=&quot;lineage-across-the-connectors&quot;&gt;Lineage across the connectors&lt;/h2&gt;
&lt;p&gt;All five connectors now report configured Google Cloud resources through Flink&#39;s FLIP-314 lineage metadata. For example, a BigQuery sink can identify a configured table, a Pub/Sub source can identify its subscriptions, and a Cloud Tasks sink can identify a fixed queue. These identities describe resources known when the job graph is built; a per-record destination chosen later cannot be inferred from that graph.&lt;/p&gt;
&lt;p&gt;On Flink 2.2 and 2.3, a configured job-status listener can receive the metadata. On Flink 1.20, the vertices can be inspected directly but are not delivered to listeners automatically. The connectors provide resource metadata; an exporter or lineage service is a separate component. The &lt;a rel=&quot;external&quot; href=&quot;https://flink-gcp.github.io/flink-connector-gcp/1.1/docs/connectors/lineage/&quot;&gt;lineage guide&lt;/a&gt; lists the resource identities and coverage limits.&lt;/p&gt;
&lt;h2 id=&quot;other-changes&quot;&gt;Other changes&lt;/h2&gt;
&lt;p&gt;BigQuery&#39;s Table source pushes more bounded-scan filters to the service: binary values, &lt;code&gt;TIME&lt;/code&gt; with precision 0–3, and &lt;code&gt;TIMESTAMP&lt;/code&gt; or &lt;code&gt;TIMESTAMP_LTZ&lt;/code&gt; with precision 0–5. Deeply nested predicates no longer risk a stack overflow during filter handling. Spanner bounded reads now push ordered &lt;code&gt;FLOAT64&lt;/code&gt;/&lt;code&gt;FLOAT8&lt;/code&gt; key predicates down to the service.&lt;/p&gt;
&lt;p&gt;Bigtable Change Streams selected-cell mode now accepts full-column deletes. Bigtable&#39;s async function now completes a result when its timeout fires between retry attempts, and Bigtable and Spanner model constructors accept immutable lists. Spanner&#39;s &lt;code&gt;ChildPartitionsEvent&lt;/code&gt; also reports a null child as a clear argument error.&lt;/p&gt;
&lt;p&gt;The documentation site now keeps separate &lt;a rel=&quot;external&quot; href=&quot;https://flink-gcp.github.io/flink-connector-gcp/1.1/&quot;&gt;1.1&lt;/a&gt; and &lt;a rel=&quot;external&quot; href=&quot;https://flink-gcp.github.io/flink-connector-gcp/1.0/&quot;&gt;1.0&lt;/a&gt; pages alongside Development. Use the 1.1 pages for the code in this release: Development follows &lt;code&gt;main&lt;/code&gt; and can describe features that have not shipped. The &lt;a rel=&quot;external&quot; href=&quot;https://github.com/flink-gcp/flink-connector-gcp/releases/tag/v1.1.0&quot;&gt;release notes&lt;/a&gt; have the complete highlight list and downloads.&lt;/p&gt;
&lt;p&gt;This is an independent open-source project. It is not affiliated with, endorsed by, or supported by the Apache Software Foundation or Google. Apache Flink, Flink, and the Flink logo are trademarks of the Apache Software Foundation.&lt;/p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>The Cloud Tasks connector in flink-connector-gcp, part five of the connector series</title>
        <published>2026-09-06T12:31:17+09:00</published>
        <updated>2026-09-06T12:31:17+09:00</updated>
        
        <author>
          <name>Unknown</name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://blog.laughingman7743.org/posts/flink-connector-gcp-cloud-tasks/"/>
        <id>https://blog.laughingman7743.org/posts/flink-connector-gcp-cloud-tasks/</id>
        
        <content type="html" xml:base="https://blog.laughingman7743.org/posts/flink-connector-gcp-cloud-tasks/">&lt;p&gt;This is the final post in the &lt;a rel=&quot;external&quot; href=&quot;https://github.com/flink-gcp/flink-connector-gcp&quot;&gt;flink-connector-gcp&lt;/a&gt; series, about the Apache Flink connectors for Google Cloud I released as 1.0.0. &lt;a rel=&quot;external&quot; href=&quot;https://blog.laughingman7743.org/posts/flink-connector-gcp-1-0-0/&quot;&gt;The release post&lt;/a&gt; introduces the project; the previous posts cover &lt;a rel=&quot;external&quot; href=&quot;https://blog.laughingman7743.org/posts/flink-connector-gcp-bigquery/&quot;&gt;BigQuery&lt;/a&gt;, &lt;a rel=&quot;external&quot; href=&quot;https://blog.laughingman7743.org/posts/flink-connector-gcp-pubsub/&quot;&gt;Pub/Sub&lt;/a&gt;, &lt;a rel=&quot;external&quot; href=&quot;https://blog.laughingman7743.org/posts/flink-connector-gcp-spanner/&quot;&gt;Spanner&lt;/a&gt; and &lt;a rel=&quot;external&quot; href=&quot;https://blog.laughingman7743.org/posts/flink-connector-gcp-bigtable/&quot;&gt;Bigtable&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;This post uses Cloud Tasks to turn enriched orders into rate-limited calls to a partner API. It covers request construction in SQL, task-id deduplication, authentication, body formats, queue sharding, failure handling and tuning, as well as the exactly-once mode planned for v1.1.0.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Documentation: &lt;a rel=&quot;external&quot; href=&quot;https://flink-gcp.github.io/flink-connector-gcp/docs/connectors/datastream/cloudtasks/&quot;&gt;https://flink-gcp.github.io/flink-connector-gcp/docs/connectors/datastream/cloudtasks/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Maven: &lt;code&gt;io.github.flink-gcp:flink-connector-gcp-cloudtasks:1.0.0&lt;/code&gt; (&lt;code&gt;1.0.0-1.20&lt;/code&gt; for Flink 1.20); for the SQL client, the &lt;code&gt;flink-sql-connector-gcp-cloudtasks&lt;/code&gt; uber-jar from the &lt;a rel=&quot;external&quot; href=&quot;https://github.com/flink-gcp/flink-connector-gcp/releases/tag/v1.0.0&quot;&gt;release page&lt;/a&gt; goes into &lt;code&gt;lib/&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;what-cloud-tasks-does-that-a-stream-needs&quot;&gt;What Cloud Tasks does that a stream needs&lt;/h2&gt;
&lt;p&gt;Cloud Tasks queues HTTP requests for dispatch. It holds each task until the queue&#39;s pacing allows it, retries responses outside the 2xx range under the queue&#39;s policy, and can schedule tasks up to 30 days ahead. My pipelines use it to deliver work to handlers, so this connector provides a sink. It has no source for reading a queue back.&lt;/p&gt;
&lt;p&gt;I built it for streams from fast sources such as Pub/Sub or Kafka that need to call a slower endpoint, usually a third-party API with a rate limit. Enforcing that limit in Flink requires a stateful throttle; a plain HTTP sink supplies no such control. With Cloud Tasks, the job enqueues records as they arrive and the queue paces requests, retries failures and holds the backlog. The sink therefore has no rate controls of its own. Queue settings determine dispatch speed.&lt;/p&gt;
&lt;p&gt;I found no Flink connector for Cloud Tasks when I started, and still found none on GitHub as of 2026-09-05. Apache Beam&#39;s built-in I/O list also had no Cloud Tasks entry when checked that day. I used this repository&#39;s Pub/Sub sink as the design reference, sharing its serializer boundary and stateless writer that flushes at checkpoints.&lt;/p&gt;
&lt;p&gt;In 1.0.0, the DataStream sink provides at-least-once task creation for HTTP and App Engine targets, fixed or per-record queue routing, opt-in named-task deduplication, and a pluggable failure policy. The SQL sink builds requests from a table: a Flink format serializes the body, while writable metadata supplies the URL, method, headers, schedule time and task id.&lt;/p&gt;
&lt;h2 id=&quot;enriched-orders-into-a-partner-api&quot;&gt;Enriched orders into a partner API&lt;/h2&gt;
&lt;p&gt;Order events arrive over Pub/Sub with a customer id. The SQL job looks up the customer&#39;s partner endpoint and tenant in Bigtable, then creates an HTTP task on a queue paced to the partner API&#39;s limit. The Bigtable post used a fixed target URL. Here each row chooses its URL, method, headers, schedule time and task id.&lt;/p&gt;
&lt;p&gt;This code is copied byte for byte from the &lt;a rel=&quot;external&quot; href=&quot;https://flink-gcp.github.io/flink-connector-gcp/docs/examples/cloudtasks/#pubsub-events-enriched-from-bigtable&quot;&gt;source-backed example&lt;/a&gt;, which CI validates through Flink&#39;s planner:&lt;/p&gt;
&lt;pre class=&quot;giallo z-l-code z-d-code&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;SET&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;execution.checkpointing.interval&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;10 s&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;CREATE&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; TABLE&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt; incoming_orders&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  event_id    STRING,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  customer_id STRING,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  order_id    STRING,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  amount      &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;DECIMAL&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;12&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;2&lt;/span&gt;&lt;span&gt;),&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  dispatch_at TIMESTAMP_LTZ(&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;6&lt;/span&gt;&lt;span&gt;),&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  proc_time &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;AS&lt;/span&gt;&lt;span&gt; PROCTIME&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;) &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;WITH&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;connector&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;pubsub&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;project&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;my-project&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;subscription&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;orders-sub&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;format&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;json&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;CREATE&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; TABLE&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt; customer_routes&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  rowkey  STRING,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  routing &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;ROW&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;endpoint&lt;/span&gt;&lt;span&gt; STRING, tenant STRING&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;&amp;gt;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;  PRIMARY KEY&lt;/span&gt;&lt;span&gt; (rowkey) &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;NOT&lt;/span&gt;&lt;span&gt; ENFORCED&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;) &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;WITH&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;connector&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;bigtable&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;project&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;my-project&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;instance&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;my-instance&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;table&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;customer-routes&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;lookup.async&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;true&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;CREATE&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; TABLE&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt; enriched_order_tasks&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  event_id       STRING,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  order_id       STRING,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  amount         &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;DECIMAL&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;12&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;2&lt;/span&gt;&lt;span&gt;),&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  customer_id    STRING,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  target_url     STRING &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;NOT NULL&lt;/span&gt;&lt;span&gt;    METADATA &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;FROM&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;url&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  request_method STRING             METADATA &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;FROM&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;http-method&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  request_headers MAP&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span&gt;STRING, STRING&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;&amp;gt;&lt;/span&gt;&lt;span&gt; METADATA &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;FROM&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;headers&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  schedule_at    TIMESTAMP_LTZ(&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;6&lt;/span&gt;&lt;span&gt;)   METADATA &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;FROM&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;schedule-time&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  dedupe_key     STRING             METADATA &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;FROM&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;task-id&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;) &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;WITH&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;connector&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;cloud-tasks&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;project&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;my-project&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;location&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;asia-northeast1&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;queue&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;partner-api&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;format&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;json&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;INSERT INTO&lt;/span&gt;&lt;span&gt; enriched_order_tasks&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;SELECT&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt; e&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;event_id&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;       e&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;order_id&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;       e&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;amount&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;       e&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;customer_id&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;       r&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;routing&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;endpoint&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; ||&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;/orders/&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; ||&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt; e&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;order_id&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;       &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;POST&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;       MAP&lt;/span&gt;&lt;span&gt;[&amp;#39;Content-Type&amp;#39;, &amp;#39;application/json&amp;#39;, &amp;#39;X-Tenant&amp;#39;, r.routing.tenant]&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;       e&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;dispatch_at&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;       e&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;event_id&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; incoming_orders &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;AS&lt;/span&gt;&lt;span&gt; e&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;JOIN&lt;/span&gt;&lt;span&gt; customer_routes &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;FOR&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; SYSTEM_TIME&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; AS&lt;/span&gt;&lt;span&gt; OF &lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;e&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;proc_time&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; AS&lt;/span&gt;&lt;span&gt; r&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;  ON&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt; e&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;customer_id&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt; r&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;rowkey&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;WHERE&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt; e&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;event_id&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; IS NOT NULL&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;  AND&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt; e&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;order_id&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; IS NOT NULL&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;  AND&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt; r&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;routing&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;endpoint&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; IS NOT NULL&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;  AND&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt; r&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;routing&lt;/span&gt;&lt;span&gt;.tenant &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;IS NOT NULL&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Physical columns form the request body. The format selected in &lt;code&gt;WITH&lt;/code&gt;, JSON here, receives exactly the four columns from &lt;code&gt;event_id&lt;/code&gt; through &lt;code&gt;customer_id&lt;/code&gt; and produces the body bytes. The connector removes the five &lt;code&gt;METADATA&lt;/code&gt; columns before serialization, so request settings cannot accidentally enter the JSON.&lt;/p&gt;
&lt;p&gt;This separation lets the sink use JSON, CSV, Avro or raw formats with a table-supplied &lt;code&gt;Content-Type&lt;/code&gt;. The form format described later supplies its own content type.&lt;/p&gt;
&lt;p&gt;A non-null row URL, method or header value overrides its fixed-option counterpart. This table has no &lt;code&gt;http.url&lt;/code&gt;, so &lt;code&gt;target_url&lt;/code&gt; must be &lt;code&gt;STRING NOT NULL&lt;/code&gt;; otherwise planning fails. Every row needs a URL when there is no fixed fallback. Row headers override fixed headers by case-insensitive name.&lt;/p&gt;
&lt;p&gt;Schedule time and task id are metadata-only. &lt;code&gt;schedule_at&lt;/code&gt; sets a time at most 30 days ahead, with null leaving the service default. The query takes it from &lt;code&gt;dispatch_at&lt;/code&gt;, allowing an order due in an hour to be enqueued now without timer state in Flink. The queue&#39;s pacing determines when it is dispatched after that time.&lt;/p&gt;
&lt;p&gt;The Bigtable lookup is the same kind of join as in the previous post. With this inner join, an event without a customer route produces no task. The &lt;code&gt;WHERE&lt;/code&gt; also drops events or route rows missing any of the four required request values. The sink sees neither case, so production jobs should count these rejected inputs separately from successful dispatches.&lt;/p&gt;
&lt;p&gt;The route table is trusted configuration. As the documentation advises, enforce an endpoint allowlist before the insert and validate &lt;code&gt;order_id&lt;/code&gt; as one path segment before concatenating it. The connector forwards URLs without restricting target hosts or paths.&lt;/p&gt;
&lt;p&gt;Checkpointing is required; the example chooses ten seconds. Pub/Sub acknowledges messages when a checkpoint completes. At the barrier, the Cloud Tasks sink waits for every outstanding create, including requests in retry backoff. Under the default failure policy, a completed checkpoint therefore means the service has durably accepted every preceding record except serializer skips.&lt;/p&gt;
&lt;p&gt;Without checkpoints, that flush does not run during the stream, and outstanding creates can be lost on failure. Both ends provide at-least-once delivery, which the lookup join does not strengthen.&lt;/p&gt;
&lt;p&gt;Successful creation means Cloud Tasks has stored the task. Dispatch and handler success are separate: the queue controls them through its retry policy, outside the sink&#39;s view. Handler delivery is at-least-once even when creation is deduplicated. The endpoint still needs an idempotent operation or its own durable business key.&lt;/p&gt;
&lt;h2 id=&quot;deduplicating-task-creation-by-task-id&quot;&gt;Deduplicating task creation by task id&lt;/h2&gt;
&lt;p&gt;Tasks are unnamed by default, letting Cloud Tasks assign their names and create them at full speed. A replay can create a second task and call the endpoint again. For handlers that are already idempotent, I keep this at-least-once default.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;task-id&lt;/code&gt; metadata column, or &lt;code&gt;taskIdExtractor(...)&lt;/code&gt; in DataStream, derives task names from row keys. Once enabled, every row must supply a non-null key. Recreating a name the service still remembers returns &lt;code&gt;ALREADY_EXISTS&lt;/code&gt;. The sink counts this as success in &lt;code&gt;tasksDeduplicated&lt;/code&gt;, so replay sends another create request without creating another task to dispatch.&lt;/p&gt;
&lt;p&gt;The documentation calls this bounded effectively-once task creation. The bound comes from name retention after execution or deletion. Google&#39;s sources disagree on that duration: the REST reference says a released id can take up to 24 hours, while the v2 proto comment says about an hour. The connector advises designing against the shorter window. A scheduled task retains its id for its lifetime plus that window.&lt;/p&gt;
&lt;p&gt;The guarantee covers creation only. Cloud Tasks still delivers the handler at least once.&lt;/p&gt;
&lt;p&gt;A task name identifies an immutable logical task. Cloud Tasks cannot update a created task, and the sink accepts &lt;code&gt;ALREADY_EXISTS&lt;/code&gt; without comparing its payload or schedule. If changed data should create another task, include a content or schedule version in the key. That is the role of &lt;code&gt;-v1&lt;/code&gt; in the documentation&#39;s external-API example.&lt;/p&gt;
&lt;p&gt;The sink uses the key&#39;s SHA-256 digest as the task id. Google&#39;s &lt;code&gt;tasks.create&lt;/code&gt; reference recommends hashed strings and warns that sequential ids, &quot;for example using a timestamp&quot;, increase latency and error rates across task commands. Hashing keeps deterministic deduplication while allowing the job to supply an event id or offset without creating sequential task names. The tradeoff is readability: the business key alone does not reveal the task&#39;s console name.&lt;/p&gt;
&lt;p&gt;Naming is opt-in because of its service-side cost. The same reference says the additional lookup causes &quot;significantly increased latency&quot;, without quantifying it. The project&#39;s benchmark compared deterministic ids with unnamed creation on a paused queue, using the preregistered exactly-once performance gate applied to candidate modes.&lt;/p&gt;
&lt;p&gt;The averages met the general gate, but the run-to-run throughput range was about 11 percent, above the 10 percent limit. The result remains inconclusive. For the current mode, I enable naming when duplicate-call costs justify the measured latency on that queue.&lt;/p&gt;
&lt;p&gt;For v1.1.0, I am planning an opt-in &lt;strong&gt;exactly-once mode&lt;/strong&gt; using Flink&#39;s two-phase commit (&lt;a rel=&quot;external&quot; href=&quot;https://github.com/flink-gcp/flink-connector-gcp/issues/1238&quot;&gt;#1238&lt;/a&gt;). It is not implemented yet. The sink would stage tasks in checkpointed state and create them only after checkpoint completion, preserving each task&#39;s identity and contents for commit retries.&lt;/p&gt;
&lt;p&gt;The guarantee would cover task creation within a verified recovery window; handler delivery would remain at least once. That window needs a safety margin inside a verified minimum name-retention period. Neither the one-hour nor the 24-hour description above establishes that minimum. After the recovery deadline, the job would stop creating tasks and retain pending work in Flink state for operator recovery.&lt;/p&gt;
&lt;p&gt;The initial scope is checkpointed streaming jobs writing to one fixed, pre-provisioned queue. Shipping the mode depends on verifying the service contract and passing recovery and performance tests; the existing at-least-once mode would remain the default.&lt;/p&gt;
&lt;h2 id=&quot;two-identities-behind-an-authenticated-call&quot;&gt;Two identities behind an authenticated call&lt;/h2&gt;
&lt;p&gt;An authenticated pipeline uses two identities. The first calls &lt;code&gt;CreateTask&lt;/code&gt;, using application-default credentials or a service-account key file. With a key file, only the path travels in the job graph; each TaskManager reads it when its writer starts.&lt;/p&gt;
&lt;p&gt;The second identity authenticates the HTTP request when Cloud Tasks later dispatches it. Its token settings are configured on the table or serializer. They do not authenticate Flink or grant permission to enqueue tasks.&lt;/p&gt;
&lt;p&gt;For a Cloud Run service or function protected by IAM, the dispatch token is OIDC. The documentation provides this &lt;a rel=&quot;external&quot; href=&quot;https://flink-gcp.github.io/flink-connector-gcp/docs/examples/cloudtasks/#an-authenticated-cloud-run-function&quot;&gt;source-backed example&lt;/a&gt;:&lt;/p&gt;
&lt;pre class=&quot;giallo z-l-code z-d-code&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;CREATE&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; TABLE&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt; function_tasks&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  order_id     STRING,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  amount       &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;DECIMAL&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;12&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;2&lt;/span&gt;&lt;span&gt;),&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  trace        MAP&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span&gt;STRING, STRING&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;&amp;gt;&lt;/span&gt;&lt;span&gt; METADATA &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;FROM&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;headers&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  schedule_at  TIMESTAMP_LTZ(&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;6&lt;/span&gt;&lt;span&gt;)    METADATA &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;FROM&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;schedule-time&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  dedupe_key   STRING              METADATA &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;FROM&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;task-id&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;) &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;WITH&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;connector&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;cloud-tasks&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;project&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;my-project&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;location&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;asia-northeast1&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;queue&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;functions&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;http.url&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;https://process-order-abc-an.a.run.app/tasks&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;http.method&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;POST&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;http.headers.Content-Type&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;application/json&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;http.oidc.service-account-email&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;    &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;dispatcher@my-project.iam.gserviceaccount.com&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;http.oidc.audience&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;https://process-order-abc-an.a.run.app&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;format&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;json&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;INSERT INTO&lt;/span&gt;&lt;span&gt; function_tasks&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;VALUES&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;o-42&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;  CAST&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;19&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;95&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; AS&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; DECIMAL&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;12&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;2&lt;/span&gt;&lt;span&gt;)),&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  MAP&lt;/span&gt;&lt;span&gt;[&amp;#39;X-Trace-Id&amp;#39;, &amp;#39;trace-42&amp;#39;]&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;  CAST&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;CURRENT_TIMESTAMP &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;+&lt;/span&gt;&lt;span&gt; INTERVAL &lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;5&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; MINUTE&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; AS&lt;/span&gt;&lt;span&gt; TIMESTAMP_LTZ(&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;6&lt;/span&gt;&lt;span&gt;)),&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;order-o-42&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The audience uses the service&#39;s stable root &lt;code&gt;run.app&lt;/code&gt; URL. Without an explicit audience, Cloud Tasks uses the full target URL, including its path. The handler must accept whichever audience is configured.&lt;/p&gt;
&lt;p&gt;The deployment must create the IAM bindings; the connector does not. The task creator needs enqueue permission and &lt;code&gt;iam.serviceAccounts.actAs&lt;/code&gt; on the dispatch service account. That account must belong to the queue&#39;s project and have the target&#39;s invoker role. OIDC settings are fixed table options because SQL exposes no per-row dispatch identity.&lt;/p&gt;
&lt;p&gt;OAuth supplies an access token for Google APIs on &lt;code&gt;*.googleapis.com&lt;/code&gt;, the use Google generally documents for it. OIDC and OAuth occupy one protobuf &lt;code&gt;oneof&lt;/code&gt;, so the builder rejects setting both. A partner endpoint that validates Google-issued tokens can use OIDC. An endpoint using its own credentials can receive them in a row-supplied or fixed header.&lt;/p&gt;
&lt;p&gt;The endpoint must also be reachable. Google documents HTTP targets as endpoints with external IP addresses. One supported exception is Cloud Run with internal ingress: Cloud Tasks can reach it in the same project or VPC Service Controls perimeter through its default &lt;code&gt;run.app&lt;/code&gt; URL. A valid token alone does not establish network reachability.&lt;/p&gt;
&lt;p&gt;Before relying on per-row URLs, check for a queue-level URI override. Its enforcement mode defaults to always, so it can silently replace every task&#39;s URL. The sink&#39;s v2 client cannot read that field and therefore cannot guard against it. The documentation records the interaction; I check the queue configuration before using row URLs.&lt;/p&gt;
&lt;p&gt;The connector also supports App Engine targets, although I do not use this integration in my own pipelines. In SQL, &lt;code&gt;target.type = &#39;app-engine&#39;&lt;/code&gt; selects a relative URI with optional service, version and instance routing. Tasks reach the application through Google&#39;s internal transport, so this target does not accept OIDC or OAuth options. The project&#39;s gated tests validate this support against real App Engine.&lt;/p&gt;
&lt;h2 id=&quot;bodies-the-target-api-expects&quot;&gt;Bodies the target API expects&lt;/h2&gt;
&lt;p&gt;The target API determines the body format. SQL can use any serialization format on the classpath whose schema requirements match the physical columns. The documentation shows the resulting bytes for nested JSON, CSV with configurable delimiters, quoting and null literals, raw single-column data in a chosen charset, and binary Avro. Avro&#39;s writer schema comes from the physical columns and must be shared with the handler.&lt;/p&gt;
&lt;p&gt;The connector passes these bytes through without inspecting them, so the table must set the appropriate &lt;code&gt;Content-Type&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The module also provides &lt;code&gt;form-urlencoded&lt;/code&gt; for &lt;code&gt;application/x-www-form-urlencoded&lt;/code&gt; bodies. It accepts &lt;code&gt;STRING&lt;/code&gt; and &lt;code&gt;ARRAY&amp;lt;STRING&amp;gt;&lt;/code&gt; columns and sets its own &lt;code&gt;Content-Type&lt;/code&gt;. Non-null columns produce fields in physical schema order; array elements produce repeated fields. Null columns and empty arrays add nothing, while a null array element fails the row.&lt;/p&gt;
&lt;p&gt;The format does not invent encodings for numbers, booleans, nested rows or maps. SQL must cast scalars to strings to make the wire representation explicit. Quoted column names such as &lt;code&gt;`items[]`&lt;/code&gt; can express bracket or dotted names expected by the server.&lt;/p&gt;
&lt;p&gt;Dynamic field names, indexed arrays and arbitrary maps require a scalar function to build the complete body and the raw format to pass it through. Sending pre-encoded content through &lt;code&gt;form-urlencoded&lt;/code&gt; would encode it again.&lt;/p&gt;
&lt;p&gt;HTTP targets serialize bodies only for &lt;code&gt;POST&lt;/code&gt;, &lt;code&gt;PUT&lt;/code&gt; and &lt;code&gt;PATCH&lt;/code&gt;; App Engine targets do so only for &lt;code&gt;POST&lt;/code&gt; and &lt;code&gt;PUT&lt;/code&gt;. Other methods skip body serialization. A &lt;code&gt;GET&lt;/code&gt; request therefore takes its query string from the URL. Use the &lt;code&gt;url&lt;/code&gt; metadata column with Flink 2.x&#39;s &lt;code&gt;URL_ENCODE&lt;/code&gt;, or a UDF on 1.20, to escape values before concatenation. The connector does not repair an unescaped query string.&lt;/p&gt;
&lt;p&gt;A DataStream serializer can return a complete &lt;code&gt;Task&lt;/code&gt; proto, including a per-record dispatch deadline and multipart body, neither of which has a SQL representation. The &lt;code&gt;httpTarget(url)&lt;/code&gt; and &lt;code&gt;appEngineTarget(relativeUri)&lt;/code&gt; helpers bind a body &lt;code&gt;SerializationSchema&lt;/code&gt; and provide URL, header and routing resolvers alongside fixed token settings. Per-record authorization requires the full &lt;code&gt;Task&lt;/code&gt; serializer.&lt;/p&gt;
&lt;p&gt;Returning &lt;code&gt;null&lt;/code&gt; from the task serializer skips a record. Nothing is written, and the &lt;code&gt;recordsSkipped&lt;/code&gt; counter reports the skip. The body-serialization helpers cannot skip this way: null from their wrapped Flink serializer is a serialization failure.&lt;/p&gt;
&lt;p&gt;The task serializer must leave the name unset. The sink constructs it from the resolved queue and hashed key, keeping naming on one path.&lt;/p&gt;
&lt;h2 id=&quot;sharding-across-queues-from-the-datastream-api&quot;&gt;Sharding across queues from the DataStream API&lt;/h2&gt;
&lt;p&gt;Each SQL table writes to one fixed queue. DataStream can resolve a queue for each record through the same resolver pattern used for tables and topics in the earlier connectors. Sharding across queues increases aggregate dispatch capacity when one queue&#39;s service limits are insufficient. This &lt;a rel=&quot;external&quot; href=&quot;https://flink-gcp.github.io/flink-connector-gcp/docs/examples/cloudtasks/#sharding-across-queues&quot;&gt;source-backed example&lt;/a&gt; keeps each customer on one queue:&lt;/p&gt;
&lt;pre class=&quot;giallo z-l-code z-d-code&quot; &gt;&lt;code data-lang=&quot;java&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;CloudTasksSink&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span&gt;OrderEvent&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;&amp;gt;&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;builder&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        .&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;destinationResolver&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                (&lt;/span&gt;&lt;span&gt;element&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span&gt; context&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; -&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                        QueueDestination&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;of&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;                                &amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;my-project&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;                                &amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;asia-northeast1&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;                                &amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;webhooks-&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;                                        +&lt;/span&gt;&lt;span&gt; Math&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;floorMod&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                                                element&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;customerId&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;hashCode&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt; 4&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        .&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;serializer&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                CloudTasksSerializationSchema&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;httpTarget&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;                                &amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;https://api.example.com/v1/orders&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                        .&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;withBody&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;new&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt; OrderEventSchema&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        .&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;build&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;One client serves all queues. Routing creates no per-queue client, stream or batcher to cache or evict. A destination includes its location because queues are regional.&lt;/p&gt;
&lt;p&gt;The sink holds no per-queue state except optional per-destination metrics. Those metrics are off by default because Flink cannot unregister them: every queue used would retain counters for the task&#39;s lifetime. Any resolver cache belongs to the resolver, travels in the job graph and should also be bounded.&lt;/p&gt;
&lt;p&gt;Every destination queue must already exist. The sink offers no create disposition because queue settings define the pacing this pipeline needs. Automatic creation would use defaults of 500 dispatches per second and 1,000 concurrent dispatches, potentially exceeding the endpoint&#39;s capacity. A deleted queue name also cannot be reused for three days, making a mistaken creation slow to undo.&lt;/p&gt;
&lt;p&gt;Queues are infrastructure the job references. The quickstart begins by creating one with limits the endpoint can sustain.&lt;/p&gt;
&lt;h2 id=&quot;what-a-failed-task-means&quot;&gt;What a failed task means&lt;/h2&gt;
&lt;p&gt;Three failure types reach &lt;code&gt;failedTaskHandler&lt;/code&gt;: serializer rejection, an exception from the task-id extractor, and a service &lt;code&gt;INVALID_ARGUMENT&lt;/code&gt; response, such as a malformed target, oversized body or rejected header. The default handler fails the job; alternatives can log and drop or forward to a dead-letter queue.&lt;/p&gt;
&lt;p&gt;The shared Pub/Sub dead-letter implementation works unchanged. When serialization succeeded, its message contains the full serialized task. A consumer can call &lt;code&gt;Task.parseFrom&lt;/code&gt; to recover the target, method, headers and authorization settings. A serialization failure produces empty message data. SQL exposes no failure-policy option and fails the job on the first routed failure.&lt;/p&gt;
&lt;p&gt;An exhausted retry budget, missing queue or &lt;code&gt;PERMISSION_DENIED&lt;/code&gt; fails the job without reaching a dropping handler. These failures require backpressure and restart rather than discarding a stream during an outage. A transient status anywhere in the cause chain takes precedence over &lt;code&gt;INVALID_ARGUMENT&lt;/code&gt;, preventing instability from being classified as a dead letter.&lt;/p&gt;
&lt;p&gt;Configuration errors also remain fatal. A null resolver result or empty extracted key would otherwise drop every record while the job appeared healthy. A serializer bug that makes every task invalid can still reach a dropping handler one record at a time. Monitor &lt;code&gt;numRecordsSendErrors&lt;/code&gt;, which counts all tasks delivered to the handler, whether dropped or not.&lt;/p&gt;
&lt;p&gt;A task dropped during creation never entered the queue, so the queue&#39;s retry policy never applied. Once created, a task whose handler fails is retried by Cloud Tasks outside the sink&#39;s view. App Engine has one documented distinction: a handler&#39;s &lt;code&gt;503&lt;/code&gt; throttles the queue, while &lt;code&gt;429&lt;/code&gt; does not trigger congestion control.&lt;/p&gt;
&lt;p&gt;Paused and disabled queues need separate monitoring. Both accept new tasks while stopping dispatch; the paused-queue contract says it &quot;will stop delivering tasks from it, but more tasks can still be added to it&quot;. The sink can therefore appear healthy while the backlog grows.&lt;/p&gt;
&lt;h2 id=&quot;the-knobs-that-matter&quot;&gt;The knobs that matter&lt;/h2&gt;
&lt;p&gt;The writer has few options because dispatch pacing belongs to the queue. Configure &lt;code&gt;maxDispatchesPerSecond&lt;/code&gt;, &lt;code&gt;maxConcurrentDispatches&lt;/code&gt; and the handler retry policy there.&lt;/p&gt;
&lt;p&gt;The sink owns create retries. The generated client sets a 20-second timeout for &lt;code&gt;CreateTask&lt;/code&gt; and no retryable status codes; an unnamed create is non-idempotent. Failed creates are parked with a due time and retried from a subsequent write or flush.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;UNAVAILABLE&lt;/code&gt;, &lt;code&gt;DEADLINE_EXCEEDED&lt;/code&gt; and &lt;code&gt;RESOURCE_EXHAUSTED&lt;/code&gt; use up to eight attempts including the first. Backoff starts at 100 ms, doubles and caps at 10 s. &lt;code&gt;NOT_FOUND&lt;/code&gt; has a separate three-attempt budget with delays from 500 ms to 2 s. Both schedules use up to 25 percent jitter in either direction.&lt;/p&gt;
&lt;p&gt;The shorter &lt;code&gt;NOT_FOUND&lt;/code&gt; budget balances two cases. A queue idle for 30 days can take minutes to reactivate and return &lt;code&gt;NOT_FOUND&lt;/code&gt; meanwhile, so that status alone does not prove the name is wrong. But a mistyped queue should fail without exhausting the longer budget for every record. Reactivation can outlast the short budget; the job&#39;s restart strategy handles that case.&lt;/p&gt;
&lt;p&gt;Retrying &lt;code&gt;DEADLINE_EXCEEDED&lt;/code&gt; can duplicate an unnamed task if the first create succeeded. At-least-once favors that over loss. Named-task deduplication removes this ambiguity within its retention window.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;maxInFlightTasks&lt;/code&gt;, default 1,000, bounds outstanding creates, including parked retries. At the cap, a write yields to the task mailbox until completions free capacity.&lt;/p&gt;
&lt;p&gt;Transport capacity can bind first. The client opens one gRPC channel by default, and one HTTP/2 channel supports about 100 concurrent streams. A subtask therefore runs about 100 concurrent creates regardless of a higher in-flight cap. Measurements on 2026-08-22 against a real paused queue reached about 210 creates per second per subtask with one channel and 1,271 with eight.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;channelPoolSize&lt;/code&gt; allows more channels but defaults to one. Automatically sizing the pool from the in-flight cap could exceed Google&#39;s guidance of roughly 1,000 tasks per second per queue, counting creates and dispatches, as the eight-channel measurement already does. It could also violate the ramp rule of increasing traffic by no more than 50 percent every five minutes.&lt;/p&gt;
&lt;p&gt;Increase the pool deliberately, using roughly one channel per 100 concurrent creates needed. A pipeline paced to a partner API usually does not need more channels unless ingestion bursts alone approach the queue guidance.&lt;/p&gt;
&lt;p&gt;The project evaluated batch creation and declined it in &lt;a rel=&quot;external&quot; href=&quot;https://github.com/flink-gcp/flink-connector-gcp/blob/main/docs/adr/0129-the-cloud-tasks-sink-keeps-one-create-rpc-per-record-and-declines-v2beta3-batchcreatetasks.md&quot;&gt;ADR-0129&lt;/a&gt;. The API exists only in v2beta3 as a long-running, explicitly non-atomic operation accepting up to 100 tasks. On the same measurement day, it was no faster at the median than 100 concurrent single creates and about twice as slow at the tail.&lt;/p&gt;
&lt;p&gt;Its failure semantics ruled it out. A batch containing existing named tasks returns one &lt;code&gt;ALREADY_EXISTS&lt;/code&gt; for the whole batch, without per-task results, while still creating its non-duplicate tasks. A sink cannot map that outcome reliably to per-task results while treating deduplication as success. Creation therefore remains one RPC per record, with the sink managing buffering, backpressure and concurrency.&lt;/p&gt;
&lt;h2 id=&quot;testing-without-an-official-emulator&quot;&gt;Testing without an official emulator&lt;/h2&gt;
&lt;p&gt;Google provides no official Cloud Tasks emulator. Integration tests use the MIT-licensed community &lt;a rel=&quot;external&quot; href=&quot;https://github.com/aertje/cloud-tasks-emulator&quot;&gt;&lt;code&gt;aertje/cloud-tasks-emulator&lt;/code&gt;&lt;/a&gt; through testcontainers, without cloud credentials, on pull requests affecting the module. It dispatches over real HTTP, allowing a server in the test JVM to record each request&#39;s method, path, body and headers. The tests also inspect OIDC bearer JWTs for the configured account and audience.&lt;/p&gt;
&lt;p&gt;Stored-task assertions use paused queues. Running queues delete tasks as soon as they complete, which would race inspections of the task itself. Separating these checks verifies both stored request settings and what reaches the handler.&lt;/p&gt;
&lt;p&gt;The emulator never garbage-collects task names, so it can test &lt;code&gt;ALREADY_EXISTS&lt;/code&gt; but not the deduplication window. It also lacks queue-level URI overrides, App Engine dispatch, OAuth tokens, failure injection and task-size enforcement. Transient retries are therefore unit-tested with a fake creator and injected clock.&lt;/p&gt;
&lt;p&gt;The connector leaves size enforcement to the service. As of 2026-09-05, Google&#39;s create reference still specified 100 KB while its quotas page specified 1 MiB. The connector validates against neither conflicting figure, and the documentation advises staying within the smaller one.&lt;/p&gt;
&lt;p&gt;The gated App Engine suite checks request construction and routing on paused queues, reads a queue-level routing override and observes failed handler attempts against a real App Engine Standard fixture. The fixture starts for the test class and returns to zero instances afterwards, with a scheduled sweep restoring the idle state after hard cancellation.&lt;/p&gt;
&lt;h2 id=&quot;closing-the-series&quot;&gt;Closing the series&lt;/h2&gt;
&lt;p&gt;This completes the five-connector series, with each post built around a pipeline I needed. Cloud Tasks illustrates the shared design approach: expose service capabilities and add the Flink behavior around them.&lt;/p&gt;
&lt;p&gt;The queue controls handler pacing, scheduling and retries. The sink retries only creation requests, hashes business keys for service-side deduplication, and makes &lt;code&gt;INVALID_ARGUMENT&lt;/code&gt; eligible for failure routing. Its Flink-specific responsibility is to wait at a checkpoint until every non-skipped create is durable under the default failure policy. The SQL API expresses the request as a table.&lt;/p&gt;
&lt;p&gt;The &lt;a rel=&quot;external&quot; href=&quot;https://github.com/flink-gcp/flink-connector-gcp/milestone/10&quot;&gt;v1.1.0 milestone&lt;/a&gt; now includes the Cloud Tasks exactly-once mode alongside Bigtable&#39;s write extensions. The &lt;a rel=&quot;external&quot; href=&quot;https://github.com/flink-gcp/flink-connector-gcp/issues/1238&quot;&gt;Cloud Tasks tracker&lt;/a&gt; covers the service investigation, staged writer and committer, DataStream and Table APIs, and recovery and performance validation. &lt;a rel=&quot;external&quot; href=&quot;https://github.com/flink-gcp/flink-connector-gcp/issues/715&quot;&gt;RPC-level metrics through the client tracer&lt;/a&gt; remain planned for v1.3.0. Reports about targets or body formats the SQL API cannot express will help guide further work: &lt;a rel=&quot;external&quot; href=&quot;https://github.com/flink-gcp/flink-connector-gcp/issues&quot;&gt;https://github.com/flink-gcp/flink-connector-gcp/issues&lt;/a&gt;&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;&lt;em&gt;This is an independent open-source project. It is not affiliated with, endorsed by, or supported by the Apache Software Foundation or Google. Apache Flink, Flink, and the Flink logo are trademarks of the Apache Software Foundation.&lt;/em&gt;&lt;/p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>The Bigtable connector in flink-connector-gcp, part four of the connector series</title>
        <published>2026-09-05T16:33:07+09:00</published>
        <updated>2026-09-05T21:29:48+09:00</updated>
        
        <author>
          <name>Unknown</name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://blog.laughingman7743.org/posts/flink-connector-gcp-bigtable/"/>
        <id>https://blog.laughingman7743.org/posts/flink-connector-gcp-bigtable/</id>
        
        <content type="html" xml:base="https://blog.laughingman7743.org/posts/flink-connector-gcp-bigtable/">&lt;p&gt;This is the fourth post in the &lt;a rel=&quot;external&quot; href=&quot;https://github.com/flink-gcp/flink-connector-gcp&quot;&gt;flink-connector-gcp&lt;/a&gt; series, about the Apache Flink connectors for Google Cloud I released as 1.0.0. &lt;a rel=&quot;external&quot; href=&quot;https://blog.laughingman7743.org/posts/flink-connector-gcp-1-0-0/&quot;&gt;The release post&lt;/a&gt; introduces the project; the previous posts cover &lt;a rel=&quot;external&quot; href=&quot;https://blog.laughingman7743.org/posts/flink-connector-gcp-bigquery/&quot;&gt;BigQuery&lt;/a&gt;, &lt;a rel=&quot;external&quot; href=&quot;https://blog.laughingman7743.org/posts/flink-connector-gcp-pubsub/&quot;&gt;Pub/Sub&lt;/a&gt; and &lt;a rel=&quot;external&quot; href=&quot;https://blog.laughingman7743.org/posts/flink-connector-gcp-spanner/&quot;&gt;Spanner&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The Bigtable connector grew out of a need to enrich streams with data from Bigtable. This post follows that SQL pipeline, then covers the full-table cache, how cell timestamps affect replayed writes, change streams, and the write extensions planned for v1.1.0.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Documentation: &lt;a rel=&quot;external&quot; href=&quot;https://flink-gcp.github.io/flink-connector-gcp/docs/connectors/datastream/bigtable/&quot;&gt;https://flink-gcp.github.io/flink-connector-gcp/docs/connectors/datastream/bigtable/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Maven: &lt;code&gt;io.github.flink-gcp:flink-connector-gcp-bigtable:1.0.0&lt;/code&gt; (&lt;code&gt;1.0.0-1.20&lt;/code&gt; for Flink 1.20); for the SQL client, the &lt;code&gt;flink-sql-connector-gcp-bigtable&lt;/code&gt; uber-jar from the &lt;a rel=&quot;external&quot; href=&quot;https://github.com/flink-gcp/flink-connector-gcp/releases/tag/v1.0.0&quot;&gt;release page&lt;/a&gt; goes into &lt;code&gt;lib/&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;what-existed-and-what-i-needed&quot;&gt;What existed, and what I needed&lt;/h2&gt;
&lt;p&gt;Google&#39;s &lt;a rel=&quot;external&quot; href=&quot;https://github.com/google/flink-connector-gcp&quot;&gt;flink-connector-gcp&lt;/a&gt; is a separate project with the same name. It publishes a Bigtable sink to Maven Central, with DataStream and Table APIs and a custom-serializer SPI. It has neither a scan source nor a lookup source. Its change-stream source has been an open pull request since March 2026, last updated in April. When I checked on 2026-09-04, its only commit in the preceding five months was a Flink version bump.&lt;/p&gt;
&lt;p&gt;Two smaller concerns also made me hesitate to build on it. The connectors directory contains Bigtable and a BigQuery module that the README describes as catalog support for the separately maintained Dataproc connector. The examples are spread across several top-level directories. I saw no basis to expect those gaps or the layout to change, so I built my own connector.&lt;/p&gt;
&lt;p&gt;My pipelines mostly use Bigtable as a low-latency attribute store. An event carries a user id, and a Bigtable row holds that user&#39;s current attributes. A lookup join adds those attributes to the event without exporting the table. I also wanted to consume a change stream from the same table.&lt;/p&gt;
&lt;p&gt;I also considered Flink&#39;s HBase connector over Google&#39;s HBase-compatible client library, but its releases did not cover the Flink versions I needed. As of 2026-09-04, the newest &lt;code&gt;apache/flink-connector-hbase&lt;/code&gt; artifacts on Maven Central were 4.0.0 builds for Flink 1.18 and 1.19, published in November 2024. There were none for 1.20 or 2.x.&lt;/p&gt;
&lt;p&gt;I did adopt its DDL model: one plain column for the row key, one &lt;code&gt;ROW&lt;/code&gt; per column family, and the HBase ecosystem&#39;s byte encodings for cells. An HBase connector table definition can therefore keep its schema when moved to this connector, and each connector can read tables written by the other.&lt;/p&gt;
&lt;p&gt;The resulting module has a bounded scan source, a change-stream source and an at-least-once sink in the DataStream API. The SQL connector adds lookup joins with three cache modes and a changelog interpretation of the change stream. I also adopted Google&#39;s serializer interface shape, so porting a serializer requires changing the interface name.&lt;/p&gt;
&lt;h2 id=&quot;an-enrichment-pipeline-in-flink-sql&quot;&gt;An enrichment pipeline in Flink SQL&lt;/h2&gt;
&lt;p&gt;JSON events arrive over Pub/Sub with a user id. A Flink SQL job looks up each user&#39;s Bigtable row through a cache, then creates an HTTP task in Cloud Tasks for each enriched event. The next post covers Cloud Tasks in detail.&lt;/p&gt;
&lt;p&gt;The job consists of three tables and one &lt;code&gt;INSERT&lt;/code&gt;. This code is copied byte for byte from the &lt;a rel=&quot;external&quot; href=&quot;https://flink-gcp.github.io/flink-connector-gcp/docs/examples/bigtable/#enriching-pubsub-events-before-creating-tasks&quot;&gt;source-backed example&lt;/a&gt;, which CI validates through Flink&#39;s planner:&lt;/p&gt;
&lt;pre class=&quot;giallo z-l-code z-d-code&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;SET&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;execution.checkpointing.interval&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;10 s&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;CREATE&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; TABLE&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt; incoming_events&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  event_id STRING,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  user_id STRING,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  event_type STRING,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  message_id STRING METADATA &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;FROM&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;message-id&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt; VIRTUAL,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  proc_time &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;AS&lt;/span&gt;&lt;span&gt; PROCTIME&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;) &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;WITH&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;connector&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;pubsub&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;project&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;my-project&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;subscription&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;events-sub&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;format&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;json&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;CREATE&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; TABLE&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt; user_attributes&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  rowkey STRING,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;  profile&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; ROW&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span&gt;tier STRING, api_path STRING&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;&amp;gt;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;  PRIMARY KEY&lt;/span&gt;&lt;span&gt; (rowkey) &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;NOT&lt;/span&gt;&lt;span&gt; ENFORCED&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;) &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;WITH&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;connector&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;bigtable&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;project&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;my-project&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;instance&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;my-instance&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;table&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;user-attributes&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;scan.row-prefix&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;user#&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;lookup.async&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;true&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;lookup.cache&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;PARTIAL&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;lookup.partial-cache.max-rows&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;10000&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;lookup.partial-cache.expire-after-write&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;10 min&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;CREATE&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; TABLE&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt; api_tasks&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  event_id STRING,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  user_id STRING,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  event_type STRING,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  user_tier STRING,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  api_path STRING,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  source_message_id STRING,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  request_headers MAP&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span&gt;STRING, STRING&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;&amp;gt;&lt;/span&gt;&lt;span&gt; METADATA &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;FROM&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;headers&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;) &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;WITH&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;connector&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;cloud-tasks&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;project&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;my-project&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;location&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;asia-northeast1&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;queue&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;events&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;http.url&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;https://api.example.com/events&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;http.method&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;POST&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;http.headers.Content-Type&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;application/json&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;format&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;json&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;INSERT INTO&lt;/span&gt;&lt;span&gt; api_tasks&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;SELECT&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;  e&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;event_id&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;  e&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;user_id&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;  e&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;event_type&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;  a&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;profile&lt;/span&gt;&lt;span&gt;.tier,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;  a&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;profile&lt;/span&gt;&lt;span&gt;.api_path,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;  e&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;message_id&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  MAP&lt;/span&gt;&lt;span&gt;[&amp;#39;X-Source-Message-Id&amp;#39;, e.message_id]&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; incoming_events &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;AS&lt;/span&gt;&lt;span&gt; e&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;JOIN&lt;/span&gt;&lt;span&gt; user_attributes &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;FOR&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; SYSTEM_TIME&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; AS&lt;/span&gt;&lt;span&gt; OF &lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;e&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;proc_time&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; AS&lt;/span&gt;&lt;span&gt; a&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;  ON&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt; e&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;user_id&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt; a&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;rowkey&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The Bigtable DDL follows the HBase connector&#39;s model. Exactly one column is not a &lt;code&gt;ROW&lt;/code&gt;: the row key. Its type determines the key&#39;s byte encoding. A &lt;code&gt;STRING&lt;/code&gt; key uses UTF-8; a &lt;code&gt;BIGINT&lt;/code&gt; key uses eight big-endian bytes.&lt;/p&gt;
&lt;p&gt;Each &lt;code&gt;ROW&lt;/code&gt; column represents a column family, and its fields name the qualifiers, with one cell per field. Here, &lt;code&gt;profile ROW&amp;lt;tier STRING, api_path STRING&amp;gt;&lt;/code&gt; defines the &lt;code&gt;profile&lt;/code&gt; family with two qualifiers.&lt;/p&gt;
&lt;p&gt;Cell encodings also follow HBase conventions, allowing the connector to read tables written by other systems that use those conventions. For keys or cells written with a different encoding, declaring &lt;code&gt;BYTES&lt;/code&gt; or &lt;code&gt;STRING&lt;/code&gt; is safer than decoding them as numbers.&lt;/p&gt;
&lt;p&gt;The selected columns determine which families Bigtable returns. This query reads &lt;code&gt;a.profile.tier&lt;/code&gt; and &lt;code&gt;a.profile.api_path&lt;/code&gt;, so the point read includes a filter for the &lt;code&gt;profile&lt;/code&gt; family. Families declared in the DDL but unused by the query are not transferred.&lt;/p&gt;
&lt;p&gt;That filter also affects which rows the lookup finds. A Bigtable row exists only while it has a cell. If none of its cells belong to a family the query reads, the filter returns nothing. The inner join then produces no task for that event. The same happens when the user id falls outside &lt;code&gt;user#&lt;/code&gt;, because &lt;code&gt;scan.row-prefix&lt;/code&gt; bounds point reads as well as scans. A &lt;code&gt;LEFT JOIN&lt;/code&gt; would retain these events with null attributes.&lt;/p&gt;
&lt;p&gt;The join must use equality on the single row-key column. Unlike the Spanner example, it cannot join on composite keys or nested family fields; Flink rejects those joins during planning. A Bigtable point read addresses one atomic row key. To look up a row by two values, encode both into that key.&lt;/p&gt;
&lt;p&gt;The example uses Flink&#39;s standard lookup cache. With &lt;code&gt;lookup.async = &#39;true&#39;&lt;/code&gt;, point reads can overlap instead of blocking the operator for each event. With &lt;code&gt;lookup.cache = &#39;PARTIAL&#39;&lt;/code&gt;, the cache loads entries on demand and retains them under at least one configured bound. Here the bounds are 10,000 rows and a ten-minute write expiry. A frequently accessed user that stays cached is read once per ten minutes rather than once per event, at the cost of up to ten minutes of stale attributes.&lt;/p&gt;
&lt;p&gt;The checkpoint interval serves the Pub/Sub source: it acknowledges messages when a checkpoint completes. Both ends of this pipeline provide at-least-once delivery. The lookup join does not strengthen that guarantee.&lt;/p&gt;
&lt;p&gt;Deployment requires three uber-jars in the SQL client&#39;s &lt;code&gt;lib/&lt;/code&gt;, one per connector. Their dependencies are relocated so the jars can coexist.&lt;/p&gt;
&lt;p&gt;Google&#39;s Bigtable connector also registers the &lt;code&gt;bigtable&lt;/code&gt; identifier. Installing both Bigtable connectors as separate jars makes factory discovery fail with an ambiguity error naming that identifier. Merging them into one fat jar can instead let whichever registration survives silently control the DDL. Keep the jars separate so the conflict remains visible.&lt;/p&gt;
&lt;h2 id=&quot;the-full-table-cache-and-when-to-reach-for-it&quot;&gt;The full-table cache, and when to reach for it&lt;/h2&gt;
&lt;p&gt;The Spanner post mentioned that its lookup source rejects a &lt;code&gt;FULL&lt;/code&gt; cache, while Bigtable offers one. The choice here depends on how large the lookup table is and how often it changes.&lt;/p&gt;
&lt;p&gt;The three cache modes differ in where reads happen. &lt;code&gt;NONE&lt;/code&gt; performs a point read for every event. &lt;code&gt;PARTIAL&lt;/code&gt; reads on a cache miss and retains the result within its configured bounds.&lt;/p&gt;
&lt;p&gt;With &lt;code&gt;FULL&lt;/code&gt;, each lookup task loads every projected row within the configured key bounds through a bounded scan. All joins use that local copy, with no point reads. Reloads can run periodically through &lt;code&gt;lookup.full-cache.periodic-reload.interval&lt;/code&gt;, in fixed-delay or fixed-rate mode, or at a time of day through &lt;code&gt;lookup.full-cache.timed-reload.iso-time&lt;/code&gt;, at a configured interval in days.&lt;/p&gt;
&lt;p&gt;A full-cache lookup is synchronous, so combining it with &lt;code&gt;lookup.async = &#39;true&#39;&lt;/code&gt; fails during planning. The &lt;code&gt;scan.row-prefix&lt;/code&gt; and range bounds apply to the cache load just as they do to point reads.&lt;/p&gt;
&lt;p&gt;A full cache suits a dimension table that fits comfortably in a subtask&#39;s heap, receives frequent lookups, and changes on a schedule. Consider a few hundred thousand user attributes rebuilt nightly. A timed reload after the rebuild makes every join local, with no Bigtable traffic between reloads. The cost is one copy of the projected table per lookup task, so memory use scales with lookup parallelism.&lt;/p&gt;
&lt;p&gt;My reading of the two designs is that the difference starts with what each service&#39;s scan promises. A Spanner snapshot read runs at one timestamp in a batch transaction whose lifetime is bounded by version retention. A full cache would need to define the snapshot it holds and how successive reloads relate, a contract that source deliberately does not offer.&lt;/p&gt;
&lt;p&gt;A Bigtable scan returns the latest cell versions as it reads each row, without a single snapshot timestamp. The cache uses the connector&#39;s bounded scan source as its loader and retains those scan semantics.&lt;/p&gt;
&lt;p&gt;A &lt;code&gt;FULL&lt;/code&gt; cache reload is a scan, so it can use a Data Boost application profile. Data Boost is Bigtable&#39;s read-only serverless compute and keeps the reload off the serving cluster. Point-read modes cannot use it.&lt;/p&gt;
&lt;p&gt;Two limits matter here. Bigtable gives no guarantee for data written less than 35 minutes before a Data Boost read, so a reload may omit the newest rows. A parallel reload above 1,000 read requests per second per cluster is reported as ineligible and billed accordingly, without an error.&lt;/p&gt;
&lt;p&gt;This project has not exercised Data Boost, which requires an Enterprise-edition instance. The gated suite checks only that the configured profile id reaches the client. I therefore consider the full-cache combination plausible but unproven.&lt;/p&gt;
&lt;h2 id=&quot;keeping-the-lookup-table-current&quot;&gt;Keeping the lookup table current&lt;/h2&gt;
&lt;p&gt;Writing the lookup table raises two questions: what happens when an update is replayed, and what happens when two updates target the same key? A second SQL job maintains the table from a stream of profile updates. It adapts the &lt;a rel=&quot;external&quot; href=&quot;https://flink-gcp.github.io/flink-connector-gcp/docs/examples/bigtable/#writing-a-stable-cell-timestamp&quot;&gt;source-backed cell-timestamp example&lt;/a&gt;; I added the Pub/Sub source:&lt;/p&gt;
&lt;pre class=&quot;giallo z-l-code z-d-code&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;CREATE&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; TABLE&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt; profile_updates&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  user_id    STRING,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  tier       STRING,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  api_path   STRING,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  updated_at TIMESTAMP_LTZ(&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;3&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;) &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;WITH&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;connector&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;    =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;pubsub&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;project&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;      =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;my-project&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;subscription&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;profile-updates-sub&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;format&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;       =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;json&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;CREATE&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; TABLE&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt; user_attributes_out&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  rowkey         STRING,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;  profile&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;        ROW&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span&gt;tier STRING, api_path STRING&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;&amp;gt;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  cell_timestamp TIMESTAMP_LTZ(&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;6&lt;/span&gt;&lt;span&gt;) METADATA &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;FROM&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;timestamp&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;  PRIMARY KEY&lt;/span&gt;&lt;span&gt; (rowkey) &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;NOT&lt;/span&gt;&lt;span&gt; ENFORCED&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;) &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;WITH&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;connector&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;bigtable&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;project&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;   =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;my-project&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;instance&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;  =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;my-instance&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;table&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;     =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;user-attributes&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;sink.insert-only-input-mode&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;insert-only&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;INSERT INTO&lt;/span&gt;&lt;span&gt; user_attributes_out&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; user_id, &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;ROW&lt;/span&gt;&lt;span&gt;(tier, api_path), &lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;CAST&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;updated_at &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;AS&lt;/span&gt;&lt;span&gt; TIMESTAMP_LTZ(&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;6&lt;/span&gt;&lt;span&gt;))&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; profile_updates;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The sink advertises an upsert contract by default because &lt;code&gt;setCell&lt;/code&gt; overwrites cells under the row key. A changelog &lt;code&gt;DELETE&lt;/code&gt; removes the whole row. Declaring the row key as a primary key is optional, as with the HBase connector. On Flink 2.x, declaring it can make updating queries cheaper: a delete may carry only the key, avoiding the stateful &lt;code&gt;ChangelogNormalize&lt;/code&gt; operator that would otherwise complete the row.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;insert-only&lt;/code&gt; option addresses a planning rule in Flink 2.3. A keyed upsert sink requires &lt;code&gt;ON CONFLICT&lt;/code&gt; when the input&#39;s upsert key differs from the sink&#39;s or cannot be inferred, as with this append stream. The option narrows an insert-only input to an insert-only contract, allowing this statement to plan without the clause on Flink 1.20, 2.2 and 2.3. The physical write is unchanged: it still overwrites cells under an existing row key.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;cell_timestamp&lt;/code&gt; column determines what a replay does to a cell. This is an at-least-once sink, so a restart replays records written since the last completed checkpoint. With a stable explicit timestamp, replay writes the same value to the same cell version. Without one, the sink uses the writer&#39;s clock, and replay adds another version. The table&#39;s garbage-collection policy determines how long those versions remain.&lt;/p&gt;
&lt;p&gt;The query takes its timestamp from the record&#39;s &lt;code&gt;updated_at&lt;/code&gt;, making a replay of that cell write a no-op. Bigtable&#39;s storage model absorbs the duplicate; the sink does not track or reject replayed records. This does not cover writes using the writer&#39;s clock, non-idempotent mutations, or collisions between distinct events that target the same cell version.&lt;/p&gt;
&lt;p&gt;On a table with the default timestamp granularity, an explicit timestamp must be millisecond-aligned or Bigtable rejects it with &lt;code&gt;INVALID_ARGUMENT&lt;/code&gt;. The opt-in &lt;code&gt;sink.cell-timestamp.truncate-to-millis&lt;/code&gt; drops the extra precision. An absent or null timestamp uses a millisecond-aligned writer clock.&lt;/p&gt;
&lt;p&gt;The sink does not guarantee the order of writes to one key. Bigtable&#39;s bulk mutation contract says entries &quot;may be applied in arbitrary order (even between entries for the same row)&quot;. Requests can also overlap in flight. Two updates to one user may therefore be applied in a different order from the order in which the job submitted them.&lt;/p&gt;
&lt;p&gt;Before deciding whether to add ordering machinery, the project measured the service. On 2026-08-11, a campaign submitted 86,196 same-row pairs in mirrored arms against a one-node SSD instance. Request sizes ranged from 2 through 19,998 entries, and the campaign observed zero reversals. That observation does not change the service contract. The connector documents the caveat rather than adding a per-key queue for reversals the campaign did not observe.&lt;/p&gt;
&lt;p&gt;For timestamped profile updates, a latest-cell read selects by the record-supplied cell timestamp, regardless of mutation arrival order. Two updates within the same millisecond can still target one version with no defined winner, and a row delete carries no timestamp. Those cases remain subject to the ordering caveat.&lt;/p&gt;
&lt;p&gt;In the DataStream API, the serializer makes the same choices. It returns the client&#39;s &lt;code&gt;RowMutationEntry&lt;/code&gt;, which can combine several cell writes and a delete into one atomic operation on a row. This example is adapted from the &lt;a rel=&quot;external&quot; href=&quot;https://flink-gcp.github.io/flink-connector-gcp/docs/examples/bigtable/#several-cells-and-a-delete-per-record&quot;&gt;source-backed examples&lt;/a&gt;, which CI compiles:&lt;/p&gt;
&lt;pre class=&quot;giallo z-l-code z-d-code&quot; &gt;&lt;code data-lang=&quot;java&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;BigtableSink&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span&gt;OrderEvent&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;&amp;gt;&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;builder&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        .&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;table&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;TableDestination&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;of&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;my-project&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;my-instance&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;orders&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        .&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;serializer&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                (&lt;/span&gt;&lt;span&gt;event&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span&gt; context&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; -&amp;gt;&lt;/span&gt;&lt;span&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;                    long&lt;/span&gt;&lt;span&gt; timestampMicros&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span&gt; event&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;updatedAtMillis&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; *&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt; 1_000&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                    RowMutationEntry&lt;/span&gt;&lt;span&gt; entry&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span&gt; RowMutationEntry&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;create&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;order#&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; +&lt;/span&gt;&lt;span&gt; event&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;id&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                    entry&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;setCell&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;cf&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;status&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span&gt; timestampMicros&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span&gt; event&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;status&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                    entry&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;setCell&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;cf&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;total&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span&gt; timestampMicros&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span&gt; event&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;totalCents&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;                    if&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;event&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;isCancelled&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                        entry&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;deleteCells&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;cf&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;reserved_stock&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                    }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;                    return&lt;/span&gt;&lt;span&gt; entry&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                }&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        .&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;build&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Multiplying by 1,000 converts the record&#39;s epoch milliseconds to the microseconds &lt;code&gt;setCell&lt;/code&gt; expects. A serializer can also return &lt;code&gt;null&lt;/code&gt; to skip a record, for example when a filter depends on the mutation being built. Nothing is written, and only the &lt;code&gt;recordsSkipped&lt;/code&gt; counter reports the skip.&lt;/p&gt;
&lt;p&gt;Dynamic destinations work as in the earlier posts. A resolver returns a &lt;code&gt;TableDestination&lt;/code&gt; for each record, allowing a table per tenant or per day. The writer maintains one bulk mutation batcher per table and one client per instance.&lt;/p&gt;
&lt;p&gt;Auto-creation requires an explicit schema: column families and their garbage-collection policies. The sink cannot infer these, so &lt;code&gt;CREATE_IF_NEEDED&lt;/code&gt; requires table-create options naming at least one family. As in the Pub/Sub sink, creation is reactive. No admin client exists until a mutation fails with &lt;code&gt;NOT_FOUND&lt;/code&gt;. The sink then ensures the table and its declared families exist and reapplies the failed mutations.&lt;/p&gt;
&lt;p&gt;Garbage collection needs a deliberate choice. A family without a rule keeps Bigtable&#39;s default of collecting nothing. If the sink uses wall-clock timestamps, duplicate versions from replays can then accumulate forever. The SQL layer therefore requires a &lt;code&gt;max-versions&lt;/code&gt; or &lt;code&gt;max-age&lt;/code&gt; rule under &lt;code&gt;create-if-needed&lt;/code&gt;. A typical rule for retaining only the latest cell is &lt;code&gt;GcRule.union(GcRule.maxVersions(1), GcRule.maxAge(...))&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Google&#39;s connector calls timestamp-based idempotence &quot;Exactly Once out of the box&quot;. Its writer flushes at the checkpoint barrier without a committer, and most of its built-in serializers use the Flink record timestamp for each cell. The underlying mechanism is the same, but this connector does not label it exactly-once.&lt;/p&gt;
&lt;p&gt;Stable timestamps cannot make an increment or append idempotent. Bigtable&#39;s primitive for protecting such a write from replay is a conditional single-row mutation: apply the data only if a marker in the row says the write has not already happened.&lt;/p&gt;
&lt;p&gt;The project benchmarked this primitive against the real service under conditions fixed in advance. Two runs were inconclusive because throughput varied too widely between repetitions. On 2026-09-05, a run that isolated each repetition in its own JVM passed: about 1.5 times the bulk baseline&#39;s throughput, at two thirds of its tail latency, with a clean replay read-back. A separate probe showed that the conditional branch also absorbed a replayed increment.&lt;/p&gt;
&lt;p&gt;That result supports a planned exactly-once mode for v1.1.0 (&lt;a rel=&quot;external&quot; href=&quot;https://github.com/flink-gcp/flink-connector-gcp/issues/1211&quot;&gt;#1211&lt;/a&gt;). It is not implemented yet. The proposal uses Flink&#39;s two-phase commit: stage records during a checkpoint interval, then apply them at checkpoint completion through conditional writes keyed on a checkpoint-id cell in each row. The intended guarantees are idempotent commit retries and no later data left visible after restoring an earlier checkpoint.&lt;/p&gt;
&lt;p&gt;The expected costs are one conditional request per row and loss of staged records if Flink state is discarded, as with BigQuery&#39;s buffered streams. The issue&#39;s design record will settle the remaining details. Until this mode ships, BigQuery is the only connector in the family with a supported exactly-once sink mode.&lt;/p&gt;
&lt;h2 id=&quot;change-streams-without-a-metadata-table&quot;&gt;Change streams without a metadata table&lt;/h2&gt;
&lt;p&gt;A Bigtable change-stream record describes one atomic row mutation as the service applied it. It contains the row key and an ordered list of entries: &lt;code&gt;SetCell&lt;/code&gt;, &lt;code&gt;DeleteCells&lt;/code&gt;, &lt;code&gt;DeleteFamily&lt;/code&gt;, and the aggregate kinds &lt;code&gt;AddToCell&lt;/code&gt; and &lt;code&gt;MergeToCell&lt;/code&gt;. It also carries a commit timestamp, a tie breaker, the source cluster and the partition&#39;s estimated low watermark.&lt;/p&gt;
&lt;p&gt;Unlike Spanner&#39;s change stream, it supplies neither a before image nor a complete after image of the row. That difference determines how the connector&#39;s two SQL modes interpret the stream.&lt;/p&gt;
&lt;p&gt;The envelope mode preserves the mutation as one insert-only row, with its entries in an array in service order. This example is copied verbatim from the &lt;a rel=&quot;external&quot; href=&quot;https://flink-gcp.github.io/flink-connector-gcp/docs/examples/bigtable/#consuming-change-streams-with-sql&quot;&gt;source-backed example&lt;/a&gt;:&lt;/p&gt;
&lt;pre class=&quot;giallo z-l-code z-d-code&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;CREATE&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; TABLE&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt; profile_mutations&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  row_key BYTES,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  entries &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;ARRAY&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;ROW&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;&amp;lt;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    entry_index &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;INT&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    kind STRING,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    family STRING,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    qualifier &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;ROW&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span&gt;value_type STRING, bytes_value BYTES, long_value &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;BIGINT&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;&amp;gt;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;    `&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;timestamp&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;`&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; ROW&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span&gt;value_type STRING, bytes_value BYTES, long_value &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;BIGINT&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;&amp;gt;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;    `&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;value&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;`&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; ROW&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span&gt;value_type STRING, bytes_value BYTES, long_value &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;BIGINT&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;&amp;gt;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    delete_range &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;ROW&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;&amp;lt;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;      start_bound STRING,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;      start_micros &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;BIGINT&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;      end_bound STRING,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;      end_micros &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;BIGINT&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;    &amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;  &amp;gt;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;&amp;gt;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  mutation_type STRING &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;NOT NULL&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    METADATA &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;FROM&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;mutation-type&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt; VIRTUAL,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  commit_timestamp TIMESTAMP_LTZ(&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;9&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;NOT NULL&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    METADATA &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;FROM&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;commit-timestamp&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt; VIRTUAL,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  source_cluster_id STRING&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    METADATA &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;FROM&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;source-cluster-id&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt; VIRTUAL&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;) &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;WITH&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;connector&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;bigtable&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;project&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;my-project&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;instance&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;my-instance&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;table&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;profiles&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;scan.mode&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;change-stream&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;scan.change-stream.changelog-mode&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;envelope&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;scan.app-profile-id&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;single-cluster-profile&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;SELECT&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  row_key,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  mutation_type,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  commit_timestamp,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  entry_index,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  kind,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  family,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  qualifier,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  entry_timestamp,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  entry_value,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  delete_range&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; profile_mutations&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;CROSS JOIN&lt;/span&gt;&lt;span&gt; UNNEST(entries) &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;AS&lt;/span&gt;&lt;span&gt; entry_table(&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  entry_index,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  kind,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  family,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  qualifier,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  entry_timestamp,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  entry_value,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  delete_range&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In envelope mode, a deletion is an inserted log record, just like any other mutation. There is no row image to turn into a Flink &lt;code&gt;DELETE&lt;/code&gt;. The stream also includes garbage-collection mutations alongside application writes across all column families; &lt;code&gt;mutation_type&lt;/code&gt; distinguishes them.&lt;/p&gt;
&lt;p&gt;An envelope table rejects a primary key. Two mutations to the same Bigtable row are two log records, so feeding a keyed upsert sink requires the job to reconstruct rows in its own stateful operator.&lt;/p&gt;
&lt;p&gt;The envelope is a useful history format: it records which cells changed and when. &lt;code&gt;entry_index&lt;/code&gt; preserves each entry&#39;s position through &lt;code&gt;UNNEST&lt;/code&gt;. These records can feed BigQuery&#39;s append path, like the history pattern in the Spanner post.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;selected-cell&lt;/code&gt; mode produces a keyed changelog, but requires a specific producer protocol. One configured cell must contain the complete serialized non-key part of a logical row. The source decodes it with a Flink format and emits keyed &lt;code&gt;UPDATE_AFTER&lt;/code&gt; and &lt;code&gt;DELETE&lt;/code&gt; rows.&lt;/p&gt;
&lt;p&gt;The resulting changelog can feed the BigQuery CDC sink from the first post. This code is copied verbatim from the &lt;a rel=&quot;external&quot; href=&quot;https://flink-gcp.github.io/flink-connector-gcp/docs/examples/bigtable/#replicating-a-selected-cell-into-bigquery&quot;&gt;source-backed example&lt;/a&gt;:&lt;/p&gt;
&lt;pre class=&quot;giallo z-l-code z-d-code&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;SET&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;execution.checkpointing.interval&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;1 min&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;CREATE&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; TABLE&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt; current_profiles&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  profile_id STRING &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;NOT NULL&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;  name&lt;/span&gt;&lt;span&gt; STRING,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  tier STRING,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;  PRIMARY KEY&lt;/span&gt;&lt;span&gt; (profile_id) &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;NOT&lt;/span&gt;&lt;span&gt; ENFORCED&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;) &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;WITH&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;connector&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;bigtable&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;project&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;my-project&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;instance&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;my-instance&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;table&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;profiles&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;scan.mode&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;change-stream&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;scan.change-stream.changelog-mode&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;selected-cell&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;scan.app-profile-id&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;single-cluster-profile&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;scan.change-stream.selected-cell.family&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;state&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-5 z-d-3&quot;&gt;  --&lt;/span&gt;&lt;span class=&quot;z-l-5 z-d-3&quot;&gt; Base64 for the qualifier &amp;quot;current&amp;quot;.&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;scan.change-stream.selected-cell.qualifier-base64&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;Y3VycmVudA==&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;scan.change-stream.selected-cell.source-cluster-id&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;cluster-a&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;value.format&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;json&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;CREATE&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; TABLE&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt; analytics_profiles&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  profile_id STRING &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;NOT NULL&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;  name&lt;/span&gt;&lt;span&gt; STRING,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  tier STRING,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;  PRIMARY KEY&lt;/span&gt;&lt;span&gt; (profile_id) &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;NOT&lt;/span&gt;&lt;span&gt; ENFORCED&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;) &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;WITH&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;connector&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;bigquery&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;project&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;my-project&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;dataset&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;analytics&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;table&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;current_profiles&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;sink.cdc.enabled&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;true&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;sink.create-disposition&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;create-if-needed&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;sink.cdc.max-staleness&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;10 min&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;INSERT INTO&lt;/span&gt;&lt;span&gt; analytics_profiles&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; profile_id, &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;name&lt;/span&gt;&lt;span&gt;, tier &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; current_profiles;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The producer must follow the documented replacement protocol. An upsert must atomically delete the selected column across all timestamps, or its entire family, and then set exactly one replacement cell. The same delete without a following set becomes a key-only delete. Entries for other cells produce no row.&lt;/p&gt;
&lt;p&gt;The source cannot infer this protocol from arbitrary traffic. A standalone, repeated or out-of-order selected write fails the job, as does writing from a second cluster. The writer must replace the complete value using the required mutation shape.&lt;/p&gt;
&lt;p&gt;Compared with the Spanner replication example, the producer supplies the row contents that Bigtable itself does not provide. This pipeline also sends BigQuery no sequence metadata, so colliding changes to one key resolve by arrival order. It is an analytics replica starting at &lt;code&gt;latest&lt;/code&gt;; an already populated source table needs a separate initial snapshot.&lt;/p&gt;
&lt;p&gt;Both SQL modes use the partition machinery described in the Spanner post. Bigtable change-stream partitions split and merge over time. Each open partition read is a streaming RPC carrying mutations, heartbeats and continuation tokens. The coordinator stores assigned and unassigned partitions, pending merges and resume tokens in Flink checkpoints.&lt;/p&gt;
&lt;p&gt;No metadata table is created, so there is no additional stateful resource to provision, grant write access to or clean up. By comparison, Apache Beam&#39;s &lt;code&gt;BigtableIO.readChangeStream&lt;/code&gt; &quot;creates and manages a metadata table to manage the state of the connector&quot;, by default in the streamed table&#39;s instance. This quote is from the Beam Java SDK documentation, checked 2026-09-04, and describes the same arrangement as the Spanner post.&lt;/p&gt;
&lt;p&gt;Retention handling also follows the Spanner pattern. Restoring a position outside the stream&#39;s retention fails the job by default. The explicit opt-in &lt;code&gt;scan.resume-fallback.mode&lt;/code&gt; discards the stale token and restarts from &lt;code&gt;earliest&lt;/code&gt;, &lt;code&gt;latest&lt;/code&gt; or a timestamp, accepting the resulting gap.&lt;/p&gt;
&lt;p&gt;This source emits no watermarks, so its DDL must not declare &lt;code&gt;SOURCE_WATERMARK()&lt;/code&gt;. Each record does carry its commit timestamp as the Flink timestamp, and the partition&#39;s estimated low watermark is available as metadata. But Bigtable permits later records to have older commit timestamps, with no published finite bound on how much older. That estimate cannot support a source-watermark guarantee.&lt;/p&gt;
&lt;p&gt;Spanner heartbeats, by contrast, guarantee that every change at or before the heartbeat timestamp has been delivered. That lets the Spanner source own its watermark. Beam&#39;s Bigtable documentation takes a different approach: it says the connector &quot;outputs all data with an output timestamp of zero, making all data late&quot; (checked 2026-09-04).&lt;/p&gt;
&lt;p&gt;A Flink job can instead define its own watermark policy over the commit timestamp:&lt;/p&gt;
&lt;pre class=&quot;giallo z-l-code z-d-code&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;commit_timestamp TIMESTAMP_LTZ(&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;3&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;NOT NULL&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  METADATA &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;FROM&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;commit-timestamp&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt; VIRTUAL,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;WATERMARK &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;FOR&lt;/span&gt;&lt;span&gt; commit_timestamp &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;AS&lt;/span&gt;&lt;span&gt; commit_timestamp &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;-&lt;/span&gt;&lt;span&gt; INTERVAL &lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;5&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; MINUTE&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The five-minute delay is an example policy. Records behind that watermark are late according to the job&#39;s own choice. Downstream allowed lateness or late-data routing must account for them.&lt;/p&gt;
&lt;p&gt;The maximum number of open partition reads is source parallelism multiplied by &lt;code&gt;scan.max-concurrent-streams-per-subtask&lt;/code&gt;, whose default is two. This is a connector capacity bound, not a service quota. Monitor the positions of active, queued and unassigned partitions against the table&#39;s change-stream retention: once records fall outside retention, the source cannot recover them.&lt;/p&gt;
&lt;p&gt;Change streams require a single-cluster-routing application profile. The API is marked &lt;code&gt;@PublicEvolving&lt;/code&gt; because its record model follows a client surface the vendor is still evolving. There is no emulator option, since the Bigtable emulator implements neither change-stream RPC.&lt;/p&gt;
&lt;h2 id=&quot;the-knobs-that-matter&quot;&gt;The knobs that matter&lt;/h2&gt;
&lt;p&gt;The sink exposes batch thresholds and in-flight bounds. Both count entries, one per record returned by the serializer. Bigtable&#39;s separate service limit counts mutations: at most 100,000 per batch. The client enforces that limit by flushing before another entry would exceed it, regardless of the configured entry threshold.&lt;/p&gt;
&lt;p&gt;Batch thresholds (&lt;code&gt;sink.batching.*&lt;/code&gt; in SQL) are unset by default, retaining the client&#39;s defaults of 100 entries, 20 MiB and a one-second timer. Lowering the entry threshold reduces the wait before a low-volume batch is sent, useful for a table-per-day job receiving only a trickle of records.&lt;/p&gt;
&lt;p&gt;The writer&#39;s in-flight bounds control backpressure: &lt;code&gt;maxInFlightEntries&lt;/code&gt; defaults to 1,000 and &lt;code&gt;maxInFlightBytes&lt;/code&gt; to 64 MiB. At either cap, &lt;code&gt;write()&lt;/code&gt; yields to the task mailbox until requests complete and the counters fall. This lets checkpoint barriers continue to run.&lt;/p&gt;
&lt;p&gt;The client has its own flow controller, capped at 20,000 outstanding entries and 100 MiB. Reaching either limit blocks Flink&#39;s task thread, which also needs to process checkpoint barriers. Raising the writer&#39;s bounds far above the client&#39;s limits therefore shifts backpressure into a blocking client call instead of increasing effective capacity.&lt;/p&gt;
&lt;p&gt;The connector exposes no retry knobs. The client retries transient &lt;code&gt;MutateRows&lt;/code&gt; failures per entry, with its own backoff and a ten-minute total budget. A failure reaches the writer after the client has given up. An &lt;code&gt;UNAVAILABLE&lt;/code&gt; at that point represents an outage that outlasted the retry budget, rather than a single slow call.&lt;/p&gt;
&lt;p&gt;Only &lt;code&gt;INVALID_ARGUMENT&lt;/code&gt; is eligible for row-level failure routing, where a configured handler can drop or dead-letter it. gRPC defines that status as a problem independent of system state. Under &lt;code&gt;CREATE_IF_NEEDED&lt;/code&gt;, a &lt;code&gt;NOT_FOUND&lt;/code&gt; can be repaired for a missing table or a declared missing family; otherwise it is fatal. All other statuses, including outages, fail the job.&lt;/p&gt;
&lt;p&gt;Bigtable can reject an entire batch because of one bad entry. A measurement confirmed this: a good record and a bad record sent together both failed with the same status. The writer therefore resubmits each parked mutation alone to confirm a row-level rejection before invoking the handler.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;maxConsecutiveRejections&lt;/code&gt; limit, 100 by default, fails the job after that many confirmed rejections without an applied mutation between them. This prevents a dropping policy from silently draining a stream that the service refuses wholesale. SQL tables expose no failure-policy option and always fail the job on the first routed failure.&lt;/p&gt;
&lt;p&gt;Scan parallelism depends on the table&#39;s tablets. Each split is a row-key range cut at boundaries Bigtable reports, and the read path cannot subdivide a tablet. A table with few tablets therefore uses few reading subtasks even when the job has higher parallelism.&lt;/p&gt;
&lt;p&gt;Each fetch hands Flink at most 1,000 rows and targets at most 8 MiB of decoded input. Server-side filters reduce memory use before data reaches the SDK. SQL family projection is one such filter.&lt;/p&gt;
&lt;h2 id=&quot;testing-and-what-the-emulator-cannot-show&quot;&gt;Testing, and what the emulator cannot show&lt;/h2&gt;
&lt;p&gt;The Bigtable emulator is useful for development, but its validation differs substantially from the real service. The differences can change failure routing. For a timestamp finer than the table&#39;s granularity or an empty row key, the service returns &lt;code&gt;INVALID_ARGUMENT&lt;/code&gt;. For a missing column family, it returns &lt;code&gt;NOT_FOUND&lt;/code&gt;. The emulator returns &lt;code&gt;INTERNAL&lt;/code&gt; in these cases.&lt;/p&gt;
&lt;p&gt;This sink treats &lt;code&gt;INTERNAL&lt;/code&gt; as fatal but can route &lt;code&gt;INVALID_ARGUMENT&lt;/code&gt; to a failure handler. An emulator-only test can therefore report a job failure for an error that is droppable on the service.&lt;/p&gt;
&lt;p&gt;The emulator also models no tablets, so a scan plan has one split regardless of parallelism. It ignores application profiles and implements neither change-stream RPC. The &lt;a rel=&quot;external&quot; href=&quot;https://flink-gcp.github.io/flink-connector-gcp/docs/connectors/datastream/bigtable/#where-the-emulator-differs-from-the-service&quot;&gt;connector page&lt;/a&gt; contains the full deviation tables measured against both environments.&lt;/p&gt;
&lt;p&gt;The gated suite creates real instances for its tests. A standing one-node instance costs roughly $470 a month, so each gated class creates an instance and deletes it afterwards. A scheduled sweep reclaims instances left by runs that die before teardown.&lt;/p&gt;
&lt;p&gt;These tests found the batch-wide rejection that led to individual confirmation of failed entries. The same-row ordering campaign also ran against the real service. It was deliberately not retained as a regression test: requiring zero reversals would turn an observation into a guarantee the service does not make.&lt;/p&gt;
&lt;p&gt;For change-stream recovery, the run on 2026-08-12 observed all 100 seeded rows through a checkpoint, a controlled failure and recovery, without loss. It reached its bounded end time in 132.3 seconds.&lt;/p&gt;
&lt;h2 id=&quot;what-s-next-for-this-connector&quot;&gt;What&#39;s next for this connector&lt;/h2&gt;
&lt;p&gt;The &lt;a rel=&quot;external&quot; href=&quot;https://github.com/flink-gcp/flink-connector-gcp/milestone/10&quot;&gt;v1.1.0 milestone&lt;/a&gt; mainly extends Bigtable writes beyond &lt;code&gt;MutateRows&lt;/code&gt;. Its first piece merged on 2026-09-03: a request-response runtime for the single-row transactions &lt;code&gt;CheckAndMutateRow&lt;/code&gt; and &lt;code&gt;ReadModifyWriteRow&lt;/code&gt;. It sits alongside the batching sink because each operation sends one request for one row and returns a response the caller needs.&lt;/p&gt;
&lt;p&gt;The runtime follows the client&#39;s classification of both RPCs as non-idempotent. It makes one attempt under a 20-second deadline, with no retries on either side. If the request ends before the service answers, the outcome is ambiguous and the job fails: replaying an increment could apply it again.&lt;/p&gt;
&lt;p&gt;The runtime and its options are present, but public entry points remain open work under &lt;a rel=&quot;external&quot; href=&quot;https://github.com/flink-gcp/flink-connector-gcp/issues/1174&quot;&gt;#1174&lt;/a&gt;. These include per-operation sinks and functions and a Table write mode. The issue also tracks aggregate column families and an atomic keep-latest mode that deletes a cell&#39;s versions and sets its replacement in one mutation.&lt;/p&gt;
&lt;p&gt;Response-bearing async SQL functions for Flink 2.2 and later follow. The committer-based exactly-once mode described above is currently planned as the milestone&#39;s last item.&lt;/p&gt;
&lt;p&gt;The next post covers Cloud Tasks, the sink at the end of this pipeline and the one service for which I found no Flink connector. Reports from workloads unlike mine help decide what to build next: &lt;a rel=&quot;external&quot; href=&quot;https://github.com/flink-gcp/flink-connector-gcp/issues&quot;&gt;https://github.com/flink-gcp/flink-connector-gcp/issues&lt;/a&gt;&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;&lt;em&gt;This is an independent open-source project. It is not affiliated with, endorsed by, or supported by the Apache Software Foundation or Google. Apache Flink, Flink, and the Flink logo are trademarks of the Apache Software Foundation.&lt;/em&gt;&lt;/p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>The Spanner connector in flink-connector-gcp, part three of the connector series</title>
        <published>2026-09-04T00:16:31+09:00</published>
        <updated>2026-09-05T21:15:04+09:00</updated>
        
        <author>
          <name>Unknown</name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://blog.laughingman7743.org/posts/flink-connector-gcp-spanner/"/>
        <id>https://blog.laughingman7743.org/posts/flink-connector-gcp-spanner/</id>
        
        <content type="html" xml:base="https://blog.laughingman7743.org/posts/flink-connector-gcp-spanner/">&lt;p&gt;This is the third post in the &lt;a rel=&quot;external&quot; href=&quot;https://github.com/flink-gcp/flink-connector-gcp&quot;&gt;flink-connector-gcp&lt;/a&gt; series, about the Apache Flink connectors for Google Cloud I released as 1.0.0. &lt;a rel=&quot;external&quot; href=&quot;https://blog.laughingman7743.org/posts/flink-connector-gcp-1-0-0/&quot;&gt;The release post&lt;/a&gt; introduces the project, and the earlier posts cover &lt;a rel=&quot;external&quot; href=&quot;https://blog.laughingman7743.org/posts/flink-connector-gcp-bigquery/&quot;&gt;BigQuery&lt;/a&gt; and &lt;a rel=&quot;external&quot; href=&quot;https://blog.laughingman7743.org/posts/flink-connector-gcp-pubsub/&quot;&gt;Pub/Sub&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;This post follows Spanner changes into BigQuery, both as a current-row replica and as append-only history. It also covers the move from JDBC to the native client, change-stream state, mutation writes, snapshot reads, lookup joins, and batch limits.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Documentation: &lt;a rel=&quot;external&quot; href=&quot;https://flink-gcp.github.io/flink-connector-gcp/docs/connectors/datastream/spanner/&quot;&gt;https://flink-gcp.github.io/flink-connector-gcp/docs/connectors/datastream/spanner/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Maven: &lt;code&gt;io.github.flink-gcp:flink-connector-gcp-spanner:1.0.0&lt;/code&gt; (&lt;code&gt;1.0.0-1.20&lt;/code&gt; for Flink 1.20); for the SQL client, the &lt;code&gt;flink-sql-connector-gcp-spanner&lt;/code&gt; uber-jar from the &lt;a rel=&quot;external&quot; href=&quot;https://github.com/flink-gcp/flink-connector-gcp/releases/tag/v1.0.0&quot;&gt;release page&lt;/a&gt; goes into &lt;code&gt;lib/&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;from-a-jdbc-dialect-to-the-native-client&quot;&gt;From a JDBC dialect to the native client&lt;/h2&gt;
&lt;p&gt;I first tried to support Spanner through Flink&#39;s JDBC connector. In February 2025, I submitted a dialect and catalog upstream (&lt;a rel=&quot;external&quot; href=&quot;https://github.com/apache/flink-connector-jdbc/pull/156&quot;&gt;apache/flink-connector-jdbc#156&lt;/a&gt;, FLINK-37288). I appreciate the review feedback it received, but at the time of writing it remains open and unmerged. I understand that committer time is scarce; I still needed a working pipeline.&lt;/p&gt;
&lt;p&gt;The native Java client began as a way around that delay, but it enabled two features that could not have fit Flink&#39;s JDBC connector. The first is change streams. Spanner serves them through long-lived partition queries carrying changes, heartbeats and child-partition tokens. A source must manage concurrent queries and their lineage, starting children after their parents finish. That lifecycle fits a FLIP-27 coordinator rather than a bounded JDBC scan.&lt;/p&gt;
&lt;p&gt;The second is snapshot partitioning. Flink&#39;s JDBC source divides a scan using a configured column and bounds, then runs separate queries without a shared snapshot. Spanner&#39;s native batch read API lets the enumerator open one batch read-only transaction and ask the service to partition it. Each server-planned partition becomes a Flink split, and every subtask rejoins the same transaction.&lt;/p&gt;
&lt;p&gt;All subtasks therefore read one consistent snapshot. Spanner chooses the partitions from its physical storage layout, removing the need to select a split column and bounds in the job. I value having fewer ways to misconfigure a scan.&lt;/p&gt;
&lt;p&gt;The module provides a bounded snapshot source, an unbounded change-stream source and an at-least-once mutation sink through both DataStream and Flink SQL APIs. SQL also supports primary-key lookup joins. The same code supports GoogleSQL and PostgreSQL; in the DataStream API, the database determines the dialect rather than a builder option. These examples use GoogleSQL.&lt;/p&gt;
&lt;h2 id=&quot;spanner-to-bigquery-replication-in-one-sql-job&quot;&gt;Spanner to BigQuery replication in one SQL job&lt;/h2&gt;
&lt;p&gt;The pipeline promised in the Pub/Sub post connects operational data in Spanner to analytics in BigQuery. One Flink SQL job consumes a Spanner change stream and writes its upsert changelog through the BigQuery CDC sink. Current row contents flow continuously into BigQuery, with checkpoints governing progress instead of a batch export schedule. No intermediate Kafka pipeline is required.&lt;/p&gt;
&lt;p&gt;The Spanner table needs a change stream that captures new row values. I wrote this DDL to match the Flink schema below:&lt;/p&gt;
&lt;pre class=&quot;giallo z-l-code z-d-code&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;CREATE&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; TABLE&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt; Orders&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  OrderId  INT64 &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;NOT NULL&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  Customer STRING(&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;128&lt;/span&gt;&lt;span&gt;),&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;  Status&lt;/span&gt;&lt;span&gt;   STRING(&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;32&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;) &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;PRIMARY KEY&lt;/span&gt;&lt;span&gt; (OrderId);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;CREATE&lt;/span&gt;&lt;span&gt; CHANGE STREAM order_changes &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;FOR&lt;/span&gt;&lt;span&gt; Orders&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;OPTIONS (value_capture_type &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;NEW_ROW_AND_OLD_VALUES&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The Flink job uses three statements copied verbatim from the &lt;a rel=&quot;external&quot; href=&quot;https://flink-gcp.github.io/flink-connector-gcp/docs/examples/bigquery/#spanner-cdc-from-either-route&quot;&gt;source-backed example&lt;/a&gt;. CI validates the site&#39;s Flink SQL through the planner and executes its GoogleSQL examples against a Spanner emulator. The source table exposes three ordering coordinates as metadata:&lt;/p&gt;
&lt;pre class=&quot;giallo z-l-code z-d-code&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;SET&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;execution.checkpointing.interval&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;1 min&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;CREATE&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; TABLE&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt; order_changes&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  OrderId &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;BIGINT&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  Customer STRING,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;  Status&lt;/span&gt;&lt;span&gt; STRING,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  commit_timestamp TIMESTAMP_LTZ(&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;9&lt;/span&gt;&lt;span&gt;) METADATA &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;FROM&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;commit-timestamp&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt; VIRTUAL,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  record_sequence STRING METADATA &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;FROM&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;sequence&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt; VIRTUAL,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  mod_number &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;INT&lt;/span&gt;&lt;span&gt; METADATA &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;FROM&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;mod-number&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt; VIRTUAL,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;  PRIMARY KEY&lt;/span&gt;&lt;span&gt; (OrderId) &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;NOT&lt;/span&gt;&lt;span&gt; ENFORCED&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;) &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;WITH&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;connector&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;spanner&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;project&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;my-project&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;instance&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;my-instance&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;database&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;orders-db&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;table&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;Orders&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;scan.mode&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;change-stream&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;scan.change-stream.name&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;order_changes&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;scan.change-stream.changelog-mode&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;upsert&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;scan.startup.mode&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;latest&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The BigQuery destination accepts those coordinates as one row of writable metadata:&lt;/p&gt;
&lt;pre class=&quot;giallo z-l-code z-d-code&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;CREATE&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; TABLE&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt; current_orders&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  OrderId &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;BIGINT&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; NOT NULL&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  Customer STRING,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;  Status&lt;/span&gt;&lt;span&gt; STRING,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  change_sequence &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;ROW&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span&gt;commit_timestamp TIMESTAMP_LTZ(&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;9&lt;/span&gt;&lt;span&gt;), record_sequence STRING, mod_number &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;INT&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    METADATA &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;FROM&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;spanner-change-sequence&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;  PRIMARY KEY&lt;/span&gt;&lt;span&gt; (OrderId) &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;NOT&lt;/span&gt;&lt;span&gt; ENFORCED&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;) &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;WITH&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;connector&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;bigquery&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;project&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;my-project&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;dataset&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;analytics&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;table&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;current_orders&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;sink.cdc.enabled&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;true&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;sink.create-disposition&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;create-if-needed&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;sink.cdc.max-staleness&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;10 min&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;INSERT&lt;/code&gt; connects them:&lt;/p&gt;
&lt;pre class=&quot;giallo z-l-code z-d-code&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;INSERT INTO&lt;/span&gt;&lt;span&gt; current_orders&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; OrderId, Customer, &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;Status&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;       ROW&lt;/span&gt;&lt;span&gt;(commit_timestamp, record_sequence, mod_number)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; order_changes;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The DDL controls how the source represents changes and how BigQuery orders them.&lt;/p&gt;
&lt;p&gt;This pipeline requires &lt;code&gt;upsert&lt;/code&gt; mode. It emits keyed &lt;code&gt;INSERT&lt;/code&gt; and &lt;code&gt;UPDATE_AFTER&lt;/code&gt; rows and key-only &lt;code&gt;DELETE&lt;/code&gt; rows, matching the BigQuery CDC sink&#39;s contract. The alternative, &lt;code&gt;full&lt;/code&gt;, reconstructs complete retract rows and emits &lt;code&gt;UPDATE_BEFORE&lt;/code&gt; / &lt;code&gt;UPDATE_AFTER&lt;/code&gt; pairs. BigQuery&#39;s CDC sink rejects update-before rows. Other downstream operators, such as aggregations over a changing table, can use them.&lt;/p&gt;
&lt;p&gt;The Spanner capture type must also match. &lt;code&gt;upsert&lt;/code&gt; needs complete after-images, supplied by &lt;code&gt;NEW_ROW&lt;/code&gt; and &lt;code&gt;NEW_ROW_AND_OLD_VALUES&lt;/code&gt;. &lt;code&gt;OLD_AND_NEW_VALUES&lt;/code&gt; contains only changed columns and cannot supply the complete row. The source validates each data-change record against the DDL before emitting rows. An incompatible capture type therefore fails deserialization instead of producing partial rows.&lt;/p&gt;
&lt;p&gt;Checkpointing persists the change-stream position and flushes the BigQuery default stream. It therefore carries the pipeline&#39;s at-least-once guarantee. Without it, there is no saved position, and a restart begins at the configured start position again.&lt;/p&gt;
&lt;p&gt;The commit timestamp uses &lt;code&gt;TIMESTAMP_LTZ(9)&lt;/code&gt; to retain nanosecond precision, so this table declares no watermark. BigQuery&#39;s &lt;code&gt;_CHANGE_SEQUENCE_NUMBER&lt;/code&gt; encodes the three source coordinates, beginning with that timestamp. Flink watermark columns support only millisecond precision; truncating here could make two changes within one millisecond equal in the first sequence section.&lt;/p&gt;
&lt;p&gt;For jobs that prioritize event-time processing over replication fidelity, the docs instead show &lt;code&gt;TIMESTAMP_LTZ(3)&lt;/code&gt; with &lt;code&gt;SOURCE_WATERMARK()&lt;/code&gt;. This replication example preserves nanoseconds.&lt;/p&gt;
&lt;p&gt;The sequence coordinates let BigQuery handle replays and out-of-order arrival. Where they order two changes, an older replay cannot overwrite the newer value. This makes an at-least-once transport usable for the replica.&lt;/p&gt;
&lt;p&gt;They cannot create a total order that Spanner does not expose. Transactions updating disjoint column sets of one row can have equal coordinates. BigQuery resolves those ties by ingestion order.&lt;/p&gt;
&lt;p&gt;The source starts at &lt;code&gt;latest&lt;/code&gt;, so a fresh job captures changes committed after startup. An already populated table needs an initial snapshot followed by a handoff to a timestamp start; that bootstrap is outside this example. The result is an analytics replica of current row contents, without a byte-for-byte or transactionally consistent copy of the Spanner database.&lt;/p&gt;
&lt;p&gt;Install &lt;code&gt;flink-sql-connector-gcp-spanner&lt;/code&gt; and &lt;code&gt;flink-sql-connector-gcp-bigquery&lt;/code&gt; in the SQL client&#39;s classpath. Their dependencies are relocated so both uber-jars can share one &lt;code&gt;lib/&lt;/code&gt;.&lt;/p&gt;
&lt;h2 id=&quot;keeping-every-change-instead-of-the-latest-row&quot;&gt;Keeping every change instead of the latest row&lt;/h2&gt;
&lt;p&gt;The replica replaces each key&#39;s previous state. Other analytics needs that history: what changed and when, how often an order changed status, how long transitions took, or the input for a slowly changing dimension. The change stream already carries those events. To keep them, the job appends them to BigQuery instead of overwriting the previous row.&lt;/p&gt;
&lt;p&gt;This history pattern requires a DataStream job in this connector. SQL&#39;s &lt;code&gt;scan.mode = &#39;change-stream&#39;&lt;/code&gt; exposes a changelog, but SQL expressions cannot access row kind to turn each change into a plain inserted log record. An append-only destination therefore rejects the updating query during planning.&lt;/p&gt;
&lt;p&gt;The DataStream deserializer receives changes before the Table layer interprets them as a changelog. Each typed &lt;code&gt;DataChangeRecord&lt;/code&gt; contains the commit timestamp, table, modification type, transaction id and record sequence, plus each mod&#39;s keys, new values and old values as normalized JSON. Emitting one row per mod makes these fields ordinary data columns.&lt;/p&gt;
&lt;p&gt;Those rows can use BigQuery&#39;s append path from the first post: the at-least-once Storage Write API method, or &lt;code&gt;FILE_LOADS&lt;/code&gt; for free ingestion at higher volumes. The commit timestamp is a natural time-partitioning column.&lt;/p&gt;
&lt;p&gt;Capture semantics determine the contents. With &lt;code&gt;NEW_ROW_AND_OLD_VALUES&lt;/code&gt;, inserts and updates include the complete new row, preserving successive states. Old values cover the changed columns, so finding what changed can use a column filter instead of a self-join with the previous row. Deletes have their own modification type and close that key&#39;s history.&lt;/p&gt;
&lt;p&gt;Delivery remains at-least-once, so the history can contain duplicates. Each output row has an identity: server transaction id, record sequence and mod number, the mod&#39;s zero-based position in the record. Queries or a scheduled &lt;code&gt;MERGE&lt;/code&gt; can deduplicate on that identity. This path needs neither the CDC sink nor sequence metadata because it does not overwrite earlier records.&lt;/p&gt;
&lt;h2 id=&quot;where-the-change-stream-source-keeps-its-state&quot;&gt;Where the change-stream source keeps its state&lt;/h2&gt;
&lt;p&gt;Spanner divides a change stream into partitions that split and merge over time. Each partition is a streaming query whose child-partition records identify the tokens to read next. The source must track this lineage, retaining every token and waiting for all parents before reading a child. Where that state lives determines much of the connector&#39;s operational work.&lt;/p&gt;
&lt;p&gt;This connector keeps the partition ledger entirely in Flink checkpoints. The coordinator begins with the null partition token and schedules a child only after every parent naming it has finished. It checkpoints the unfinished topology. Finished parents remain as compact proofs until their children can be scheduled, then are removed.&lt;/p&gt;
&lt;p&gt;No metadata table is created, and progress tracking needs no write permission. The workload principal needs database read access, &lt;code&gt;spanner.databases.select&lt;/code&gt; and the client&#39;s session permissions. A checkpoint restore brings the partition ledger back with the job.&lt;/p&gt;
&lt;p&gt;Apache Beam&#39;s &lt;code&gt;SpannerIO.readChangeStream&lt;/code&gt;, used by Google&#39;s Dataflow templates, instead creates a partition metadata table in a designated Spanner database. I checked this against Google&#39;s Dataflow connector documentation on 2026-09-03. That table is an additional stateful resource to provision, grant write access to and clean up.&lt;/p&gt;
&lt;p&gt;The source uses each record&#39;s Spanner commit timestamp as its Flink event timestamp. The coordinator owns one watermark: the minimum across all unfinished partitions, including discovered, queued and running partitions. It broadcasts that value to every reader.&lt;/p&gt;
&lt;p&gt;Quiet partitions advance through heartbeats, with a default interval of two seconds and a configurable range from one second to five minutes. They are not marked idle, because removing them from the minimum could make later records from those partitions late.&lt;/p&gt;
&lt;p&gt;Heartbeat conversion subtracts one millisecond after truncation. A later nanosecond timestamp can fall in the same Flink millisecond, so the subtraction prevents an on-time record from being classified as late. These are the source-provided timestamps and watermarks. A job that supplies its own watermark strategy chooses that strategy&#39;s behavior instead.&lt;/p&gt;
&lt;p&gt;The source provides at-least-once delivery. A reader checkpoints each partition&#39;s greatest consumed record timestamp and resumes inclusively at that timestamp. Several records can share it; starting after it could skip one. Records at the recovery boundary can therefore repeat.&lt;/p&gt;
&lt;p&gt;Deduplicate on server transaction id and record sequence where uniqueness is required. Commit timestamps alone are not unique. In the earlier replication example, sequence metadata handles those boundary replays.&lt;/p&gt;
&lt;p&gt;The change stream retains records for the period configured in its DDL. Restoring an expired partition position fails the job by default. The explicit opt-in &lt;code&gt;scan.resume-fallback.mode&lt;/code&gt; discards the entire stale ledger and starts fresh, accepting loss of the unavailable interval. I prefer to make that availability-versus-history decision in the job configuration.&lt;/p&gt;
&lt;p&gt;Before releasing readers, the coordinator reads stream metadata and logs its watch scope, effective retention and value-capture type. It warns about an explicit watched-column list, since newly added columns will not be included automatically, and rejects unsupported partition modes at startup.&lt;/p&gt;
&lt;p&gt;The change-stream API is &lt;code&gt;@PublicEvolving&lt;/code&gt; and may change in a minor release, with changes announced in release notes. The rest of the connector is frozen by the japicmp gate described in the release post.&lt;/p&gt;
&lt;h2 id=&quot;a-sink-where-the-mutation-names-its-own-table&quot;&gt;A sink where the mutation names its own table&lt;/h2&gt;
&lt;p&gt;The sink is configured with a database. Its serializer returns a Spanner &lt;code&gt;Mutation&lt;/code&gt;, which names the target table, so one sink can write to every table the serializer chooses. The other four connectors take destination resolvers on their builders. Spanner needs none because the mutation already carries the routing information.&lt;/p&gt;
&lt;p&gt;The sink calls &lt;code&gt;batchWriteAtLeastOnce&lt;/code&gt; with one mutation per mutation group. Spanner reports status per group, so each rejection identifies one input record. The sink can retry, route or fail that record individually. A plain commit would reject the entire batch for one bad row.&lt;/p&gt;
&lt;p&gt;The Spanner client does not retry the batch-write RPC: its generated settings have an empty retryable-code set. The sink therefore owns the retry loop. It retries &lt;code&gt;ABORTED&lt;/code&gt;, &lt;code&gt;UNAVAILABLE&lt;/code&gt;, &lt;code&gt;DEADLINE_EXCEEDED&lt;/code&gt; and &lt;code&gt;RESOURCE_EXHAUSTED&lt;/code&gt; within its own budget.&lt;/p&gt;
&lt;p&gt;A retry includes groups with transient failures and groups for which the service returned no status, as can happen when the response stream fails partway through. Groups confirmed as applied are not resent. The sink exposes this work through &lt;code&gt;mutationsRetried&lt;/code&gt; and transient-error counters, which is less visible when sibling connectors rely on SDK retries.&lt;/p&gt;
&lt;p&gt;The configurable failure handler normally receives only &lt;code&gt;ALREADY_EXISTS&lt;/code&gt;, such as a replayed insert or unique-index collision, and &lt;code&gt;INVALID_ARGUMENT&lt;/code&gt;. Schema violations fail the job by default: a &lt;code&gt;NULL&lt;/code&gt; in a &lt;code&gt;NOT NULL&lt;/code&gt; column, an over-long value, or a foreign-key or &lt;code&gt;CHECK&lt;/code&gt; failure. These often indicate a faulty mapping affecting every record of that shape. Dropping records individually could conceal the mapping bug.&lt;/p&gt;
&lt;p&gt;System state can also cause the same status family. If a database&#39;s CMEK key is disabled or unreachable, writes fail with &lt;code&gt;FAILED_PRECONDITION&lt;/code&gt;. Routing that status to a dropping handler could discard the whole stream during a key incident.&lt;/p&gt;
&lt;p&gt;For occasional schema-invalid input, &lt;code&gt;constraintViolationPolicy(ROUTE_TO_FAILURE_HANDLER)&lt;/code&gt; makes those statuses eligible for dropping or dead-lettering. The default preserves records for source replay after restart. The opt-in allows progress by routing the rejected records.&lt;/p&gt;
&lt;p&gt;Spanner&#39;s batch write has no replay protection: the service documents that a mutation may be applied more than once. The serializer&#39;s chosen operation determines the effect:&lt;/p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Operation&lt;/th&gt;&lt;th&gt;Same mutation replayed&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;insertOrUpdate&lt;/code&gt;, &lt;code&gt;replace&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Idempotent&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;delete&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Idempotent; deleting an absent row is simply applied&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;insert&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Refused with &lt;code&gt;ALREADY_EXISTS&lt;/code&gt;, routed to the failure handler&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;update&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Idempotent, unless the row was deleted in between: then &lt;code&gt;NOT_FOUND&lt;/code&gt; fails the job&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;&lt;code&gt;NOT_FOUND&lt;/code&gt; cannot distinguish a missing row from a missing table. Routing it could silently discard every record when the table name is wrong. For updates that may target absent rows, I use &lt;code&gt;insertOrUpdate&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;SQL selects the operation from the DDL. A declared primary key produces an upsert sink using &lt;code&gt;insertOrUpdate&lt;/code&gt;, with deletes built from the declared key columns. A keyless table accepts only insert input and uses &lt;code&gt;insert&lt;/code&gt;, preserving duplicate-key errors rather than assuming an unknown physical key supports upserts.&lt;/p&gt;
&lt;p&gt;Idempotence of an individual mutation does not guarantee the latest value wins. The writer uses separate mutation groups, which Spanner may apply in an unspecified order. Successive writes to one key can therefore finish out of input order. This affects pipelines writing to Spanner; the earlier replication example writes to BigQuery.&lt;/p&gt;
&lt;h2 id=&quot;reading-a-table-at-one-snapshot&quot;&gt;Reading a table at one snapshot&lt;/h2&gt;
&lt;p&gt;The bounded source reads one database snapshot and finishes. It can run inside a streaming pipeline, for example to load a Spanner table for a join with an unbounded stream. Server-planned partitions all rejoin the same batch transaction, so every subtask reads the same consistent snapshot.&lt;/p&gt;
&lt;p&gt;Snapshot reads provide at-least-once delivery with a duplicate window of one partition. Partitioned-query row order is not contractual, so the source cannot resume at a position within a partition. Checkpoints record the partitions still held by a reader, and recovery rereads them from the start.&lt;/p&gt;
&lt;p&gt;A partition cancelled by Flink during a running job is also reread. The &lt;code&gt;partitionsReread&lt;/code&gt; metric can therefore explain duplicates even when the job has not failed. A pipeline requiring unique rows should deduplicate downstream on the primary key.&lt;/p&gt;
&lt;p&gt;The snapshot expires after the database&#39;s &lt;code&gt;version_retention_period&lt;/code&gt;, one hour by default and configurable up to a week. A backfill must finish within that period or use a database whose retention was raised beforehand. A savepoint cannot resume an expired snapshot.&lt;/p&gt;
&lt;p&gt;Two options reduce a backfill&#39;s impact on serving traffic. &lt;code&gt;rpcPriority(LOW)&lt;/code&gt; costs nothing extra: reads stay on the instance&#39;s compute but are shed first at capacity. Data Boost, enabled with &lt;code&gt;dataBoostEnabled(true)&lt;/code&gt;, uses separate billed compute. It needs &lt;code&gt;spanner.databases.useDataBoost&lt;/code&gt;, which is absent from &lt;code&gt;roles/spanner.databaseReader&lt;/code&gt;, and has its own concurrency quota.&lt;/p&gt;
&lt;p&gt;In August 2026, the gated suite exercised Data Boost end to end on a 100-processing-unit &lt;code&gt;STANDARD&lt;/code&gt; instance, the cheapest edition. That run confirmed that a Data Boost backfill does not require an edition upgrade from &lt;code&gt;STANDARD&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;SQL scans push projection into the requested columns and convert predicates on consecutive primary-key columns to exact key ranges. Flink evaluates the remaining predicates. &lt;code&gt;scan.index&lt;/code&gt; selects a secondary index, checked against live metadata at the scan snapshot. Broader filter pushdown is planned for the &lt;a rel=&quot;external&quot; href=&quot;https://github.com/flink-gcp/flink-connector-gcp/milestone/10&quot;&gt;v1.1.0 milestone&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id=&quot;lookup-joins-from-plain-sql&quot;&gt;Lookup joins from plain SQL&lt;/h2&gt;
&lt;p&gt;Lookup joins are the read feature I use most often. An event carries an account id, and a Spanner table holds the account&#39;s current data. A processing-time temporal join enriches the event through a point read, with a cache in front, without exporting the table. The Spanner dimension table has a composite primary key:&lt;/p&gt;
&lt;pre class=&quot;giallo z-l-code z-d-code&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;CREATE&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; TABLE&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt; accounts&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  region STRING(&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;16&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;NOT NULL&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  account INT64 &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;NOT NULL&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;  name&lt;/span&gt;&lt;span&gt; STRING(&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;128&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;) &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;PRIMARY KEY&lt;/span&gt;&lt;span&gt; (region, account);&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The Flink job declares that table and joins it to events from Pub/Sub. This combines two connectors from the series. The example adapts the &lt;a rel=&quot;external&quot; href=&quot;https://flink-gcp.github.io/flink-connector-gcp/docs/examples/spanner/#joining-a-composite-key-lookup-table&quot;&gt;source-backed lookup example&lt;/a&gt;, validated in CI; I added the Pub/Sub event stream:&lt;/p&gt;
&lt;pre class=&quot;giallo z-l-code z-d-code&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;CREATE&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; TABLE&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt; order_events&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  account   &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;BIGINT&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  region    STRING,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  amount    &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;INT&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  proc_time &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;AS&lt;/span&gt;&lt;span&gt; PROCTIME&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;) &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;WITH&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;connector&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;    =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;pubsub&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;project&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;      =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;my-project&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;subscription&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;account-events-sub&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;format&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;       =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;json&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;CREATE&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; TABLE&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt; accounts&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  region  STRING,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  account &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;BIGINT&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;  name&lt;/span&gt;&lt;span&gt;    STRING,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;  PRIMARY KEY&lt;/span&gt;&lt;span&gt; (region, account) &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;NOT&lt;/span&gt;&lt;span&gt; ENFORCED&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;) &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;WITH&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;connector&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;spanner&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;project&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;   =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;my-project&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;instance&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;  =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;my-instance&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;database&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;  =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;orders-db&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;table&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;     =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;accounts&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;lookup.async&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;true&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;lookup.cache&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;PARTIAL&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;lookup.partial-cache.expire-after-write&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;10 min&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;SELECT&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt; e&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;region&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;e&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;account&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;e&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;amount&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;a&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;name&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; order_events &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;AS&lt;/span&gt;&lt;span&gt; e&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;LEFT JOIN&lt;/span&gt;&lt;span&gt; accounts &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;FOR&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; SYSTEM_TIME&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; AS&lt;/span&gt;&lt;span&gt; OF &lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;e&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;proc_time&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; AS&lt;/span&gt;&lt;span&gt; a&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;  ON&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt; e&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;account&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt; a&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;account&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; AND&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt; e&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;region&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt; a&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;region&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The join equality must include every declared primary-key column because a point read needs the complete key. Composite keys are encoded in &lt;code&gt;PRIMARY KEY&lt;/code&gt; declaration order, regardless of the join-predicate order. That declaration must match the physical Spanner primary key or the lookup will address a different key.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;WITH&lt;/code&gt; options control lookup behavior. &lt;code&gt;lookup.async&lt;/code&gt; selects asynchronous rather than synchronous point reads. The lookup cache uses Flink&#39;s standard &lt;code&gt;NONE&lt;/code&gt; and &lt;code&gt;PARTIAL&lt;/code&gt; modes, with expiry, size and missing-key settings. I generally use &lt;code&gt;PARTIAL&lt;/code&gt; with a write expiry such as the example&#39;s ten minutes. Frequently read keys stay cached, at the cost of up to that much staleness.&lt;/p&gt;
&lt;p&gt;The source rejects &lt;code&gt;FULL&lt;/code&gt; because a scan-backed cache would require snapshot and refresh semantics it does not provide. The next connector in the series, Bigtable, offers that mode.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;lookup.max-retries&lt;/code&gt; retries &lt;code&gt;ABORTED&lt;/code&gt;, &lt;code&gt;DEADLINE_EXCEEDED&lt;/code&gt; and &lt;code&gt;UNAVAILABLE&lt;/code&gt;. Of these, the client library retries only &lt;code&gt;UNAVAILABLE&lt;/code&gt;, so the connector option supplies further attempts for the other two. It excludes &lt;code&gt;RESOURCE_EXHAUSTED&lt;/code&gt;: the client already retries that status while respecting the server-requested delay, which an immediate connector retry would undermine.&lt;/p&gt;
&lt;h2 id=&quot;batch-limits-that-are-correctness-bounds-first&quot;&gt;Batch limits that are correctness bounds first&lt;/h2&gt;
&lt;p&gt;A request-level rejection affects every mutation in the request, so the sink must bound each batch. It flushes when any of three limits is reached. The defaults, inherited from Apache Beam, are 5,000 cells, 500 mutations and 1 MiB, all well below service limits.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;maxBatchCells&lt;/code&gt; accounts for secondary indexes. Spanner counts one cell for the table column plus one for each index containing it. The sink reads &lt;code&gt;INFORMATION_SCHEMA&lt;/code&gt; when the writer opens and uses that schema to estimate each mutation&#39;s cost. This requires &lt;code&gt;spanner.databases.select&lt;/code&gt; in addition to write access.&lt;/p&gt;
&lt;p&gt;The 5,000-cell default is sixteen times below Spanner&#39;s published 80,000 per-group figure. That headroom covers undercounting for tables created after startup or hidden from the writer&#39;s role, whose index entries cannot be included. Raising the limit consumes that margin.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;maxBatchBytes&lt;/code&gt; protects the request-level size limit. Google&#39;s documentation can be read as specifying either 10 MiB or 100 MiB, so the gated suite tested oversized requests. The service accepted roughly 12 MiB and rejected roughly 110 MiB, naming a limit of 104,857,600 bytes, exactly 100 MiB.&lt;/p&gt;
&lt;p&gt;The connector estimates size because the client cannot report a mutation&#39;s wire size, and the estimate generally reads low. &lt;code&gt;BYTES&lt;/code&gt; is counted at its base64 length: Spanner wraps it in a protobuf &lt;code&gt;Value&lt;/code&gt;, which has no bytes kind. The measurement showed 83,886,080 raw bytes becoming 111,852,884 on the wire, roughly four thirds of the input.&lt;/p&gt;
&lt;p&gt;Raising one limit may have no effect if another still binds first. For example, &lt;code&gt;maxBatchMutations&lt;/code&gt; cannot be reached above &lt;code&gt;maxBatchCells&lt;/code&gt;, because every mutation costs at least one cell. Building those options logs a warning naming both values where the job&#39;s &lt;code&gt;main&lt;/code&gt; runs. Compare &lt;code&gt;bufferedCells&lt;/code&gt; and &lt;code&gt;bufferedBytes&lt;/code&gt; when tuning to see which limit triggers the flush.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;batchWriteTimeout&lt;/code&gt; bounds a complete attempt. Its default is 30 seconds, replacing the client&#39;s one-hour timeout for this RPC only. Including retries, the default worst case is 369.375 seconds: ten 30-second attempts and up to 69.375 seconds of jittered backoff.&lt;/p&gt;
&lt;p&gt;A checkpoint may encounter that loop twice. A record-triggered flush can already be running when the barrier arrives, followed by another invocation for the checkpoint flush. Choose the checkpoint timeout to cover that work, alignment and the rest of the job.&lt;/p&gt;
&lt;p&gt;Change-stream capacity is source parallelism multiplied by &lt;code&gt;scan.max-concurrent-queries-per-subtask&lt;/code&gt;, default 8. This bounds the connector&#39;s open partition queries; it is not a Spanner quota.&lt;/p&gt;
&lt;p&gt;If &lt;code&gt;activeChangeStreamQueries&lt;/code&gt; stays at that product while queued or unassigned partition lag grows, increase parallelism or the per-subtask bound. Alert when either lag approaches stream retention, since expired records cannot be recovered. The per-subtask bound changes reader capacity. Changing parallelism requires restarting from a checkpoint or savepoint so Flink can redistribute ownership.&lt;/p&gt;
&lt;h2 id=&quot;testing-and-what-the-emulator-cannot-show&quot;&gt;Testing, and what the emulator cannot show&lt;/h2&gt;
&lt;p&gt;The weekly gated real-GCP suite establishes service behavior. Spanner&#39;s emulator is useful but has specific differences. It supports the sink&#39;s &lt;code&gt;BatchWrite&lt;/code&gt; RPC only from v1.5.31; older versions return &lt;code&gt;UNIMPLEMENTED&lt;/code&gt;. The repository therefore pins its image separately from the other connectors&#39; emulator bundle.&lt;/p&gt;
&lt;p&gt;The emulator also serializes concurrent read-write transactions. In measured reads, it planned exactly two partitions for every table, ignored both partition hints, and applied a stricter partitionability check with a less useful error message than the service. The &lt;a rel=&quot;external&quot; href=&quot;https://flink-gcp.github.io/flink-connector-gcp/docs/connectors/datastream/spanner/#reading-against-the-emulator&quot;&gt;connector page&lt;/a&gt; records the full deviation table measured on 2026-08-10.&lt;/p&gt;
&lt;p&gt;The sink&#39;s rejection-status table was measured against both environments, including &lt;code&gt;NOT NULL&lt;/code&gt; violations, missing tables and replayed inserts. The gated suite asserts each result so changes on either side are detected.&lt;/p&gt;
&lt;p&gt;Each test class creates a regional 100-processing-unit &lt;code&gt;STANDARD&lt;/code&gt; instance and deletes it afterwards. Names include creation time, allowing the next run and a scheduled sweep to reclaim instances left by interrupted tests. Instances are not retained between runs because they bill for their entire lifetime.&lt;/p&gt;
&lt;p&gt;The measured change-stream recovery run delivered all 5,000 unique mutations after an intentional failure following a checkpoint. It also delivered 500 repeats at the inclusive recovery boundary, matching the documented duplicate window. A subsequent savepoint restore consumed a mutation written while the job was stopped.&lt;/p&gt;
&lt;h2 id=&quot;what-s-next-for-this-connector&quot;&gt;What&#39;s next for this connector&lt;/h2&gt;
&lt;p&gt;The &lt;a rel=&quot;external&quot; href=&quot;https://github.com/flink-gcp/flink-connector-gcp/milestone/10&quot;&gt;v1.1.0 milestone&lt;/a&gt; extends bounded-scan filter pushdown. Further work is driven by reports, especially from workloads unlike mine: &lt;a rel=&quot;external&quot; href=&quot;https://github.com/flink-gcp/flink-connector-gcp/issues.&quot;&gt;https://github.com/flink-gcp/flink-connector-gcp/issues.&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;The next post covers Bigtable: lookup joins with a full-table cache, change streams, and the write extensions planned for v1.1.0.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;&lt;em&gt;This is an independent open-source project. It is not affiliated with, endorsed by, or supported by the Apache Software Foundation or Google. Apache Flink, Flink, and the Flink logo are trademarks of the Apache Software Foundation.&lt;/em&gt;&lt;/p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>The Pub/Sub connector in flink-connector-gcp, part two of the connector series</title>
        <published>2026-09-03T00:13:09+09:00</published>
        <updated>2026-09-05T21:04:38+09:00</updated>
        
        <author>
          <name>Unknown</name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://blog.laughingman7743.org/posts/flink-connector-gcp-pubsub/"/>
        <id>https://blog.laughingman7743.org/posts/flink-connector-gcp-pubsub/</id>
        
        <content type="html" xml:base="https://blog.laughingman7743.org/posts/flink-connector-gcp-pubsub/">&lt;p&gt;This is the second post in the &lt;a rel=&quot;external&quot; href=&quot;https://github.com/flink-gcp/flink-connector-gcp&quot;&gt;flink-connector-gcp&lt;/a&gt; series, about the Apache Flink connectors for Google Cloud I released as 1.0.0. &lt;a rel=&quot;external&quot; href=&quot;https://blog.laughingman7743.org/posts/flink-connector-gcp-1-0-0/&quot;&gt;The release post&lt;/a&gt; introduces the project, and the &lt;a rel=&quot;external&quot; href=&quot;https://blog.laughingman7743.org/posts/flink-connector-gcp-bigquery/&quot;&gt;previous post&lt;/a&gt; covers BigQuery.&lt;/p&gt;
&lt;p&gt;This post follows a Pub/Sub order pipeline in Flink SQL and a DataStream sink that routes records to different topics. It also explains the source&#39;s streaming-pull design, seek-on-start behavior, and tuning for checkpoint-based acknowledgment.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Documentation: &lt;a rel=&quot;external&quot; href=&quot;https://flink-gcp.github.io/flink-connector-gcp/docs/connectors/datastream/pubsub/&quot;&gt;https://flink-gcp.github.io/flink-connector-gcp/docs/connectors/datastream/pubsub/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Maven: &lt;code&gt;io.github.flink-gcp:flink-connector-gcp-pubsub:1.0.0&lt;/code&gt; (&lt;code&gt;1.0.0-1.20&lt;/code&gt; for Flink 1.20); for the SQL client, the &lt;code&gt;flink-sql-connector-gcp-pubsub&lt;/code&gt; uber-jar from the &lt;a rel=&quot;external&quot; href=&quot;https://github.com/flink-gcp/flink-connector-gcp/releases/tag/v1.0.0&quot;&gt;release page&lt;/a&gt; goes into &lt;code&gt;lib/&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Two Pub/Sub connectors already existed when I started. Apache&#39;s &lt;a rel=&quot;external&quot; href=&quot;https://github.com/apache/flink-connector-gcp-pubsub&quot;&gt;flink-connector-gcp-pubsub&lt;/a&gt; uses the legacy &lt;code&gt;SourceFunction&lt;/code&gt; API over unary &lt;code&gt;Pull&lt;/code&gt;. At the time of writing, its last main-branch commit was in November 2024. That commit added a Table API sink (FLINK-36077), but a Table API source is still missing.&lt;/p&gt;
&lt;p&gt;Google&#39;s connector in its &lt;a rel=&quot;external&quot; href=&quot;https://github.com/GoogleCloudPlatform/pubsub/tree/master/flink-connector&quot;&gt;pubsub repository&lt;/a&gt; is well built on the StreamingPull API and moved to Flink 2.2 in June 2026. It remains DataStream-only and, at the time of writing, is not published to a Maven repository.&lt;/p&gt;
&lt;p&gt;I needed complete Flink SQL support, with both source and sink tables, and message attributes queryable as columns. I also wanted a start-position table option, a policy that sends poison messages through Pub/Sub&#39;s dead-lettering, and a sink that chooses a topic for each record at runtime.&lt;/p&gt;
&lt;h2 id=&quot;why-the-source-consumes-through-streaming-pull&quot;&gt;Why the source consumes through streaming pull&lt;/h2&gt;
&lt;p&gt;Apache&#39;s connector chose unary pull during its review (&lt;a rel=&quot;external&quot; href=&quot;https://github.com/apache/flink/pull/6594&quot;&gt;FLINK-9311&lt;/a&gt;). An earlier implementation used the high-level &lt;code&gt;Subscriber&lt;/code&gt;, but the final design called unary &lt;code&gt;Pull&lt;/code&gt; on the blocking gRPC stub. The review gave three reasons: backpressure blocking &lt;code&gt;sourceContext.collect()&lt;/code&gt; naturally stops the pull loop; users avoid tuning flow control; and removing the intermediate queue reduces memory use and latency. I think that choice suited its source framework.&lt;/p&gt;
&lt;p&gt;Two of those reasons depend on &lt;code&gt;SourceFunction&lt;/code&gt;. Its hand-written &lt;code&gt;run()&lt;/code&gt; loop needs queues and lock coordination to bridge an asynchronous client. In FLIP-27, Flink provides that bridge: &lt;code&gt;SplitReader.fetch()&lt;/code&gt; is a pull loop, while &lt;code&gt;SourceReaderBase&lt;/code&gt; owns the element queue and backpressure between the fetcher and task thread.&lt;/p&gt;
&lt;p&gt;Flow-control tuning remains a concern. This connector exposes the subscriber settings rather than hiding them, and the tuning section explains how to size them.&lt;/p&gt;
&lt;p&gt;Lease extension and ordering led me to the high-level client. Unary &lt;code&gt;Pull&lt;/code&gt; returns acknowledgment ids without managing their leases. Apache&#39;s connector does not extend deadlines, so messages must be acknowledged within the subscription deadline, at most 600 seconds. Its documentation accordingly requires a much shorter checkpoint interval. The high-level client extends leases automatically, up to an hour by default, which better suits checkpoint-based acknowledgment.&lt;/p&gt;
&lt;p&gt;The high-level client also provides sequential dispatch per ordering key. Using unary pull would require implementing that behavior separately. Streaming pull does cost more gRPC CPU. The FLINK-9311 review measured the synchronous design at 3,000 messages per second with one-second checkpoints and 20,000 with 50-millisecond checkpoints, showing its dependence on checkpoint frequency.&lt;/p&gt;
&lt;p&gt;Each split represents one streaming-pull connection to a subscription. It stores no progress position because Pub/Sub has no offset to resume from. In default unordered mode, the plan creates &lt;code&gt;max(|subscriptions|, parallelism)&lt;/code&gt; splits so every subscription is consumed and every subtask can work. Per-key ordering uses exactly one split per subscription.&lt;/p&gt;
&lt;p&gt;The plan is deterministic from the subscription list, ordering mode and parallelism, and is recomputed on each start. Restoring a savepoint with a different parallelism therefore reassigns the splits. A MiniCluster test verifies rescaling in both directions.&lt;/p&gt;
&lt;p&gt;Pub/Sub holds delivery state on the server. The source acknowledges a message when the checkpoint covering its emission completes. A failure before that leaves the message unacknowledged for redelivery, giving at-least-once delivery. Checkpoints contain no message data, so source recovery does not require retaining one.&lt;/p&gt;
&lt;p&gt;Checkpointing is still mandatory. Without it, messages remain unacknowledged until subscriber flow control fills and consumption stalls. If messages are waiting and no checkpoint arrives within ten minutes, the reader fails the job with an error naming &lt;code&gt;execution.checkpointing.interval&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The reader checks behavior because it cannot reliably inspect the configured interval. &lt;code&gt;env.enableCheckpointing(...)&lt;/code&gt; updates the job configuration, while the reader receives the TaskManager configuration. An absent interval there does not prove checkpointing is disabled.&lt;/p&gt;
&lt;h2 id=&quot;the-order-pipeline-in-flink-sql&quot;&gt;The order pipeline in Flink SQL&lt;/h2&gt;
&lt;p&gt;The example consumes JSON order events from a topic, uses the publish timestamp as event time, calculates hourly totals per customer, and publishes them to another topic. Pub/Sub-specific behavior is expressed through columns and options. This example assumes the subscription exists; a later section covers resource creation. The source table is:&lt;/p&gt;
&lt;pre class=&quot;giallo z-l-code z-d-code&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;CREATE&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; TABLE&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt; incoming_orders&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  order_id     STRING,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  customer_id  STRING,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  amount       &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;INT&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  message_id   STRING              METADATA &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;FROM&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;message-id&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;   VIRTUAL,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  publish_time TIMESTAMP_LTZ(&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;3&lt;/span&gt;&lt;span&gt;)    METADATA &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;FROM&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;publish-time&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt; VIRTUAL,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  attrs        MAP&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span&gt;STRING, STRING&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;&amp;gt;&lt;/span&gt;&lt;span&gt; METADATA &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;FROM&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;attributes&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;   VIRTUAL,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  ordering_key STRING              METADATA &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;FROM&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;ordering-key&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt; VIRTUAL,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  subscription STRING              METADATA &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;FROM&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;subscription&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt; VIRTUAL,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  WATERMARK &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;FOR&lt;/span&gt;&lt;span&gt; publish_time &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;AS&lt;/span&gt;&lt;span&gt; publish_time &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;-&lt;/span&gt;&lt;span&gt; INTERVAL &lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;5&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; SECOND&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;) &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;WITH&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;connector&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;    =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;pubsub&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;project&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;      =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;my-project&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;subscription&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;orders-sub&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;format&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;       =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;json&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A Pub/Sub message contains a payload, attributes and an ordering key. &lt;code&gt;format&lt;/code&gt; decodes the payload into physical columns. The remaining fields are exposed as metadata.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;publish-time&lt;/code&gt; records when the service received the message, truncated to milliseconds and never rounded up. It provides an event-time column for the watermark without requiring a timestamp in the payload.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;attributes&lt;/code&gt; is always a map, empty when the message has none. Queries can filter with &lt;code&gt;WHERE attrs[&#39;channel&#39;] = &#39;mobile&#39;&lt;/code&gt; or group by an attribute. This avoids writing a custom deserializer, one of my original requirements.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;ordering-key&lt;/code&gt; is &lt;code&gt;NULL&lt;/code&gt; when no key is set. Pub/Sub represents that absence as an empty string; the connector maps it to SQL null.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;subscription&lt;/code&gt; contains the full resource name, such as &lt;code&gt;projects/my-project/subscriptions/orders-sub&lt;/code&gt;. The configuration option accepts a bare id, but the metadata uses the API&#39;s resource-name form, suitable for joins with audit logs or Cloud Asset Inventory. Consequently, &lt;code&gt;WHERE subscription = &#39;orders-sub&#39;&lt;/code&gt; matches nothing, a distinction emphasized in the documentation.&lt;/p&gt;
&lt;p&gt;The sink maps a column back to message attributes:&lt;/p&gt;
&lt;pre class=&quot;giallo z-l-code z-d-code&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;CREATE&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; TABLE&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt; customer_totals&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  customer_id STRING,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  total       &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;INT&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  attrs       MAP&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span&gt;STRING, STRING&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;&amp;gt;&lt;/span&gt;&lt;span&gt; METADATA &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;FROM&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;attributes&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;) &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;WITH&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;connector&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;pubsub&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;project&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;   =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;my-project&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;topic&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;     =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;customer-totals&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;format&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;    =&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;json&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;INSERT INTO&lt;/span&gt;&lt;span&gt; customer_totals&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; customer_id,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;       CAST&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;SUM&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;amount) &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;AS&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; INT&lt;/span&gt;&lt;span&gt;),&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;       MAP&lt;/span&gt;&lt;span&gt;[&amp;#39;window-start&amp;#39;, CAST(window_start AS STRING)]&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;FROM&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; TABLE&lt;/span&gt;&lt;span&gt;(TUMBLE(&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;TABLE&lt;/span&gt;&lt;span&gt; incoming_orders, DESCRIPTOR(publish_time), INTERVAL &lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; HOUR&lt;/span&gt;&lt;span&gt;))&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;GROUP BY&lt;/span&gt;&lt;span&gt; window_start, window_end, customer_id;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Both tables adapt the &lt;a rel=&quot;external&quot; href=&quot;https://flink-gcp.github.io/flink-connector-gcp/docs/examples/pubsub/&quot;&gt;source-backed examples&lt;/a&gt;, which CI validates through Flink&#39;s planner. The combination is mine.&lt;/p&gt;
&lt;p&gt;The window TVF makes the totals insert-only, which this sink requires. Pub/Sub cannot express retractions. An updating query such as a plain &lt;code&gt;GROUP BY customer_id&lt;/code&gt; therefore fails during planning, preventing update and delete rows from being published as ordinary messages.&lt;/p&gt;
&lt;p&gt;The source instead adopts the format&#39;s changelog mode. A changelog format can therefore travel over Pub/Sub, although the at-least-once transport may redeliver a retraction.&lt;/p&gt;
&lt;p&gt;This pipeline uses event time without requesting ordered delivery. The watermark permits five seconds of lateness in this example, a bound to size for the topic. Arrival order within that bound does not change the hourly totals. Transport-level ordering addresses a narrower requirement and is covered below.&lt;/p&gt;
&lt;p&gt;A null key or value in the attributes map fails the write. Pub/Sub supports neither, and silently removing an entry would lose data without the query knowing. Filter such entries in the query before writing.&lt;/p&gt;
&lt;p&gt;Install the &lt;code&gt;flink-sql-connector-gcp-pubsub&lt;/code&gt; uber-jar in Flink&#39;s &lt;code&gt;lib/&lt;/code&gt; or load it with &lt;code&gt;ADD JAR&lt;/code&gt;. Its bundled dependencies are relocated so it can coexist with the BigQuery and Bigtable uber-jars.&lt;/p&gt;
&lt;h2 id=&quot;a-fan-out-sink-that-routes-on-record-content&quot;&gt;A fan-out sink that routes on record content&lt;/h2&gt;
&lt;p&gt;Dynamic destinations were my main reason for building this sink. A resolver chooses a topic from each record&#39;s values or attributes, allowing one input stream to fan out to topics selected at runtime. The release post introduced this feature across all five connectors. On Pub/Sub, the sink is:&lt;/p&gt;
&lt;pre class=&quot;giallo z-l-code z-d-code&quot; &gt;&lt;code data-lang=&quot;java&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;DataStream&lt;/span&gt;&lt;span&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;OrderEvent&lt;/span&gt;&lt;span&gt;&amp;gt;&lt;/span&gt;&lt;span&gt; orders&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span&gt; .&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;orders&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;sinkTo&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        PubSubSink&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span&gt;OrderEvent&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;&amp;gt;&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;builder&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                .&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;destinationResolver&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                        (&lt;/span&gt;&lt;span&gt;event&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span&gt; context&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; -&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                                TopicDestination&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;of&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;my-project&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;orders-&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; +&lt;/span&gt;&lt;span&gt; event&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;region&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                .&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;serializer&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                        PubSubSerializationSchema&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;payload&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;new&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt; OrderEventSchema&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                                .&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;withAttributes&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;e &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;-&amp;gt;&lt;/span&gt;&lt;span&gt; Map&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;of&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;tenant&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span&gt; e&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;tenantId&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                .&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;topicCreateOptions&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                        TopicCreateOptions&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;builder&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                                .&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;messageRetention&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;Duration&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;ofDays&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;7&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                                .&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;build&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                .&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;build&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This composite adapts the &lt;a rel=&quot;external&quot; href=&quot;https://flink-gcp.github.io/flink-connector-gcp/docs/examples/pubsub/&quot;&gt;source-backed examples&lt;/a&gt;, which CI compiles. The combination is mine.&lt;/p&gt;
&lt;p&gt;The resolver runs once per record, before serialization. Any retained field can determine the route, including message attributes preserved by a passthrough deserializer. &lt;code&gt;TopicDestination&lt;/code&gt; holds only project and topic identity, with equality over both. It is cheap to allocate and keys the writer&#39;s publisher map.&lt;/p&gt;
&lt;p&gt;The writer manages publishers as destinations appear and disappear. It creates an SDK publisher lazily for each active topic and retains at most 100 (&lt;code&gt;maxActivePublishers&lt;/code&gt;). At capacity, a new topic evicts the least-recently-used publisher with no pending messages. If none is clean, the writer drains all publishers first. It never discards an in-flight message.&lt;/p&gt;
&lt;p&gt;After a successful checkpoint flush, publishers idle for over an hour (&lt;code&gt;destinationIdleTimeout&lt;/code&gt;) are released. A later record recreates the publisher. Releases use bounded two-phase shutdowns with overlapping waits, so several releases share one timeout budget. A shutdown that times out during eviction fails the running task, preventing abandoned gRPC channels from accumulating. At final close, teardown continues even if shutdown times out.&lt;/p&gt;
&lt;p&gt;The writer flushes all records at each checkpoint barrier instead of storing them in Flink state. A savepoint-less redeploy can duplicate messages but cannot lose messages covered by a completed checkpoint. The next section explains how a newly routed topic is created, allowing a new region in this example to receive its stream without redeployment.&lt;/p&gt;
&lt;p&gt;SQL sinks write to the single topic named in their DDL. A SQL job can fan out to topics known at planning time using multiple &lt;code&gt;INSERT&lt;/code&gt; statements in a &lt;code&gt;STATEMENT SET&lt;/code&gt;. A topic computed from record contents requires the DataStream API shown above. A Kafka-style &lt;code&gt;topic&lt;/code&gt; metadata column was considered and declined because &lt;code&gt;INSERT&lt;/code&gt; branching covers the SQL cases the planner can express.&lt;/p&gt;
&lt;h2 id=&quot;creating-what-is-missing-on-both-sides&quot;&gt;Creating what is missing, on both sides&lt;/h2&gt;
&lt;p&gt;The sink creates missing topics under the default &lt;code&gt;CREATE_IF_NEEDED&lt;/code&gt;. Creation begins only after a publish returns &lt;code&gt;NOT_FOUND&lt;/code&gt;: the sink parks the messages, creates the topic, and republishes with bounded backoff. Existing topics incur no admin call.&lt;/p&gt;
&lt;p&gt;A topic can use service defaults, so the disposition alone authorizes creation. &lt;code&gt;TopicCreateOptions&lt;/code&gt; optionally adds retention, a CMEK key and storage regions.&lt;/p&gt;
&lt;p&gt;Source creation is authorized by supplying settings for each subscription; there is no disposition option. A subscription needs a topic binding that only the pipeline author can choose. Those settings must be per subscription. Sharing one topic binding across several subscriptions would make the job consume a full copy of the topic&#39;s stream from each.&lt;/p&gt;
&lt;p&gt;In SQL, the mapping looks like &lt;code&gt;&#39;scan.auto-create.topics.orders-sub&#39; = &#39;orders&#39;&lt;/code&gt;. Its keys must match the subscription list exactly. The source creates subscriptions only: every mapped topic must already exist.&lt;/p&gt;
&lt;p&gt;Creation is idempotent on both sides. &lt;code&gt;ALREADY_EXISTS&lt;/code&gt; counts as success, so concurrent jobs need no coordination. Existing resources are left as they are; creation settings are neither applied nor compared.&lt;/p&gt;
&lt;p&gt;A new subscription normally has no backlog from before its creation. To replay that history, enable topic-level retention, exposed as &lt;code&gt;messageRetention&lt;/code&gt; in the sink&#39;s creation settings. The start-position options below then determine where to begin within the retained history.&lt;/p&gt;
&lt;h2 id=&quot;what-a-start-position-does-to-a-subscription&quot;&gt;What a start position does to a subscription&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;scan.startup.mode&lt;/code&gt;, or &lt;code&gt;startPosition(...)&lt;/code&gt; in the DataStream API, selects the starting position. Every non-default value performs a seek:&lt;/p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Mode&lt;/th&gt;&lt;th&gt;Behavior&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;continue-from-subscription&lt;/code&gt; (default)&lt;/td&gt;&lt;td&gt;Starts wherever the subscription already is; the only mode that issues no seek&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;earliest-retained&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Replays the whole retained backlog&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;latest&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Discards the existing backlog&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;timestamp&lt;/code&gt; (with &lt;code&gt;scan.startup.timestamp-millis&lt;/code&gt;)&lt;/td&gt;&lt;td&gt;Everything published before the instant is marked acknowledged, everything after unacknowledged&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;These options replace a manual &lt;code&gt;gcloud pubsub subscriptions seek&lt;/code&gt; before submitting a backfill. A seek changes server-side subscription state shared by all consumers; there is no per-consumer offset. A job using a non-default start position should therefore own its subscription.&lt;/p&gt;
&lt;p&gt;The enumerator performs the seek on the first start and records it in checkpointed state. Restoring that state resumes consumption without seeking again.&lt;/p&gt;
&lt;p&gt;A redeploy without a savepoint seeks again because the record of the previous seek is gone. A job that repeatedly crashes before its first completed checkpoint also repeats the seek. &lt;code&gt;latest&lt;/code&gt; resolves against the clock each time and is therefore not reproducible. Use &lt;code&gt;timestamp&lt;/code&gt; for an exact boundary.&lt;/p&gt;
&lt;p&gt;Retention determines what a backward seek can recover. Already-acknowledged messages are available only if the subscription retains acknowledged messages or the topic retains messages. With neither enabled, only unacknowledged messages can be recovered. The startup check warns about that combination.&lt;/p&gt;
&lt;p&gt;Before assigning splits, the enumerator checks every subscription. It rejects per-key ordering on a subscription without message ordering, which would otherwise emit unordered messages. It also rejects subscriptions with exactly-once delivery enabled: their acknowledgment ids expire with the deadline and are invalidated on redelivery, while this source retains ids for a whole checkpoint interval.&lt;/p&gt;
&lt;p&gt;All subscriptions are checked before any seek runs, so a rejected configuration cannot leave another subscription already rewound. These checks capture startup settings only. They do not detect settings changed while the job is running.&lt;/p&gt;
&lt;h2 id=&quot;per-key-ordering-and-where-it-stands&quot;&gt;Per-key ordering, and where it stands&lt;/h2&gt;
&lt;p&gt;The connector supports per-key ordering end to end, but I expect most pipelines, including mine, to leave it disabled. Google&#39;s &lt;a rel=&quot;external&quot; href=&quot;https://cloud.google.com/pubsub/docs/ordering&quot;&gt;ordering documentation&lt;/a&gt; describes the costs: lower publish availability, higher end-to-end latency, a 1 MB/s publish limit per ordering key, and at most one outstanding batch per key for pull subscriptions. Checkpoint-based acknowledgment adds another constraint here: roughly one batch per key per checkpoint interval.&lt;/p&gt;
&lt;p&gt;In my experience, many apparent ordering requirements are event-time requirements. The example&#39;s watermark handles those within its declared lateness bound without requiring ordered transport. Pub/Sub serves message fan-out rather than a log, and transport ordering is a separate choice.&lt;/p&gt;
&lt;p&gt;For jobs that need ordering, both APIs support it on the source and sink. A source table with &lt;code&gt;scan.ordering-mode = &#39;per-key&#39;&lt;/code&gt; assigns each subscription to one subtask over one streaming-pull connection. It guarantees per-key order at the source output; downstream exchanges must partition by the key to preserve it.&lt;/p&gt;
&lt;p&gt;On a sink table, &lt;code&gt;sink.message-ordering.enabled&lt;/code&gt; and an &lt;code&gt;ordering-key&lt;/code&gt; metadata column automatically route keyed rows to one writer subtask. The pair is validated during planning. The &lt;a rel=&quot;external&quot; href=&quot;https://flink-gcp.github.io/flink-connector-gcp/docs/connectors/datastream/pubsub/#message-ordering&quot;&gt;ordering documentation&lt;/a&gt; details the costs and conditions.&lt;/p&gt;
&lt;p&gt;The Pub/Sub emulator cannot validate ordered consumption. The client&#39;s per-key callback serialization depends on a subscription property the emulator does not set. Callbacks can therefore arrive out of order even without Flink involved. The connector verifies ordered consumption in its weekly real-service suite.&lt;/p&gt;
&lt;p&gt;Enabling publish ordering also changes retry behavior. With &lt;code&gt;enableMessageOrdering&lt;/code&gt;, the SDK replaces its retry settings with unlimited retries, including for unkeyed messages. During an outage, even a configured retry timeout no longer ends the wait.&lt;/p&gt;
&lt;p&gt;The connector rejects &lt;code&gt;retryTotalTimeout&lt;/code&gt; and &lt;code&gt;retryMaxAttempts&lt;/code&gt; when ordering is enabled, since the SDK would ignore them. It also adds &lt;code&gt;publishProgressTimeout&lt;/code&gt;, a watchdog that fails the job when no publish completes within that interval. In one August 2026 measurement against an unreachable endpoint, an unordered publish failed after 591 seconds, while an ordered flush was still waiting at 700 seconds. That gap informed the watchdog&#39;s default.&lt;/p&gt;
&lt;h2 id=&quot;failure-routing-on-both-sides&quot;&gt;Failure routing on both sides&lt;/h2&gt;
&lt;p&gt;On the source, &lt;code&gt;scan.deserialization-failure-policy&lt;/code&gt; handles messages the format cannot decode. The default, &lt;code&gt;fail&lt;/code&gt;, leaves the message unacknowledged and fails the job. A permanently bad message can then fail each restart. &lt;code&gt;drop&lt;/code&gt; acknowledges and discards it, with a counter and rate-limited logging. &lt;code&gt;nack&lt;/code&gt; returns it for redelivery so the subscription&#39;s dead-letter policy can handle it.&lt;/p&gt;
&lt;p&gt;I wanted &lt;code&gt;nack&lt;/code&gt; so a poison message could reach a dead-letter topic for later replay while the pipeline kept running. It uses Pub/Sub&#39;s service-side machinery without adding dead-letter code to the job. Startup rejects &lt;code&gt;nack&lt;/code&gt; on a subscription with no dead-letter policy, since an undecodable message would otherwise keep being redelivered without failing the job.&lt;/p&gt;
&lt;p&gt;Dead-lettering counts deliveries regardless of cause, so unrelated job restarts contribute to the same counter. The Pub/Sub service account also needs publish and subscribe grants. Without them, the service continues redelivering instead of forwarding.&lt;/p&gt;
&lt;p&gt;For more complex handling, a DataStream deserializer receives the entire message and writes to a &lt;code&gt;Collector&lt;/code&gt;. It can emit a bad-record variant instead of throwing, then use a side output to route it within the checkpointed pipeline.&lt;/p&gt;
&lt;p&gt;The sink routes two failure types to its configurable handler: serializer rejections and messages individually rejected by the service with &lt;code&gt;INVALID_ARGUMENT&lt;/code&gt;. Other failures, including outages, remain fatal. Routing an outage to a dropping handler would lose messages instead of applying backpressure and restarting.&lt;/p&gt;
&lt;p&gt;Individual confirmation matters because &lt;code&gt;Publish&lt;/code&gt; is an all-or-nothing batch RPC. The SDK reports the batch status for every message, and real-service measurements found no indication of which message caused it. The sink republishes the failed batch one message per request. Only messages rejected individually reach the handler; valid neighbors are published.&lt;/p&gt;
&lt;p&gt;After 100 confirmed rejections in a row without a success, &lt;code&gt;maxConsecutiveRejections&lt;/code&gt; fails the job even under a dropping policy. Dropping occasional anomalous records should not allow a wholly rejected stream to drain silently.&lt;/p&gt;
&lt;p&gt;The handler can use the same &lt;code&gt;PubSubDeadLetterQueue&lt;/code&gt; as the BigQuery post. The shared failure contract lets this implementation serve every connector. Each published element carries attributes identifying the connector, destination, error, timestamp and subtask. It does not create its topic, since an automatically created dead-letter destination may have no consumer.&lt;/p&gt;
&lt;h2 id=&quot;tuning-around-checkpoint-shaped-acknowledgement&quot;&gt;Tuning around checkpoint-shaped acknowledgement&lt;/h2&gt;
&lt;p&gt;Google&#39;s documentation gives no recommended flow-control values beyond sizing them to the client machines. The SDK defaults protect memory; they are not throughput targets. The connector leaves these settings at their SDK defaults and documents a sizing rule for checkpoint-based acknowledgment. All messages received since the last completed checkpoint count against the outstanding-message limit:&lt;/p&gt;
&lt;pre class=&quot;giallo z-l-code z-d-code&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;flowControlMaxOutstandingElementCount ≳ peak messages/s × checkpoint interval&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Below that bound, the client stops pulling before the checkpoint completes, making throughput depend on checkpoint frequency. The SDK default of 1,000 outstanding messages saturates at a hundred messages per second with a 10-second checkpoint interval. Higher limits increase both reader memory use and the number of messages replayed after failure.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;maxAckExtensionPeriod&lt;/code&gt;, one hour by default, must also comfortably exceed the checkpoint interval. Otherwise leases expire before the covering checkpoint can acknowledge the messages, causing redelivery.&lt;/p&gt;
&lt;p&gt;The connector adds a hard buffer budget of 10,000 messages or 64 MiB of serialized data per source reader, whichever is exceeded first. It covers cases where SDK flow control is insufficient, such as a split paused by watermark alignment or a downstream operator that stops consuming.&lt;/p&gt;
&lt;p&gt;Measurements showed why the extra bound is needed. When a buffered message exhausts its lease-extension budget, the client stops extending it and releases its flow-control permit. Pub/Sub redelivers the message, but the connector still holds the original. Released permits allow another wave of intake, mostly duplicates, growing the buffer by roughly one flow-control window per wave. The emulator stopped after two waves; the real service continued.&lt;/p&gt;
&lt;p&gt;The connector bounds the buffer and stops a paused split&#39;s subscriber entirely if it outgrows that bound. On resume, it reopens the subscriber. Returning the leases lets Pub/Sub redeliver to a consumer that can make progress.&lt;/p&gt;
&lt;p&gt;The sink caps unacknowledged publishes at 1,000 messages and 64 MiB per writer subtask. Count alone is insufficient: Pub/Sub accepts messages up to 10 MiB, so 1,000 could occupy about 10 GiB. Retries retain them for up to the SDK&#39;s default 600-second timeout, or indefinitely with ordering enabled. A partial outage is therefore when retained data can peak.&lt;/p&gt;
&lt;p&gt;The 64 MiB default is below the Java subscriber&#39;s 100 MB SDK default, which applies per client rather than per subtask. Size &lt;code&gt;maxInFlightBytes&lt;/code&gt; so its total across sink subtasks on a TaskManager fits the heap budget. With default limits, bytes become the binding cap only above roughly 64 KiB per message.&lt;/p&gt;
&lt;p&gt;The connector uses its own bound because the SDK flow controller blocks the task thread instead of yielding to Flink&#39;s mailbox. With ordering enabled, the SDK also leaks permits on its per-key cancellation path, eventually exhausting the budget and hanging the thread without an exception. The design record documents that behavior.&lt;/p&gt;
&lt;p&gt;As with BigQuery, the &lt;a rel=&quot;external&quot; href=&quot;https://flink-gcp.github.io/flink-connector-gcp/docs/connectors/datastream/pubsub/&quot;&gt;connector documentation&lt;/a&gt; records measurements beside the design decisions. The &lt;a rel=&quot;external&quot; href=&quot;https://flink-gcp.github.io/flink-connector-gcp/docs/reference/pubsub/&quot;&gt;configuration reference&lt;/a&gt; lists all options. Those experiments provide a starting point for adjusting defaults to another workload.&lt;/p&gt;
&lt;h2 id=&quot;what-s-next&quot;&gt;What&#39;s next&lt;/h2&gt;
&lt;p&gt;Near-term milestones focus on other connectors, including Bigtable write extensions in v1.1.0. Pub/Sub&#39;s roadmap is driven by workload reports on the &lt;a rel=&quot;external&quot; href=&quot;https://github.com/flink-gcp/flink-connector-gcp/issues&quot;&gt;issue tracker&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The next post covers Spanner, whose change-stream source feeds the BigQuery CDC sink from the first post. Together they provide Spanner-to-BigQuery replication in one Flink SQL job.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;&lt;em&gt;This is an independent open-source project. It is not affiliated with, endorsed by, or supported by the Apache Software Foundation or Google. Apache Flink, Flink, and the Flink logo are trademarks of the Apache Software Foundation.&lt;/em&gt;&lt;/p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>The BigQuery connector in flink-connector-gcp, part one of the connector series</title>
        <published>2026-09-02T00:11:29+09:00</published>
        <updated>2026-09-05T20:55:15+09:00</updated>
        
        <author>
          <name>Unknown</name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://blog.laughingman7743.org/posts/flink-connector-gcp-bigquery/"/>
        <id>https://blog.laughingman7743.org/posts/flink-connector-gcp-bigquery/</id>
        
        <content type="html" xml:base="https://blog.laughingman7743.org/posts/flink-connector-gcp-bigquery/">&lt;p&gt;This is the first of five posts about &lt;a rel=&quot;external&quot; href=&quot;https://github.com/flink-gcp/flink-connector-gcp&quot;&gt;flink-connector-gcp&lt;/a&gt;, the Apache Flink connectors for Google Cloud I released as 1.0.0. &lt;a rel=&quot;external&quot; href=&quot;https://blog.laughingman7743.org/posts/flink-connector-gcp-1-0-0/&quot;&gt;The release post&lt;/a&gt; explains the motivation, supported Flink versions, and testing.&lt;/p&gt;
&lt;p&gt;This post covers BigQuery&#39;s three write methods, a multi-tenant ingestion pipeline, the bounded source, and defaults informed by measurements against the real service.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Documentation: &lt;a rel=&quot;external&quot; href=&quot;https://flink-gcp.github.io/flink-connector-gcp/docs/connectors/datastream/bigquery/&quot;&gt;https://flink-gcp.github.io/flink-connector-gcp/docs/connectors/datastream/bigquery/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Maven: &lt;code&gt;io.github.flink-gcp:flink-connector-gcp-bigquery:1.0.0&lt;/code&gt; (&lt;code&gt;1.0.0-1.20&lt;/code&gt; for Flink 1.20)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;When I surveyed the &lt;a rel=&quot;external&quot; href=&quot;https://github.com/GoogleCloudDataproc/flink-bigquery-connector&quot;&gt;Dataproc-maintained BigQuery connector&lt;/a&gt; in July 2026, it was actively developed but lacked two features I needed. Each sink wrote to one fixed table, and schema changes required a job redeployment. Dynamic destinations and schema evolution became central to this connector.&lt;/p&gt;
&lt;h2 id=&quot;one-builder-three-write-methods&quot;&gt;One builder, three write methods&lt;/h2&gt;
&lt;p&gt;The sink uses one builder, following the style of Beam&#39;s &lt;code&gt;BigQueryIO&lt;/code&gt;. A call to &lt;code&gt;writeMethod(...)&lt;/code&gt; on &lt;code&gt;BigQuerySink.builder()&lt;/code&gt; selects one of three implementations when the job graph is built. Destination, serializer, table creation, schema updates, and failure-handler settings stay on that builder. Switching from streaming inserts to batch loads therefore changes configuration without rewriting the pipeline.&lt;/p&gt;
&lt;pre class=&quot;giallo z-l-code z-d-code&quot; &gt;&lt;code data-lang=&quot;java&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;Sink&lt;/span&gt;&lt;span&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;MyEvent&lt;/span&gt;&lt;span&gt;&amp;gt;&lt;/span&gt;&lt;span&gt; sink&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        BigQuerySink&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span&gt;MyEvent&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;&amp;gt;&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;builder&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                .&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;writeMethod&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;WriteMethod&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;STORAGE_API_AT_LEAST_ONCE&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                .&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;destinationResolver&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                        (&lt;/span&gt;&lt;span&gt;e&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span&gt; ctx&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; -&amp;gt;&lt;/span&gt;&lt;span&gt; TableDestination&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;of&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;my-project&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;my_dataset&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span&gt; e&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;tableName&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                .&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;serializer&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;new&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt; MyEventProtoSerializer&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                .&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;build&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The three methods trade visibility latency against delivery guarantee and ingestion cost:&lt;/p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;&lt;/th&gt;&lt;th&gt;Visibility&lt;/th&gt;&lt;th&gt;Delivery&lt;/th&gt;&lt;th&gt;Ingestion price&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;STORAGE_API_AT_LEAST_ONCE&lt;/code&gt;&lt;/td&gt;&lt;td&gt;seconds (as appends succeed)&lt;/td&gt;&lt;td&gt;at least once&lt;/td&gt;&lt;td&gt;volume-based Storage Write API pricing&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;STORAGE_API_EXACTLY_ONCE&lt;/code&gt;&lt;/td&gt;&lt;td&gt;per checkpoint&lt;/td&gt;&lt;td&gt;exactly once&lt;/td&gt;&lt;td&gt;volume-based Storage Write API pricing&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;FILE_LOADS&lt;/code&gt;&lt;/td&gt;&lt;td&gt;per checkpoint&lt;/td&gt;&lt;td&gt;exactly once&lt;/td&gt;&lt;td&gt;load jobs are free on the shared slot pool&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;&lt;code&gt;STORAGE_API_AT_LEAST_ONCE&lt;/code&gt; writes through the Storage Write API&#39;s default stream. At every checkpoint, the writer flushes pending batches and waits for all in-flight appends before passing the barrier. A completed checkpoint therefore means BigQuery has acknowledged every preceding record. No sink buffer remains in Flink state.&lt;/p&gt;
&lt;p&gt;The project evaluated &lt;a rel=&quot;external&quot; href=&quot;https://cwiki.apache.org/confluence/spaces/FLINK/pages/181309759/FLIP-171+Async+Sink&quot;&gt;FLIP-171&lt;/a&gt;&#39;s &lt;code&gt;AsyncSinkBase&lt;/code&gt; for this sink and the other connectors. Its &lt;code&gt;AsyncSinkWriter&lt;/code&gt; stores unflushed buffers in writer state rather than flushing them at the barrier. Redeploying without that state loses those buffered records. I chose to flush at the barrier so a savepoint-less redeploy risks duplicates instead of loss. The Storage Write API SDK already provides the in-flight window needed for backpressure, so the sink did not need that machinery from the base class.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;STORAGE_API_EXACTLY_ONCE&lt;/code&gt; uses application-created buffered streams and a two-phase protocol. Writers append rows at explicit offsets, where they remain invisible. When a checkpoint completes, the committer calls &lt;code&gt;FlushRows&lt;/code&gt; up to that checkpoint&#39;s offset.&lt;/p&gt;
&lt;p&gt;Each writer reuses one buffered stream per active destination across checkpoints. Google&#39;s guidance discourages calling &lt;code&gt;CreateWriteStream&lt;/code&gt; at checkpoint frequency, and the API has a quota of 10,000 calls per hour per project and region.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;FlushRows&lt;/code&gt; is naturally idempotent: flushing an already-flushed offset returns &lt;code&gt;ALREADY_EXISTS&lt;/code&gt;. A restarted committer can therefore retry without deterministic-ID machinery and can run at the sink&#39;s full parallelism.&lt;/p&gt;
&lt;p&gt;The two modes have different failure risks. At-least-once keeps the sink ahead of source acknowledgment, so discarding operator state can duplicate rows but cannot lose them. Exactly-once puts visibility and source acknowledgment in the same commit phase without an atomic operation spanning both. Discarding state during a redeploy can then lose up to one checkpoint&#39;s data.&lt;/p&gt;
&lt;p&gt;This limitation is inherent to two-phase commit; a Kafka exactly-once producer has the same risk. The sink cannot detect discarded state because a writer restored without state looks like a new job. Redeploy exactly-once jobs through savepoints. With the Flink Kubernetes Operator, use &lt;code&gt;upgradeMode: savepoint&lt;/code&gt; or &lt;code&gt;last-state&lt;/code&gt;, never &lt;code&gt;stateless&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The later &lt;code&gt;FILE_LOADS&lt;/code&gt; section covers its ingestion costs and checkpoint-related quotas.&lt;/p&gt;
&lt;h2 id=&quot;a-multi-tenant-pipeline-end-to-end&quot;&gt;A multi-tenant pipeline, end to end&lt;/h2&gt;
&lt;p&gt;The pipeline that motivated these features receives events from many tenants and writes to one BigQuery table per tenant. Tenants can appear or disappear without a job redeployment. That requires per-record routing, table creation on first write, schema evolution, and a destination for unroutable records. The sink combines all four:&lt;/p&gt;
&lt;pre class=&quot;giallo z-l-code z-d-code&quot; &gt;&lt;code data-lang=&quot;java&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;Map&lt;/span&gt;&lt;span&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;String&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; TableDestination&lt;/span&gt;&lt;span&gt;&amp;gt;&lt;/span&gt;&lt;span&gt; tablesByTenant&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; new&lt;/span&gt;&lt;span&gt; HashMap&lt;/span&gt;&lt;span&gt;&amp;lt;&lt;/span&gt;&lt;span&gt;&amp;gt;&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;BigQuerySink&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span&gt;OrderEvent&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;&amp;gt;&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;builder&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        .&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;destinationResolver&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                (&lt;/span&gt;&lt;span&gt;event&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span&gt; context&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; -&amp;gt;&lt;/span&gt;&lt;span&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;                    if&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;!&lt;/span&gt;&lt;span&gt;event&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;hasKnownTenant&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;                        return&lt;/span&gt;&lt;span&gt; UnroutableRecord&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;of&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;event&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;deadLetterPayload&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;Unknown tenant&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                    }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;                    return&lt;/span&gt;&lt;span&gt; tablesByTenant&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;computeIfAbsent&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                            event&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;tenantId&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                            id &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;-&amp;gt;&lt;/span&gt;&lt;span&gt; TableDestination&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;of&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;my-project&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;my_dataset&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;orders_&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; +&lt;/span&gt;&lt;span&gt; id&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                }&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        .&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;serializer&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;new&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt; OrderEventProtoSerializer&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        .&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;tableCreateOptions&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                TableCreateOptions&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;builder&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                        .&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;timePartitioning&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;TimePartitioningType&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;DAY&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;created_at&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                        .&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;timePartitioningExpiration&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;Duration&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;ofDays&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;90&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                        .&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;clusteredFields&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;List&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;of&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;customer_id&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                        .&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;build&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        .&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;schemaUpdateOptions&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;SchemaUpdateOptions&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;builder&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;allowNewFields&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;build&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        .&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;failureHandler&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                FailureHandler&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;sendToDeadLetterQueue&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                        PubSubDeadLetterQueue&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;builder&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                                .&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;topic&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;TopicDestination&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;of&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;my-project&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;dead-letters&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                                .&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;build&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        .&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;build&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This composite adapts the &lt;a rel=&quot;external&quot; href=&quot;https://flink-gcp.github.io/flink-connector-gcp/docs/examples/dynamic-destinations/&quot;&gt;source-backed examples&lt;/a&gt;, which CI compiles. The combination is mine.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Routing.&lt;/strong&gt; The resolver runs once per record, before serialization, on the writer&#39;s hot path. It must be serializable, deterministic and cheap. Caching &lt;code&gt;TableDestination&lt;/code&gt; values, as above, is the documented pattern.&lt;/p&gt;
&lt;p&gt;Each active destination has its own writer state, including a stream writer for the Storage Write API methods. Destinations are evicted after an idle timeout, one hour by default, so tenant churn does not leave connections accumulating indefinitely.&lt;/p&gt;
&lt;p&gt;For a record it cannot place, the resolver returns an &lt;code&gt;UnroutableRecord&lt;/code&gt; containing a payload and reason. That result reaches the failure handler. A bare &lt;code&gt;null&lt;/code&gt; is always fatal, preventing a drop policy from concealing a routing bug.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Table creation.&lt;/strong&gt; With the default &lt;code&gt;CREATE_IF_NEEDED&lt;/code&gt;, the first record for a missing table creates it. The serializer supplies the schema; &lt;code&gt;tableCreateOptions(...)&lt;/code&gt; supplies partitioning and clustering. HTTP 409 is treated as success when parallel subtasks race to create the table.&lt;/p&gt;
&lt;p&gt;Heavy contention can also hit BigQuery&#39;s per-table metadata-update quota and return 403 &lt;code&gt;rateLimitExceeded&lt;/code&gt;. In August 2026, the project raced sixteen creations against one absent table and saw five rate-limited responses. The connector retries these within its recovery backoff budget, allowing concurrent creation to back off instead of failing the job immediately.&lt;/p&gt;
&lt;p&gt;A missing table returns an unexpected status. Opening a Storage Write API stream against it gives &lt;code&gt;PERMISSION_DENIED&lt;/code&gt;, because BigQuery masks table existence from callers who might be probing names. The permission named in the message varies by path, so the connector checks the status code, never the text.&lt;/p&gt;
&lt;p&gt;The &lt;a rel=&quot;external&quot; href=&quot;https://github.com/goccy/bigquery-emulator&quot;&gt;goccy/bigquery-emulator&lt;/a&gt; returns &lt;code&gt;NOT_FOUND&lt;/code&gt; instead. Emulator tests therefore do not exercise this recovery path. In this project, auto-creation had never worked against the real service until direct measurements exposed the difference in August 2026. The documentation records dates and trial counts. That experience is why I use a weekly real-GCP suite to establish service behavior.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Schema evolution.&lt;/strong&gt; The sink handles changes in either direction without a restart. If someone changes the destination schema through DDL, the default stream&#39;s append response reports the new schema. The writer rebuilds its connection with a fresh descriptor.&lt;/p&gt;
&lt;p&gt;If the serializer&#39;s schema extends the table&#39;s schema, &lt;code&gt;schemaUpdateOptions(...)&lt;/code&gt; can allow the sink to update the table. It reads the live schema, combines it with the serializer&#39;s schema, and submits an etag-conditioned update. The union only widens: new fields are appended as &lt;code&gt;NULLABLE&lt;/code&gt;; existing fields are not removed, reordered or retyped. Concurrent unions converge, so parallel subtasks need no additional coordination.&lt;/p&gt;
&lt;p&gt;In six of seven instrumented real-service runs, an update reached the write backend in about 35 seconds. The writer keeps retrying affected batches during propagation. One run took much longer. In a checkpointed job, the checkpoint timeout bounds that wait by triggering a restart.&lt;/p&gt;
&lt;p&gt;Schema updates are opt-in because an unexpected field in the serializer&#39;s schema can alter a live table. Enabling &lt;code&gt;allowNewFields()&lt;/code&gt; means trusting that schema. The connector&#39;s additive schema union does not remove fields, so unwanted additions require a separate cleanup. BigQuery supports &lt;a rel=&quot;external&quot; href=&quot;https://docs.cloud.google.com/bigquery/docs/managing-table-schemas#delete_a_column&quot;&gt;explicit column deletion with &lt;code&gt;ALTER TABLE DROP COLUMN&lt;/code&gt;&lt;/a&gt;. By default, a schema-mismatch append fails the job for investigation.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The dead letter path.&lt;/strong&gt; &lt;code&gt;PubSubDeadLetterQueue&lt;/code&gt; publishes failed elements to a Pub/Sub topic. Attributes identify the connector, intended destination or &lt;code&gt;unresolved&lt;/code&gt; for routing failures, error, timestamp and subtask.&lt;/p&gt;
&lt;p&gt;All connectors share the failure-handler contract, so one dead-letter implementation can serve a job&#39;s BigQuery, Bigtable and Pub/Sub sinks. Delivery is at-least-once. The implementation deliberately does not create the topic: a newly created dead-letter destination may have no consumer.&lt;/p&gt;
&lt;h2 id=&quot;the-same-pipeline-on-free-ingestion&quot;&gt;The same pipeline on free ingestion&lt;/h2&gt;
&lt;p&gt;Both Storage Write API methods use volume-based ingestion pricing. With &lt;code&gt;FILE_LOADS&lt;/code&gt;, writers stage each destination&#39;s rows in Cloud Storage, using zstandard-compressed Avro by default. At every checkpoint, a committer submits BigQuery load jobs. These jobs are free on the shared slot pool, although Cloud Storage staging still costs money and shared-pool capacity is not guaranteed. Paid &lt;code&gt;PIPELINE&lt;/code&gt; slots are available when capacity must be reserved.&lt;/p&gt;
&lt;p&gt;Rows become visible after the checkpoint&#39;s load completes, typically minutes rather than seconds. A high-volume pipeline that accepts that latency can switch with two builder calls and a suitable checkpoint interval:&lt;/p&gt;
&lt;pre class=&quot;giallo z-l-code z-d-code&quot; &gt;&lt;code data-lang=&quot;java&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;env&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;enableCheckpointing&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;300_000&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;span class=&quot;z-l-5 z-d-3&quot;&gt; //&lt;/span&gt;&lt;span class=&quot;z-l-5 z-d-3&quot;&gt; 5 minutes; see the quota arithmetic below&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;BigQuerySink&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span&gt;OrderEvent&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;&amp;gt;&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;builder&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        .&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;writeMethod&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;WriteMethod&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;FILE_LOADS&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        .&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;destinationResolver&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span class=&quot;z-l-5 z-d-3&quot;&gt;   //&lt;/span&gt;&lt;span class=&quot;z-l-5 z-d-3&quot;&gt; unchanged&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        .&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;serializer&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;serializer&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span class=&quot;z-l-5 z-d-3&quot;&gt;     //&lt;/span&gt;&lt;span class=&quot;z-l-5 z-d-3&quot;&gt; unchanged&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        .&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;fileLoadsOptions&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                FileLoadsOptions&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;builder&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                        .&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;stagingPath&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;gs://my-staging-bucket/flink-loads&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                        .&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;build&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        .&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;build&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;FILE_LOADS&lt;/code&gt; provides exactly-once delivery through precise file references and deterministic job IDs. Each load names the exact file URIs emitted by the writers, never a bucket prefix that could include files from failed attempts. The job ID hashes the destination and file list. After a crash, a retry reattaches to the existing load job instead of submitting a second load.&lt;/p&gt;
&lt;p&gt;Committables remain in Flink&#39;s committer state until their loads succeed. The referenced files are therefore part of the recoverable data. Use a dedicated bucket for &lt;code&gt;stagingPath&lt;/code&gt;, with a lifecycle age longer than the longest outage the job must recover from. If the rule deletes files still referenced by a checkpoint, restoring that checkpoint leaves the loads permanently failing.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;FileLoadsOptions&lt;/code&gt; selects the staging format. Avro is the default; Parquet is opt-in and requires &lt;code&gt;parquet-avro&lt;/code&gt; plus a Hadoop runtime for any compression. The connector does not ship these dependencies. It checks for them when building the job graph and names any missing artifact in the client-side error.&lt;/p&gt;
&lt;p&gt;BigQuery rejects Parquet load jobs whose schema contains a &lt;code&gt;JSON&lt;/code&gt; column, regardless of the file contents. For such destinations, the connector overrides the format to Avro and logs that choice once per destination.&lt;/p&gt;
&lt;p&gt;Both formats use the same derived Avro schema, so both reject &lt;code&gt;INTERVAL&lt;/code&gt;, &lt;code&gt;RANGE&lt;/code&gt; and BigQuery flexible column names. The tuning section includes the measurement used to evaluate Parquet&#39;s performance.&lt;/p&gt;
&lt;p&gt;The checkpoint interval determines load-job quota use. A standard table allows 1,500 modifications per day from load, copy and query jobs combined. Each checkpoint that commits files uses at least one modification per active destination:&lt;/p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Checkpoint interval&lt;/th&gt;&lt;th&gt;Modifications per destination per day&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;1 min&lt;/td&gt;&lt;td&gt;1,440, too close to the ceiling to be viable&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;2 min&lt;/td&gt;&lt;td&gt;720&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;5 min&lt;/td&gt;&lt;td&gt;288&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;Multiple destinations also consume the project quota. At a 3-minute interval, one destination needs 480 load jobs per day. Two hundred active destinations use about 96,000 of the project&#39;s 100,000 daily load jobs, leaving little room for retries or other workloads.&lt;/p&gt;
&lt;p&gt;The connector rejects intervals below 2 minutes when building the graph and warns below 5 minutes. Lowering that guard requires an explicit opt-in for short-lived jobs. Pipelines needing visibility within seconds should use the Storage Write API methods; &lt;code&gt;FILE_LOADS&lt;/code&gt; accepts minutes of latency for free ingestion.&lt;/p&gt;
&lt;h2 id=&quot;reading-a-table-back-out&quot;&gt;Reading a table back out&lt;/h2&gt;
&lt;p&gt;The Storage Read API source is a bounded FLIP-27 source. It can run inside a streaming job, for example to load a dimension table for a broadcast join, and finishes when it has read the table. There is no unbounded or CDC source, and none is planned: BigQuery exposes no changelog primitive on which to build one.&lt;/p&gt;
&lt;p&gt;A split stores one session read stream and the number of rows already consumed. Recovery resumes the read at that offset using the API&#39;s own mechanism. Readers request another stream as soon as they finish one, so requesting more streams than subtasks can help balance the work.&lt;/p&gt;
&lt;p&gt;BigQuery decides the actual stream count. Measurements in August 2026 produced 936 streams for a 910 GB table and always 1 for a small table, regardless of how many were requested.&lt;/p&gt;
&lt;p&gt;BigQuery charges for bytes scanned by a read session and stores columns separately. &lt;code&gt;selectedFields&lt;/code&gt;, the equivalent of Table API projection pushdown, avoids scanning unused columns. &lt;code&gt;rowRestriction&lt;/code&gt; filters rows before transfer. Reading a view first executes it as a query, incurring both query-scan and result-read charges, so pruning needs to happen inside that query.&lt;/p&gt;
&lt;p&gt;The source uses Avro on the wire. In the comparison with Arrow, constructing individual records from Arrow was 34% slower than decoding Avro directly, and the Arrow representation was 84% larger on the wire. Arrow&#39;s advantage depends on avoiding individual row materialization, while Flink requires the source to produce records. The connector therefore retained Avro.&lt;/p&gt;
&lt;h2 id=&quot;change-data-capture&quot;&gt;Change data capture&lt;/h2&gt;
&lt;p&gt;With &lt;code&gt;sink.cdc.enabled&lt;/code&gt;, the Table API sink accepts an upsert changelog and writes &lt;code&gt;UPSERT&lt;/code&gt; and &lt;code&gt;DELETE&lt;/code&gt; mutations through the Storage Write API&#39;s CDC support. The &lt;code&gt;_CHANGE_SEQUENCE_NUMBER&lt;/code&gt; pseudocolumn controls ordering. It contains one to four slash-separated hexadecimal sections, compared as unsigned numbers by BigQuery.&lt;/p&gt;
&lt;p&gt;Built-in sequence profiles derive these values from Debezium PostgreSQL and MySQL metadata, TiCDC commit timestamps, and Spanner commit-timestamp coordinates. The API remains experimental while an upstream question about exposing Debezium source metadata to sequence providers is resolved.&lt;/p&gt;
&lt;p&gt;The worked example appears in the Spanner post: its change-stream source and this CDC sink form a replication pipeline in one Flink SQL job.&lt;/p&gt;
&lt;h2 id=&quot;defaults-with-measurements-behind-them&quot;&gt;Defaults with measurements behind them&lt;/h2&gt;
&lt;p&gt;The &lt;a rel=&quot;external&quot; href=&quot;https://flink-gcp.github.io/flink-connector-gcp/docs/reference/bigquery/&quot;&gt;configuration reference&lt;/a&gt; lists every option. Where a default needed empirical evidence, the project measured it and recorded the result beside the setting. Examples include:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Staging file roll size (16 MiB).&lt;/strong&gt; In the August 2026 measurement, loading 769 MiB took 15.0 s with 2 MiB files, 8.3 s with 8 MiB files, and 16.9 s with 128 MiB files. Load times were lowest near 8 MiB; making files smaller did not keep improving them. The default is 16 MiB because file size also determines how much one destination can load within the 10,000-URI job limit: about 156 GiB at 16 MiB, compared with 78 GiB at 8 MiB.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Zstandard staging compression.&lt;/strong&gt; Compression runs on the task thread and directly affects throughput. Across 2,000,000 rows, deflate took 11,436 ms and zstandard 3,182 ms. Zstandard&#39;s output was only 1.8% larger, so the choice mainly reduced CPU cost.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Parquet staging as opt-in.&lt;/strong&gt; Parquet used 0.785x the bytes of Avro across a 64x range of file sizes. Below 256 MiB of input per load job, however, it loaded several times slower, with a sharp improvement at that threshold. A streaming checkpoint&#39;s load is usually smaller. Parquet is therefore suited to batch workloads where each destination&#39;s per-commit volume clearly exceeds 256 MiB.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;maxInflightRequests&lt;/code&gt; at 100 where the SDK defaults to 1000.&lt;/strong&gt; The default stream multiplexes appends over a shared pool that grows when connections appear busy. At the SDK default, a connection needs more than 200 queued requests to count as busy, so the pool rarely grows and throughput plateaus. The connector follows Google&#39;s guidance to lower the threshold and documents how to restore the SDK default.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Append batch size at 512 KiB.&lt;/strong&gt; The default bounds memory and per-record latency. Throughput-oriented jobs can increase it toward the API&#39;s 10 MB request cap, at the cost of more buffered bytes per destination and larger retry units.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The newest option, added days before release, limits staging files finalized concurrently by one writer at a checkpoint: &lt;code&gt;maxConcurrentCheckpointFinalizations&lt;/code&gt;, default 1 and maximum 8. In its sizing measurements, increasing sink parallelism reduced upload time by 77-85%, while writer-local close concurrency of 8 reduced close time by roughly 79-84%. Parallelism remains the first adjustment to try; local concurrency helps when more slots are unavailable.&lt;/p&gt;
&lt;p&gt;These numbers describe measured workloads, not fixed service behavior. The documentation records the experiments so they can be repeated when a default does not suit another workload.&lt;/p&gt;
&lt;h2 id=&quot;what-s-next-for-this-connector&quot;&gt;What&#39;s next for this connector&lt;/h2&gt;
&lt;p&gt;The &lt;a rel=&quot;external&quot; href=&quot;https://github.com/flink-gcp/flink-connector-gcp/milestone/10&quot;&gt;v1.1.0 milestone&lt;/a&gt; extends the Table API source&#39;s filter pushdown. The CDC API will also continue to evolve while the Debezium-format question is resolved.&lt;/p&gt;
&lt;p&gt;The next post covers Pub/Sub: metadata columns in both directions, seek-on-start behavior, and ordered delivery. Reports from workloads unlike mine help guide the project: &lt;a rel=&quot;external&quot; href=&quot;https://github.com/flink-gcp/flink-connector-gcp/issues&quot;&gt;https://github.com/flink-gcp/flink-connector-gcp/issues&lt;/a&gt;&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;&lt;em&gt;This is an independent open-source project. It is not affiliated with, endorsed by, or supported by the Apache Software Foundation or Google. Apache Flink, Flink, and the Flink logo are trademarks of the Apache Software Foundation.&lt;/em&gt;&lt;/p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Releasing flink-connector-gcp 1.0.0, Apache Flink connectors for BigQuery, Pub/Sub, Cloud Tasks, Bigtable, and Spanner</title>
        <published>2026-08-31T19:06:04+09:00</published>
        <updated>2026-09-05T20:41:12+09:00</updated>
        
        <author>
          <name>Unknown</name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://blog.laughingman7743.org/posts/flink-connector-gcp-1-0-0/"/>
        <id>https://blog.laughingman7743.org/posts/flink-connector-gcp-1-0-0/</id>
        
        <content type="html" xml:base="https://blog.laughingman7743.org/posts/flink-connector-gcp-1-0-0/">&lt;p&gt;I have released &lt;a rel=&quot;external&quot; href=&quot;https://github.com/flink-gcp/flink-connector-gcp&quot;&gt;flink-connector-gcp&lt;/a&gt; 1.0.0: five independent &lt;a rel=&quot;external&quot; href=&quot;https://flink.apache.org/&quot;&gt;Apache Flink&lt;/a&gt; connectors for Google Cloud, covering BigQuery, Cloud Pub/Sub, Cloud Tasks, Bigtable, and Spanner. Each offers DataStream and Flink SQL / Table APIs. The artifacts are available on Maven Central under &lt;a rel=&quot;external&quot; href=&quot;https://central.sonatype.com/namespace/io.github.flink-gcp&quot;&gt;&lt;code&gt;io.github.flink-gcp&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Documentation: &lt;a rel=&quot;external&quot; href=&quot;https://flink-gcp.github.io/flink-connector-gcp/&quot;&gt;https://flink-gcp.github.io/flink-connector-gcp/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Source: &lt;a rel=&quot;external&quot; href=&quot;https://github.com/flink-gcp/flink-connector-gcp&quot;&gt;https://github.com/flink-gcp/flink-connector-gcp&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Release: &lt;a rel=&quot;external&quot; href=&quot;https://github.com/flink-gcp/flink-connector-gcp/releases/tag/v1.0.0&quot;&gt;https://github.com/flink-gcp/flink-connector-gcp/releases/tag/v1.0.0&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This post explains why I built the project and what it provides. The following posts cover one connector each, with feature details, pipeline examples, and tuning.&lt;/p&gt;
&lt;h2 id=&quot;why-i-built-it&quot;&gt;Why I built it&lt;/h2&gt;
&lt;p&gt;The existing connectors lacked features my pipelines needed, and adding those features upstream was either slow or not an available route. These were the options for each service when I started.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Pub/Sub.&lt;/strong&gt; Apache&#39;s &lt;a rel=&quot;external&quot; href=&quot;https://github.com/apache/flink-connector-gcp-pubsub&quot;&gt;flink-connector-gcp-pubsub&lt;/a&gt; has seen sparse activity. At the time of writing, its last main-branch commit was in November 2024. That commit added a Table API sink, but a Table API source is still missing. Google&#39;s connector in &lt;a rel=&quot;external&quot; href=&quot;https://github.com/GoogleCloudPlatform/pubsub/tree/master/flink-connector&quot;&gt;GoogleCloudPlatform/pubsub&lt;/a&gt; is well built on the StreamingPull API and moved to Flink 2.2 in June 2026. It remains DataStream-only and, at the time of writing, is not published to a Maven repository. Neither provides the Table API source or the full set of source and sink features I needed.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;BigQuery.&lt;/strong&gt; The &lt;a rel=&quot;external&quot; href=&quot;https://github.com/GoogleCloudDataproc/flink-bigquery-connector&quot;&gt;Dataproc-maintained connector&lt;/a&gt; is actively developed, but my July 2026 survey found two gaps. Each sink writes to one fixed table, so it cannot route records to destinations chosen at runtime. It also cannot follow schema changes without redeploying the job.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Bigtable.&lt;/strong&gt; Google&#39;s &lt;a rel=&quot;external&quot; href=&quot;https://github.com/google/flink-connector-gcp&quot;&gt;flink-connector-gcp&lt;/a&gt;, a separate project with the same name, provides an exactly-once Bigtable sink with Table API support. I also needed lookup joins for enrichment and a change-stream source.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Spanner.&lt;/strong&gt; I submitted a JDBC dialect and catalog to Flink&#39;s JDBC connector (&lt;a rel=&quot;external&quot; href=&quot;https://github.com/apache/flink-connector-jdbc/pull/156&quot;&gt;apache/flink-connector-jdbc#156&lt;/a&gt;, FLINK-37288). It received review feedback, which I appreciate, but has remained open and unmerged since February 2025. Committer time is scarce in a large project. I understand the delay, but needed a working pipeline, and a JDBC dialect would not have covered change streams anyway.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Cloud Tasks.&lt;/strong&gt; I did not find an existing Flink connector at all.&lt;/p&gt;
&lt;p&gt;Each project has its own priorities and review queue. I needed features I could not get from those projects, so I built connectors for all five services around a shared design.&lt;/p&gt;
&lt;h2 id=&quot;what-is-in-the-box&quot;&gt;What is in the box&lt;/h2&gt;
&lt;p&gt;Five connectors, each with two API surfaces:&lt;/p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Connector&lt;/th&gt;&lt;th&gt;DataStream&lt;/th&gt;&lt;th&gt;Flink SQL / Table API&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;BigQuery&lt;/td&gt;&lt;td&gt;source + sink&lt;/td&gt;&lt;td&gt;scan source (bounded), sink&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Cloud Pub/Sub&lt;/td&gt;&lt;td&gt;source + sink&lt;/td&gt;&lt;td&gt;source, sink&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Cloud Tasks&lt;/td&gt;&lt;td&gt;sink&lt;/td&gt;&lt;td&gt;sink&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Bigtable&lt;/td&gt;&lt;td&gt;source + sink&lt;/td&gt;&lt;td&gt;scan source, lookup, sink&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Spanner&lt;/td&gt;&lt;td&gt;source + sink&lt;/td&gt;&lt;td&gt;scan source, lookup, sink&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;Each release has two version lines: &lt;code&gt;1.0.0&lt;/code&gt; for the supported Flink 2.x range and &lt;code&gt;1.0.0-1.20&lt;/code&gt; for Flink 1.20 LTS. The next section explains that policy. Each connector also has a &lt;code&gt;flink-sql-connector-gcp-*&lt;/code&gt; uber-jar for SQL, available from the release page and installed in Flink&#39;s &lt;code&gt;lib/&lt;/code&gt;.&lt;/p&gt;
&lt;pre class=&quot;giallo z-l-code z-d-code&quot; &gt;&lt;code data-lang=&quot;xml&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;z-l-3 z-d-5&quot;&gt;dependency&lt;/span&gt;&lt;span&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  &amp;lt;&lt;/span&gt;&lt;span class=&quot;z-l-3 z-d-5&quot;&gt;groupId&lt;/span&gt;&lt;span&gt;&amp;gt;&lt;/span&gt;&lt;span&gt;io.github.flink-gcp&lt;/span&gt;&lt;span&gt;&amp;lt;/&lt;/span&gt;&lt;span class=&quot;z-l-3 z-d-5&quot;&gt;groupId&lt;/span&gt;&lt;span&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  &amp;lt;&lt;/span&gt;&lt;span class=&quot;z-l-3 z-d-5&quot;&gt;artifactId&lt;/span&gt;&lt;span&gt;&amp;gt;&lt;/span&gt;&lt;span&gt;flink-connector-gcp-bigquery&lt;/span&gt;&lt;span&gt;&amp;lt;/&lt;/span&gt;&lt;span class=&quot;z-l-3 z-d-5&quot;&gt;artifactId&lt;/span&gt;&lt;span&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  &amp;lt;&lt;/span&gt;&lt;span class=&quot;z-l-3 z-d-5&quot;&gt;version&lt;/span&gt;&lt;span&gt;&amp;gt;&lt;/span&gt;&lt;span&gt;1.0.0&lt;/span&gt;&lt;span&gt;&amp;lt;/&lt;/span&gt;&lt;span class=&quot;z-l-3 z-d-5&quot;&gt;version&lt;/span&gt;&lt;span&gt;&amp;gt;&lt;/span&gt;&lt;span class=&quot;z-l-5 z-d-3&quot;&gt; &amp;lt;!--&lt;/span&gt;&lt;span class=&quot;z-l-5 z-d-3&quot;&gt; 1.0.0-1.20 for a Flink 1.20 job &lt;/span&gt;&lt;span class=&quot;z-l-5 z-d-3&quot;&gt;--&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;&amp;lt;/&lt;/span&gt;&lt;span class=&quot;z-l-3 z-d-5&quot;&gt;dependency&lt;/span&gt;&lt;span&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;h2 id=&quot;which-flink-versions-are-supported-and-how&quot;&gt;Which Flink versions are supported, and how&lt;/h2&gt;
&lt;p&gt;The support policy follows Flink&#39;s current and previous minor releases, currently 2.2 and 2.3, plus the 1.20 long-term support line. Supporting both major versions requires two artifact lines.&lt;/p&gt;
&lt;p&gt;One artifact covers both supported 2.x minors. The connectors compile against the oldest, 2.2, and run on the newer version. A weekly job verifies this compatibility: it builds the jars once against 2.2, then reruns the test suite against those same jars on the newer minor. If that check finds a break, the documented fallback is separate artifacts per minor, as the Kafka connector publishes. So far, it has found none.&lt;/p&gt;
&lt;p&gt;Flink 1.20 uses the same source tree. Before choosing whether to maintain another branch, I measured the API differences affecting these connectors and found two. Each has a same-named file in small per-major source roots, selected by a Maven property at build time. All other source is shared.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;-1.20&lt;/code&gt; artifacts compile that code for Flink 1.x. Binary compatibility does not extend across majors: a 2.x jar is not expected to load on a 1.20 cluster, or vice versa. The LTS line therefore has a separate compilation and version suffix.&lt;/p&gt;
&lt;p&gt;Expanding the supported range requires an explicit change. A scheduled check reports new Flink minors and lists the required edits. Support is claimed only after the compatibility job passes. The weekly matrix also builds against the next unreleased Flink snapshot to catch upstream breaks before release.&lt;/p&gt;
&lt;h2 id=&quot;the-features-i-built-it-for&quot;&gt;The features I built it for&lt;/h2&gt;
&lt;p&gt;Everything below is documented with runnable examples on the &lt;a rel=&quot;external&quot; href=&quot;https://flink-gcp.github.io/flink-connector-gcp/docs/examples/&quot;&gt;documentation site&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Dynamic destinations.&lt;/strong&gt; Every DataStream sink can derive its destination from each record: a BigQuery table, Pub/Sub topic, Cloud Tasks queue, Bigtable table, or Spanner table. A multi-tenant pipeline can route to per-tenant tables through one sink and accept new tenants without restarting.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Auto-creation.&lt;/strong&gt; The BigQuery and Pub/Sub sinks can create a table or topic on first write. A new tenant&#39;s first record can therefore create its destination and land in it within the same job. Bigtable also supports opt-in auto-creation when a table schema is declared.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Schema evolution.&lt;/strong&gt; The BigQuery sink can follow changes to the destination table&#39;s schema without restarting the job. Adding a column no longer requires a redeployment.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;BigQuery batch ingestion from a streaming job.&lt;/strong&gt; Alongside its at-least-once and exactly-once Storage Write API paths, the sink offers &lt;code&gt;FILE_LOADS&lt;/code&gt;. The streaming job stages Avro files, or optionally Parquet, in Cloud Storage and commits them through load jobs at checkpoints. Load jobs have no streaming-insert cost, making this economical for high-volume pipelines that can tolerate checkpoint-aligned latency. The job retains the same sink API and dynamic destinations.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;CDC into BigQuery.&lt;/strong&gt; The BigQuery Table API sink accepts changelog streams and uses the Storage Write API&#39;s CDC support to write upserts and deletes with explicit ordering. Flink SQL can therefore materialize a changing table in BigQuery.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Change streams out of Spanner (and Bigtable).&lt;/strong&gt; Both connectors provide change-stream sources. Combining the Spanner source with the BigQuery CDC sink makes Spanner-to-BigQuery replication a single Flink SQL job.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Pub/Sub attributes, ordering, and seek in SQL.&lt;/strong&gt; Message attributes and ordering keys are metadata columns on both the source and sink. A source table reads &lt;code&gt;attributes&lt;/code&gt; as &lt;code&gt;MAP&amp;lt;STRING, STRING&amp;gt;&lt;/code&gt; and can use the publish timestamp as event time. A sink writes attributes and ordering keys from columns.&lt;/p&gt;
&lt;p&gt;The start position is also a table option. Declaring &lt;code&gt;earliest-retained&lt;/code&gt;, &lt;code&gt;latest&lt;/code&gt;, or a timestamp makes the connector seek the subscription on startup, replacing a manual &lt;code&gt;gcloud pubsub subscriptions seek&lt;/code&gt; before a backfill. Neither existing Pub/Sub connector offers a Table API source.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Lookup joins against Bigtable and Spanner.&lt;/strong&gt; Both provide Table API lookup sources with caching options. A Flink SQL enrichment join can use &lt;code&gt;FOR SYSTEM_TIME AS OF&lt;/code&gt; to look up a Bigtable row or query a Spanner table.&lt;/p&gt;
&lt;h2 id=&quot;how-it-is-tested&quot;&gt;How it is tested&lt;/h2&gt;
&lt;p&gt;The test coverage matters as much as the feature list, especially where emulators behave differently from the real services.&lt;/p&gt;
&lt;p&gt;Every pull request runs integration tests for the affected connectors against service emulators. The weekly matrix builds against every supported Flink version. A &lt;a rel=&quot;external&quot; href=&quot;https://siom79.github.io/japicmp/&quot;&gt;japicmp&lt;/a&gt; gate compares each build&#39;s public API with the published 1.0.0 release.&lt;/p&gt;
&lt;p&gt;Documentation checks compare option and metric tables with the source. CI also compiles the Java examples, validates Flink SQL examples through the planner, and executes GoogleSQL examples against a Spanner emulator.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A weekly E2E suite also runs against real Google Cloud services.&lt;/strong&gt; It covers behavior emulators cannot establish: BigQuery load jobs and default-stream fan-out, serializer fidelity against real column types, ordered Pub/Sub subscription delivery and dead-letter forwarding, Cloud Tasks dispatch to an App Engine handler, and Bigtable and Spanner rejection statuses.&lt;/p&gt;
&lt;p&gt;A separate gated manual probe checks BigQuery schema evolution on the real service. It stays outside the weekly suite because schema propagation can outlast a CI runner.&lt;/p&gt;
&lt;p&gt;Tests create and delete real instances where needed. The harness also checks that the gated test classes actually ran. Missing credentials must not produce a green result from a suite that skipped its tests.&lt;/p&gt;
&lt;h2 id=&quot;how-it-was-built&quot;&gt;How it was built&lt;/h2&gt;
&lt;p&gt;Claude and Codex helped write much of this repository under the support programs acknowledged below. I make the architecture decisions and give every change its final review. Two practices make that process inspectable.&lt;/p&gt;
&lt;p&gt;First, the repository records its decisions in close to 150 architecture decision records. Changes to a settled design are expected to read and update the relevant records. This preserves the reasoning for the next contributor, whether human or model.&lt;/p&gt;
&lt;p&gt;Second, every pull request follows a fixed review sequence. The first self-review asks whether the code does what the description says. The second asks whether the description itself is true. A different model then performs an independent review: Codex reviews Claude&#39;s work, and Claude reviews Codex&#39;s.&lt;/p&gt;
&lt;p&gt;The independent reviewer receives the diff without the pull-request description or author notes. Its findings are hypotheses to verify. Using another model mattered more than I expected: one measured case in the decision records showed repeated same-model reviews stopping at a shallow fix, while the other model identified the underlying race condition. The pull-request review threads are public.&lt;/p&gt;
&lt;h2 id=&quot;what-s-next&quot;&gt;What&#39;s next&lt;/h2&gt;
&lt;p&gt;As of August 2026, the tracker groups planned features into these milestones:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;a rel=&quot;external&quot; href=&quot;https://github.com/flink-gcp/flink-connector-gcp/milestone/10&quot;&gt;v1.1.0&lt;/a&gt;&lt;/strong&gt; is mostly about extending Bigtable writes: check-and-mutate, read-modify-write, aggregate column families, and response-bearing writes exposed up to Flink SQL, plus wider filter pushdown for BigQuery and Spanner and release-pipeline automation.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;a rel=&quot;external&quot; href=&quot;https://github.com/flink-gcp/flink-connector-gcp/milestone/11&quot;&gt;v1.2.0&lt;/a&gt;&lt;/strong&gt; plans a sixth connector, for Firestore / Datastore.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;a rel=&quot;external&quot; href=&quot;https://github.com/flink-gcp/flink-connector-gcp/milestone/12&quot;&gt;v1.3.0&lt;/a&gt;&lt;/strong&gt; plans observability work: exporting the Google client libraries&#39; own metrics through Flink&#39;s metric reporters, so the connector-internal RPC behavior shows up on the same dashboards as the job.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Issues and feedback are welcome on the &lt;a rel=&quot;external&quot; href=&quot;https://github.com/flink-gcp/flink-connector-gcp/issues&quot;&gt;tracker&lt;/a&gt;. Reports from use cases unlike mine are especially useful because they expose gaps in what I can test.&lt;/p&gt;
&lt;p&gt;The following posts cover one connector each, with feature details, an end-to-end pipeline, and tuning guidance.&lt;/p&gt;
&lt;h2 id=&quot;acknowledgements&quot;&gt;Acknowledgements&lt;/h2&gt;
&lt;p&gt;This project receives support from Anthropic through &lt;a rel=&quot;external&quot; href=&quot;https://claude.com/&quot;&gt;Claude&lt;/a&gt; and &lt;a rel=&quot;external&quot; href=&quot;https://claude.com/contact-sales/claude-for-oss&quot;&gt;Claude for OSS&lt;/a&gt;, its program for open-source developers, and from OpenAI through &lt;a rel=&quot;external&quot; href=&quot;https://openai.com/codex/&quot;&gt;Codex&lt;/a&gt; and &lt;a rel=&quot;external&quot; href=&quot;https://openai.com/form/codex-for-oss/&quot;&gt;Codex for Open Source&lt;/a&gt;, its program for open-source maintainers.&lt;/p&gt;
&lt;p&gt;Building five connectors with this review process takes substantial model time. These programs made it viable as a personal project outside an employer. I am grateful to both.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;&lt;em&gt;This is an independent open-source project. It is not affiliated with, endorsed by, or supported by the Apache Software Foundation or Google. Apache Flink, Flink, and the Flink logo are trademarks of the Apache Software Foundation.&lt;/em&gt;&lt;/p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>A SQL injection in PyAthena&#39;s parameter formatter (CVE-2026-65321)</title>
        <published>2026-08-03T22:54:26+09:00</published>
        <updated>2026-08-04T00:54:00+09:00</updated>
        
        <author>
          <name>Unknown</name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://blog.laughingman7743.org/posts/pyathena-sql-injection-cve-2026-65321/"/>
        <id>https://blog.laughingman7743.org/posts/pyathena-sql-injection-cve-2026-65321/</id>
        
        <content type="html" xml:base="https://blog.laughingman7743.org/posts/pyathena-sql-injection-cve-2026-65321/">&lt;p&gt;PyAthena 3.35.4 fixes a SQL injection in the default parameter formatter. The bug affected &lt;code&gt;DELETE&lt;/code&gt; and &lt;code&gt;CREATE TABLE ... AS SELECT&lt;/code&gt; (CTAS) statements built with PyAthena&#39;s parameter substitution. An application that passed an untrusted string as a parameter to one of those statements could let that string break out of its quotes and run as SQL. All released versions up to and including 3.35.3 are affected, and the fix is in 3.35.4.&lt;/p&gt;
&lt;h2 id=&quot;how-pyathena-quotes-parameters&quot;&gt;How PyAthena quotes parameters&lt;/h2&gt;
&lt;p&gt;PyAthena implements the DB-API &lt;code&gt;pyformat&lt;/code&gt; parameter style. When an application calls &lt;code&gt;cursor.execute(sql, params)&lt;/code&gt;, PyAthena substitutes each parameter into the SQL text after quoting it, rather than sending the value to Athena as a bound parameter. Quoting a string here means wrapping it in single quotes and escaping any single quote inside it, so that the value stays one string literal. The formatter is the code that does this quoting.&lt;/p&gt;
&lt;h2 id=&quot;two-escapers-chosen-by-statement-prefix&quot;&gt;Two escapers, chosen by statement prefix&lt;/h2&gt;
&lt;p&gt;The formatter carried two different escapers. One doubles a single quote, turning &lt;code&gt;&#39;&lt;/code&gt; into &lt;code&gt;&#39;&#39;&lt;/code&gt;; this is the ANSI SQL rule that Trino and Athena follow. The other backslash-escapes it, turning &lt;code&gt;&#39;&lt;/code&gt; into &lt;code&gt;\&#39;&lt;/code&gt;; this is the Hive and MySQL rule. Both conventions were present because Athena runs two kinds of statement, SQL through its Trino engine and a smaller set of DDL through a Hive-derived engine, and the two engines read string literals by different rules.&lt;/p&gt;
&lt;p&gt;The formatter picked the escaper from the start of the statement. &lt;code&gt;SELECT&lt;/code&gt;, &lt;code&gt;WITH&lt;/code&gt;, &lt;code&gt;INSERT&lt;/code&gt;, &lt;code&gt;UPDATE&lt;/code&gt;, and &lt;code&gt;MERGE&lt;/code&gt; went to the quote-doubling escaper. Everything else went to the backslash escaper.&lt;/p&gt;
&lt;h2 id=&quot;why-delete-and-ctas-were-injectable&quot;&gt;Why DELETE and CTAS were injectable&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;DELETE&lt;/code&gt; was missing from that first list, and a CTAS statement starts with &lt;code&gt;CREATE&lt;/code&gt;, so both fell through to the backslash escaper. That would be harmless if Athena read &lt;code&gt;\&#39;&lt;/code&gt; as an escaped quote. It does not. Trino and Athena treat a backslash inside a single-quoted literal as an ordinary character, so &lt;code&gt;\&#39;&lt;/code&gt; is a literal backslash followed by a quote that ends the string.&lt;/p&gt;
&lt;p&gt;The effect is easiest to see in the rendered SQL. Given the parameter value &lt;code&gt;a&#39; OR 1=1 --&lt;/code&gt;, the formatter produced this for a &lt;code&gt;DELETE&lt;/code&gt;:&lt;/p&gt;
&lt;pre class=&quot;giallo z-l-code z-d-code&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;DELETE&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; FROM&lt;/span&gt;&lt;span&gt; t &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;WHERE&lt;/span&gt;&lt;span&gt; c &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;a\&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; OR&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt; 1&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;z-l-5 z-d-3&quot;&gt; --&lt;/span&gt;&lt;span class=&quot;z-l-5 z-d-3&quot;&gt;&amp;#39;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Athena reads the &lt;code&gt;\&lt;/code&gt; as a backslash and the following &lt;code&gt;&#39;&lt;/code&gt; as the end of the string literal. The &lt;code&gt;OR 1=1&lt;/code&gt; then runs as part of the &lt;code&gt;WHERE&lt;/code&gt; clause, and the &lt;code&gt;--&lt;/code&gt; comments out the trailing quote. A predicate meant to match one row now matches every row. The same break-out works in a CTAS &lt;code&gt;SELECT&lt;/code&gt;, where an injected &lt;code&gt;UNION SELECT&lt;/code&gt; can copy data the caller never meant to expose into the new table.&lt;/p&gt;
&lt;h2 id=&quot;why-it-went-unnoticed&quot;&gt;Why it went unnoticed&lt;/h2&gt;
&lt;p&gt;The escaper selection had no test that passed a quote-containing string through &lt;code&gt;DELETE&lt;/code&gt; or CTAS. The tests that did cover those paths used values without quotes, so the wrong escaper produced output that looked correct. An earlier fix had already moved &lt;code&gt;UPDATE&lt;/code&gt; and &lt;code&gt;MERGE&lt;/code&gt; onto the safe escaper, so the mismatch was understood in the abstract, but &lt;code&gt;DELETE&lt;/code&gt; and CTAS were left behind and no test caught them.&lt;/p&gt;
&lt;h2 id=&quot;defaulting-to-the-safe-escaper&quot;&gt;Defaulting to the safe escaper&lt;/h2&gt;
&lt;p&gt;3.35.4 inverts the choice. The formatter now uses the quote-doubling escaper by default, and routes a statement to the backslash escaper only when it recognizes that statement as Hive DDL: &lt;code&gt;CREATE TABLE&lt;/code&gt; or &lt;code&gt;CREATE EXTERNAL TABLE&lt;/code&gt; that is not a CTAS, &lt;code&gt;ALTER&lt;/code&gt;, &lt;code&gt;DROP&lt;/code&gt; of a database, schema, or table, &lt;code&gt;MSCK REPAIR&lt;/code&gt;, &lt;code&gt;SHOW&lt;/code&gt;, and &lt;code&gt;DESCRIBE&lt;/code&gt;. Leading comments are stripped before this check, so a comment cannot hide the statement type.&lt;/p&gt;
&lt;p&gt;The default matters because the two mistakes are not symmetric. Doubling a quote in a Hive DDL statement is at worst a formatting quirk, since the Hive lexer joins adjacent string literals. Backslash-escaping a quote in a Trino statement is a break-out. When the statement type is unclear, the formatter now falls back to the escaper that cannot be exploited.&lt;/p&gt;
&lt;h2 id=&quot;coordinated-disclosure&quot;&gt;Coordinated disclosure&lt;/h2&gt;
&lt;p&gt;The issue was reported through VulnCheck, acting as CNA, on behalf of the researcher Rahul Karne. The proof of concept runs PyAthena&#39;s real formatter and executes the generated SQL against a local DuckDB database, which reproduces the break-out without touching AWS. It is tracked as CVE-2026-65321 and GitHub advisory GHSA-xwj5-g6cv-4r5c, scored CVSS 3.1 9.8 (Critical), and CVSS 4.0 9.3 in the CVE record. That score is the worst case for a library of this kind, since it assumes untrusted input reaches the vulnerable parameter directly; the real exposure of a given application depends on whether it passes untrusted values into &lt;code&gt;DELETE&lt;/code&gt; or CTAS parameters.&lt;/p&gt;
&lt;h2 id=&quot;what-to-do&quot;&gt;What to do&lt;/h2&gt;
&lt;p&gt;Upgrade to PyAthena 3.35.4:&lt;/p&gt;
&lt;pre class=&quot;giallo z-l-code z-d-code&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;pip install -U &amp;#39;pyathena&amp;gt;=3.35.4&amp;#39;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Until the upgrade lands, avoid passing untrusted values as parameters to &lt;code&gt;DELETE&lt;/code&gt; or CTAS statements. Building those statements from trusted input only, or routing the untrusted value through a &lt;code&gt;SELECT&lt;/code&gt;- or &lt;code&gt;INSERT&lt;/code&gt;-shaped query, avoids the vulnerable escaper.&lt;/p&gt;
&lt;h2 id=&quot;what-i-took-from-it&quot;&gt;What I took from it&lt;/h2&gt;
&lt;p&gt;Two habits would have caught this earlier. The first is to test the security-relevant branch with a hostile value rather than a benign one; a single quote in a &lt;code&gt;DELETE&lt;/code&gt; parameter would have failed loudly. The second is to make the default the safe one, so that a gap in the statement-type detection fails closed instead of open. The longer-term direction is to move parameter values off client-side escaping and onto Athena&#39;s server-side parameters, which removes this class of bug at the source.&lt;/p&gt;
&lt;h2 id=&quot;references&quot;&gt;References&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;external&quot; href=&quot;https://github.com/pyathena-dev/PyAthena/security/advisories/GHSA-xwj5-g6cv-4r5c&quot;&gt;GitHub Security Advisory GHSA-xwj5-g6cv-4r5c&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;external&quot; href=&quot;https://github.com/pyathena-dev/PyAthena/pull/745&quot;&gt;Fix pull request (#745)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;external&quot; href=&quot;https://github.com/pyathena-dev/PyAthena/releases/tag/v3.35.4&quot;&gt;Release notes for v3.35.4&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;external&quot; href=&quot;https://pypi.org/project/pyathena/3.35.4/&quot;&gt;PyAthena 3.35.4 on PyPI&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;external&quot; href=&quot;https://www.cve.org/CVERecord?id=CVE-2026-65321&quot;&gt;CVE-2026-65321&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Migrating a Subsonic server to Navidrome and s3fs</title>
        <published>2023-09-20T16:07:20+09:00</published>
        <updated>2026-09-27T00:00:00+00:00</updated>
        
        <author>
          <name>Unknown</name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://blog.laughingman7743.org/posts/navidrome-s3fs/"/>
        <id>https://blog.laughingman7743.org/posts/navidrome-s3fs/</id>
        
        <content type="html" xml:base="https://blog.laughingman7743.org/posts/navidrome-s3fs/">&lt;p&gt;This is an English translation of my 2023 setup log about replacing a Subsonic server on CentOS 7 with Navidrome on Debian 12.
I first tried Mountpoint for Amazon S3, then switched to s3fs for the music library.
The package versions and commands below reflect that deployment.&lt;/p&gt;
&lt;h2 id=&quot;ec2-and-navidrome&quot;&gt;EC2 and Navidrome&lt;/h2&gt;
&lt;p&gt;Attach an instance role that can access the S3 bucket, using a policy like the one below.
Open ports 80 and 443 in the security group, and allow SSH access from your own IP address.&lt;/p&gt;
&lt;pre class=&quot;giallo z-l-code z-d-code&quot; &gt;&lt;code data-lang=&quot;json&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;{&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;    &amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;Version&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;2012-10-17&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;    &amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;Statement&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; [&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;            &amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;Action&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; [&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;                &amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;s3:*&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;            ]&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;            &amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;Resource&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; [&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;                &amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;arn:aws:s3:::your-s3-bucket*&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;            ]&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;            &amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;Effect&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;Allow&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    ]&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Create a user for Navidrome and run Mountpoint for S3 as that user.
I gave the user a login shell to test the mount, although a login shell is probably unnecessary otherwise.&lt;/p&gt;
&lt;pre class=&quot;giallo z-l-code z-d-code&quot; &gt;&lt;code data-lang=&quot;shellscript&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; sudo&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; useradd&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt; -&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;m&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; navidrome&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt; -&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;s&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; /bin/bash&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt; -&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;d&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; /var/lib/navidrome&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Install Navidrome according to its documentation.
I also installed Emacs as my editor.&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;external&quot; href=&quot;https://www.navidrome.org/docs/installation/linux/&quot;&gt;https://www.navidrome.org/docs/installation/linux/&lt;/a&gt;&lt;/p&gt;
&lt;pre class=&quot;giallo z-l-code z-d-code&quot; &gt;&lt;code data-lang=&quot;shellscript&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; sudo&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; apt&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; update&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; sudo&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; apt&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; upgrade&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; sudo&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; apt&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; install&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; emacs&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; ffmpeg&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; sudo&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; install&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt; -&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;d&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt; -&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;o&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; navidrome&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt; -&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;g&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; navidrome&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; /opt/navidrome&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; sudo&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; install&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt; -&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;d&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt; -&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;o&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; navidrome&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt; -&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;g&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; navidrome&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; /var/lib/navidrome&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; wget&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; https://github.com/navidrome/navidrome/releases/download/v0.49.3/navidrome_0.49.3_Linux_arm64.tar.gz&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; sudo&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; tar&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt; -&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;xvzf&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; navidrome_0.49.3_Linux_arm64.tar.gz&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt; -&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;C&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; /opt/navidrome/&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; sudo&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; chown&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt; -&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;R&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; navidrome:navidrome&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; /opt/navidrome&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Create directories for the music and data, along with the configuration file.
I initially mounted the S3 bucket at the music directory with Mountpoint for S3.&lt;/p&gt;
&lt;pre class=&quot;giallo z-l-code z-d-code&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;$ sudo mkdir /var/lib/navidrome/{music,data}&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;$ sudo touch /var/lib/navidrome/navidrome.toml&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;$ sudo chown -R navidrome:navidrome /var/lib/navidrome&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Set the required options in &lt;code&gt;navidrome.toml&lt;/code&gt;, using the configuration reference.&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;external&quot; href=&quot;https://www.navidrome.org/docs/usage/configuration-options/&quot;&gt;https://www.navidrome.org/docs/usage/configuration-options/&lt;/a&gt;&lt;/p&gt;
&lt;pre class=&quot;giallo z-l-code z-d-code&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;MusicFolder = &amp;quot;/var/lib/navidrome/music&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;DataFolder = &amp;quot;/var/lib/navidrome/data&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;LogLevel = &amp;quot;info&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;Address = &amp;quot;0.0.0.0&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;ScanSchedule = &amp;quot;0 5 * * *&amp;quot;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Create a systemd unit for Navidrome.&lt;/p&gt;
&lt;pre class=&quot;giallo z-l-code z-d-code&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;[Unit]&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;Description=Navidrome Music Server and Streamer compatible with Subsonic/Airsonic&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;After=remote-fs.target network.target&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;AssertPathExists=/var/lib/navidrome&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;[Install]&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;WantedBy=multi-user.target&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;[Service]&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;User=navidrome&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;Group=navidrome&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;Type=simple&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;ExecStart=/opt/navidrome/navidrome --configfile &amp;quot;/var/lib/navidrome/navidrome.toml&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;WorkingDirectory=/var/lib/navidrome&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;TimeoutStopSec=20&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;KillMode=process&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;Restart=on-failure&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;# See https://www.freedesktop.org/software/systemd/man/systemd.exec.html&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;DevicePolicy=closed&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;NoNewPrivileges=yes&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;PrivateTmp=yes&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;PrivateUsers=yes&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;ProtectControlGroups=yes&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;ProtectKernelModules=yes&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;ProtectKernelTunables=yes&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;RestrictNamespaces=yes&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;RestrictRealtime=yes&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;SystemCallFilter=~@clock @debug @module @mount @obsolete @reboot @setuid @swap&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;ReadWritePaths=/var/lib/navidrome&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;# You can uncomment the following line if you&amp;#39;re not using the jukebox This&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;# will prevent navidrome from accessing any real (physical) devices&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;#PrivateDevices=yes&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;# You can change the following line to `strict` instead of `full` if you don&amp;#39;t&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;# want navidrome to be able to write anything on your filesystem outside of&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;# /var/lib/navidrome.&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;ProtectSystem=full&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;# You can uncomment the following line if you don&amp;#39;t have any media in /home/*.&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;# This will prevent navidrome from ever reading/writing anything there.&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;#ProtectHome=true&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;# You can customize some Navidrome config options by setting environment variables here. Ex:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;#Environment=ND_BASEURL=&amp;quot;/navidrome&amp;quot;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Save it as &lt;code&gt;/etc/systemd/system/navidrome.service&lt;/code&gt;, then start and enable the service.&lt;/p&gt;
&lt;pre class=&quot;giallo z-l-code z-d-code&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;$ sudo systemctl daemon-reload&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;$ sudo systemctl start navidrome.service&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;$ sudo systemctl status navidrome.service&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;$ sudo systemctl enable navidrome.service&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Forward port 4533 over SSH and create the Navidrome admin user.&lt;/p&gt;
&lt;h2 id=&quot;trying-mountpoint-for-s3&quot;&gt;Trying Mountpoint for S3&lt;/h2&gt;
&lt;p&gt;Mountpoint for S3 was straightforward to install using its documentation.&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;external&quot; href=&quot;https://github.com/awslabs/mountpoint-s3/blob/main/doc/INSTALL.md&quot;&gt;https://github.com/awslabs/mountpoint-s3/blob/main/doc/INSTALL.md&lt;/a&gt;&lt;/p&gt;
&lt;pre class=&quot;giallo z-l-code z-d-code&quot; &gt;&lt;code data-lang=&quot;shellscript&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; wget&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; https://s3.amazonaws.com/mountpoint-s3-release/1.0.1/arm64/mount-s3-1.0.1-arm64.deb&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; sudo&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; apt-get&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; install&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; ./mount-s3-1.0.1-arm64.deb&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Create a temporary directory and try mounting the bucket.&lt;/p&gt;
&lt;pre class=&quot;giallo z-l-code z-d-code&quot; &gt;&lt;code data-lang=&quot;shellscript&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; mkdir&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; mount&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; mount-s3&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; your-s3-bucket&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; ./mount&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; ls&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; ./mount&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The S3 files appeared with &lt;code&gt;ls&lt;/code&gt;.
After confirming the mount worked, I unmounted it.&lt;/p&gt;
&lt;pre class=&quot;giallo z-l-code z-d-code&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;$ umount ./mount&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;To mount the bucket at startup, I added a systemd unit based on &lt;a rel=&quot;external&quot; href=&quot;https://github.com/awslabs/mountpoint-s3/issues/441&quot;&gt;this issue&lt;/a&gt;.&lt;/p&gt;
&lt;pre class=&quot;giallo z-l-code z-d-code&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;[Unit]&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;Description=Mountpoint for Amazon S3 mount&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;Wants=network.target&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;AssertPathIsDirectory=/var/lib/navidrome/music&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;[Service]&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;Type=forking&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;User=navidrome&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;Group=navidrome&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;ExecStart=/usr/bin/mount-s3 your-s3-bucket /var/lib/navidrome/music&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;ExecStop=/usr/bin/fusermount -u /var/lib/navidrome/music&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;[Install]&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;WantedBy=default.target&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Save it as &lt;code&gt;/etc/systemd/system/mountpoint-s3.service&lt;/code&gt;, then start and enable it.&lt;/p&gt;
&lt;pre class=&quot;giallo z-l-code z-d-code&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;$ sudo systemctl daemon-reload&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;$ sudo systemctl start mountpoint-s3.service&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;$ sudo systemctl status mountpoint-s3.service&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;$ sudo systemctl enable mountpoint-s3.service&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The mounted directory was visible to the Navidrome user, but not to other users, including root in this test.&lt;/p&gt;
&lt;pre class=&quot;giallo z-l-code z-d-code&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;$ ls -al /var/lib/navidrome&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;drwxr-xr-x  3 navidrome navidrome 4096 Sep 17 15:38 data&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;d?????????  ? ?         ?            ?            ? music&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;-rw-r--r--  1 navidrome navidrome  159 Sep 17 14:44 navidrome.toml&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo z-l-code z-d-code&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;$ sudo ls -al /var/lib/navidrome&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;drwxr-xr-x  3 navidrome navidrome 4096 Sep 17 15:38 data&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;d?????????  ? ?         ?            ?            ? music&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;-rw-r--r--  1 navidrome navidrome  159 Sep 17 14:44 navidrome.toml&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo z-l-code z-d-code&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;$ sudo -u navidrome ls -al /var/lib/navidrome&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;drwxr-xr-x  3 navidrome navidrome 4096 Sep 17 15:38 data&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;drwxr-xr-x  2 navidrome navidrome    0 Sep 17 16:26 music&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;-rw-r--r--  1 navidrome navidrome  159 Sep 17 14:44 navidrome.toml&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I uploaded a few music files to S3, and Navidrome scanned and played them successfully.
Then I uploaded the whole library from the old Subsonic server&#39;s EBS volume: 1.1 TB and 90,098 files.
With that larger library, Navidrome did not read every file from the Mountpoint for S3 directory.&lt;/p&gt;
&lt;pre class=&quot;giallo z-l-code z-d-code&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;$ sudo du -sh /var/lib/navidrome/music/&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;1.1T    /var/lib/navidrome/music/&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;$ sudo find /var/lib/navidrome/music/ -type f -not -name cover.jpg | wc -l&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;90098&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;There were some warnings, but I could not determine the cause or collect logs useful enough for a GitHub issue.
I switched to s3fs.&lt;/p&gt;
&lt;h2 id=&quot;switching-to-s3fs&quot;&gt;Switching to s3fs&lt;/h2&gt;
&lt;p&gt;&lt;a rel=&quot;external&quot; href=&quot;https://github.com/s3fs-fuse/s3fs-fuse&quot;&gt;https://github.com/s3fs-fuse/s3fs-fuse&lt;/a&gt;&lt;/p&gt;
&lt;pre class=&quot;giallo z-l-code z-d-code&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;$ sudo apt install s3fs&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;$ echo &amp;quot;s3fs#your-s3-bucket /var/lib/navidrome/music fuse uid=****,gid=****,iam_role=auto,endpoint=your-region,allow_other,mp_umask=022,use_cache=/tmp,nonempty 0 0&amp;quot; | sudo tee -a /etc/fstab&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;$ sudo mount -a&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Navidrome scanned every file in the directory mounted with s3fs.&lt;/p&gt;
&lt;h2 id=&quot;nginx-and-https&quot;&gt;Nginx and HTTPS&lt;/h2&gt;
&lt;p&gt;To make Navidrome accessible from outside the instance, I configured Nginx and Let&#39;s Encrypt.
I installed Nginx according to its documentation.&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;external&quot; href=&quot;http://nginx.org/en/linux_packages.html#Debian&quot;&gt;http://nginx.org/en/linux_packages.html#Debian&lt;/a&gt;&lt;/p&gt;
&lt;pre class=&quot;giallo z-l-code z-d-code&quot; &gt;&lt;code data-lang=&quot;shellscript&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; sudo&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; apt&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; install&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; curl&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; gnupg2&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; ca-certificates&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; lsb-release&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; debian-archive-keyring&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; curl&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; https://nginx.org/keys/nginx_signing.key&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; |&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt; gpg&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt; -&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;-dearmor&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;  |&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt; sudo&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; tee&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; /usr/share/keyrings/nginx-archive-keyring.gpg&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; &amp;gt;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;/dev/null&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; gpg&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt; -&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;-dry-run&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt; -&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;-quiet&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt; -&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;-no-keyring&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt; -&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;-import&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt; -&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;-import-options&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; import-show&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; /usr/share/keyrings/nginx-archive-keyring.gpg&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; echo&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;deb [signed-by=/usr/share/keyrings/nginx-archive-keyring.gpg] &lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;\&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;  http://nginx.org/packages/debian &lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;`&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;lsb_release&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt; -&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;cs&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;`&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; nginx&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;  |&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt; sudo&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; tee&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; /etc/apt/sources.list.d/nginx.list&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; echo&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt; -&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;e&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;Package: *\nPin: origin nginx.org\nPin: release o=nginx\nPin-Priority: 900\n&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;  |&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt; sudo&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; tee&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; /etc/apt/preferences.d/99nginx&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; sudo&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; apt&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; update&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; sudo&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; apt&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; install&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; nginx&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Install Certbot and request a certificate.
Because I manage DNS with Route 53, I used the &lt;a rel=&quot;external&quot; href=&quot;https://certbot-dns-route53.readthedocs.io/en/stable/&quot;&gt;certbot-dns-route53&lt;/a&gt; plugin for the DNS challenge.&lt;/p&gt;
&lt;pre class=&quot;giallo z-l-code z-d-code&quot; &gt;&lt;code data-lang=&quot;shellscript&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; sudo&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; apt&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; install&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; certbot&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; python3-certbot-dns-route53&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; certbot&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; certonly&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt; -&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;-dns-route53&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt; -&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;d&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; foo.bar&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt; -&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;d&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt; *&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;.foo.bar&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt; -&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;m&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; foo@bar&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt; -&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;-agree-tos&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The EC2 instance also needs permissions to update the relevant Route 53 hosted zone.&lt;/p&gt;
&lt;pre class=&quot;giallo z-l-code z-d-code&quot; &gt;&lt;code data-lang=&quot;json&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;{&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;    &amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;Version&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;2012-10-17&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;    &amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;Id&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;certbot-dns-route53&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;    &amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;Statement&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; [&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;            &amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;Effect&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;Allow&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;            &amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;Action&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; [&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;                &amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;route53:ListHostedZones&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;                &amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;route53:GetChange&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;            ]&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;            &amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;Resource&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; [&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;                &amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;*&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;            ]&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        }&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;            &amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;Effect&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span&gt; :&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;Allow&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;            &amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;Action&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span&gt; :&lt;/span&gt;&lt;span&gt; [&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;                &amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;route53:ChangeResourceRecordSets&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;            ]&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;            &amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;Resource&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span&gt; :&lt;/span&gt;&lt;span&gt; [&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;                &amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;arn:aws:route53:::hostedzone/YOURHOSTEDZONEID&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;            ]&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    ]&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The certificate needs periodic renewal, so I enabled Certbot&#39;s systemd timer.
With the Route 53 plugin, renewal did not require a separate script.&lt;/p&gt;
&lt;pre class=&quot;giallo z-l-code z-d-code&quot; &gt;&lt;code data-lang=&quot;shellscript&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; sudo&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; systemctl&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; enable&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; certbot.timer&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I modified &lt;code&gt;/lib/systemd/system/certbot.service&lt;/code&gt; as shown below to reload Nginx after certificate renewal.&lt;/p&gt;
&lt;pre class=&quot;giallo z-l-code z-d-code&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;[Unit]&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;Description=Certbot&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;Documentation=file:///usr/share/doc/python-certbot-doc/html/index.html&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;Documentation=https://certbot.eff.org/docs&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;[Service]&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;Type=oneshot&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;ExecStart=/usr/bin/certbot -q renew --no-random-sleep-on-renew --post-hook &amp;quot;/usr/sbin/nginx -t &amp;amp;&amp;amp; /usr/bin/systemctl reload nginx&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;PrivateTmp=true&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Configure the certificate in Nginx and proxy requests to Navidrome.
This was my proxy configuration.&lt;/p&gt;
&lt;pre class=&quot;giallo z-l-code z-d-code&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;upstream navidrome {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    server 127.0.0.1:4533;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;server {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    listen       80;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    server_name  foo.bar;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    rewrite ^ https://$server_name$request_uri? permanent;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;server {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    listen       443 ssl http2;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    server_name  foo.bar;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    access_log  /var/log/nginx/foo.bar_access.log  ltsv;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    ssl_certificate /etc/letsencrypt/live/foo.bar/fullchain.pem;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    ssl_certificate_key /etc/letsencrypt/live/foo.bar/privkey.pem;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    location / {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        proxy_set_header       Host               $host;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        proxy_set_header       X-Real-IP          $remote_addr;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        proxy_set_header       X-Forwarded-Proto  https;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        proxy_set_header       X-Forwarded-Host   $host;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        proxy_set_header       X-Forwarded-Server $host;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        proxy_set_header       X-Forwarded-For    $proxy_add_x_forwarded_for;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        proxy_pass             http://navidrome;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        proxy_redirect         http:// https://;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        proxy_connect_timeout  30;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        proxy_send_timeout     60;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        proxy_read_timeout     60;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo z-l-code z-d-code&quot; &gt;&lt;code data-lang=&quot;shellscript&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; sudo&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; systemctl&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; start&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; nginx.service&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; sudo&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; systemctl&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; status&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; nginx.service&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; sudo&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; systemctl&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; enable&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; nginx.service&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A firewall was not installed by default on this instance.
Install and configure one if needed.&lt;/p&gt;
&lt;pre class=&quot;giallo z-l-code z-d-code&quot; &gt;&lt;code data-lang=&quot;shellscript&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; sudo&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; apt&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; install&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; ufw&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; sudo&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; ufw&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; allow&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; 22/tcp&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; sudo&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; ufw&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; allow&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; 80/tcp&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; sudo&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; ufw&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; allow&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; 443/tcp&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; sudo&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; ufw&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; allow&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; 4533/tcp&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; sudo&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; systemctl&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; start&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; ufw&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; sudo&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; systemctl&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; enable&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; ufw&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; sudo&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; ufw&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; enable&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Finally, I allocated an Elastic IP, associated it with the instance, and configured the Route 53 record.&lt;/p&gt;
&lt;p&gt;I could connect from an iPhone using these Subsonic clients:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;external&quot; href=&quot;https://substreamerapp.com/&quot;&gt;https://substreamerapp.com/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;external&quot; href=&quot;http://michaelsapps.dk/playsubapp/&quot;&gt;http://michaelsapps.dk/playsubapp/&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The old Subsonic server used 1.5 TB of sc1 EBS storage.
Moving the library to S3 removed that fixed volume size from the server.
I hoped Mountpoint for S3 would become a workable option for this library later.&lt;/p&gt;
&lt;p&gt;Now I can keep buying music on Bandcamp.&lt;/p&gt;
</content>
        
    </entry>
</feed>
