Skip to content

Commit d0387ae

Browse files
jogroganCopilot
andauthored
Skip SQL job generation when a pipeline source or sink has no connector (#247)
A JobTemplate with no `databases` filter matches every sink, so a table with no connector configuration (empty `WITH ()`) would still get a Flink SqlJob rendered against it -- e.g. `CREATE TABLE ... WITH ()` plus an `INSERT INTO` that Flink cannot execute. Such tables are moved by a non-SQL JobTemplate rather than a SqlJob. This happens in both directions: a connector-less sink (materialized by a downstream non-SQL job) and a connector-less source (the reverse case). Add MissingConnectorException (SQLNonTransientException) to signal that a table has no connector and cannot participate in a generated SQL job. PipelineRel throws it while resolving connector configs when the sink (sql()) or any source (script()) has an empty config map. Callers that generate SQL treat it as 'skip the SQL job', not an error: - K8sJobDeployer: a sqlOrNull helper catches it so {{sql}}/{{flinksql}} resolve to null, dropping SQL-based JobTemplates during rendering while non-SQL templates still render. - LogicalTableDeployer and K8sMaterializedViewDeployer: catch it and treat the pipeline as having no SQL. - K8sPipelineDeployer stores "" instead of null for the Pipeline CR sql. Fixture: add ads-catalog-database to demodb-write-template's databases. The offline demo tier (ADS_CATALOG) previously had only a read/trigger template, so its sink had no write connector; the nearline->offline logical-table graph now correctly renders a FlinkSessionJob for that tier. Tests (per testing-best-practices.md): PipelineRelImplementorTest asserts sql() throws when the sink or a source has no connector; K8sJobDeployerTest asserts a connector-less sink skips the SQL template while a non-SQL template still renders. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent e63d9ee commit d0387ae

9 files changed

Lines changed: 204 additions & 6 deletions

File tree

deploy/samples/demodb.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ spec:
5656
databases:
5757
- profile-database
5858
- ads-database
59+
- ads-catalog-database
5960
methods:
6061
- Modify
6162
connector: |
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package com.linkedin.hoptimator;
2+
3+
import java.sql.SQLNonTransientException;
4+
5+
6+
/**
7+
* Signals that a table has no connector configuration, and therefore cannot participate
8+
* in a generated SQL job. Callers that generate SQL-based jobs are expected to catch this
9+
* and skip SQL generation, while still emitting any non-SQL jobs.
10+
*
11+
* <p>This is not necessarily an error: some tables are moved by means other than a SQL job.
12+
* For example, a JobTemplate may render a non-SQL job (rather than {@code SqlJob}) to move data
13+
* into or out of such a table.
14+
*/
15+
public class MissingConnectorException extends SQLNonTransientException {
16+
17+
public MissingConnectorException(String path) {
18+
super("No connector configured for '" + path + "'.");
19+
}
20+
}

hoptimator-k8s/src/main/java/com/linkedin/hoptimator/k8s/K8sJobDeployer.java

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.linkedin.hoptimator.k8s;
22

33
import com.linkedin.hoptimator.Job;
4+
import com.linkedin.hoptimator.MissingConnectorException;
45
import com.linkedin.hoptimator.Source;
56
import com.linkedin.hoptimator.SqlDialect;
67
import com.linkedin.hoptimator.ThrowingFunction;
@@ -45,6 +46,7 @@ public List<String> specify() throws SQLException {
4546
ThrowingFunction<SqlDialect, String> sql = job.sql();
4647
ThrowingFunction<SqlDialect, String> fieldMap = job.fieldMap();
4748
String name = K8sUtils.canonicalizeName(job.sink().database(), job.name());
49+
4850
Template.Environment env = new Template.SimpleEnvironment()
4951
.with("name", name)
5052
.with("database", job.sink().database())
@@ -55,8 +57,8 @@ public List<String> specify() throws SQLException {
5557
.with("sourceCatalogs", () -> job.sources().stream().map(Source::catalog).filter(Objects::nonNull).collect(Collectors.joining(",")))
5658
.with("sourceSchemas", () -> job.sources().stream().map(Source::schema).collect(Collectors.joining(",")))
5759
.with("sourceTables", () -> job.sources().stream().map(Source::table).collect(Collectors.joining(",")))
58-
.with("sql", () -> sql.apply(SqlDialect.ANSI))
59-
.with("flinksql", () -> sql.apply(SqlDialect.FLINK))
60+
.with("sql", () -> sqlOrNull(sql, SqlDialect.ANSI))
61+
.with("flinksql", () -> sqlOrNull(sql, SqlDialect.FLINK))
6062
.with("flinkconfigs", properties)
6163
.with("fieldMap", () -> "'" + fieldMap.apply(SqlDialect.ANSI) + "'")
6264
.with(properties);
@@ -77,4 +79,18 @@ public List<String> specify() throws SQLException {
7779
}
7880
return renderedTemplates;
7981
}
82+
83+
/**
84+
* Renders the pipeline SQL for the given dialect, returning {@code null} if a source or the
85+
* sink has no connector. Such a pipeline cannot be materialized by a SQL job; returning
86+
* {@code null} causes SQL-based JobTemplates to be skipped, while non-SQL JobTemplates still
87+
* render.
88+
*/
89+
private static String sqlOrNull(ThrowingFunction<SqlDialect, String> sql, SqlDialect dialect) throws SQLException {
90+
try {
91+
return sql.apply(dialect);
92+
} catch (MissingConnectorException e) {
93+
return null;
94+
}
95+
}
8096
}

hoptimator-k8s/src/main/java/com/linkedin/hoptimator/k8s/K8sMaterializedViewDeployer.java

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import com.linkedin.hoptimator.Deployer;
44
import com.linkedin.hoptimator.MaterializedView;
5+
import com.linkedin.hoptimator.MissingConnectorException;
56
import com.linkedin.hoptimator.Sink;
67
import com.linkedin.hoptimator.Source;
78
import com.linkedin.hoptimator.SqlDialect;
@@ -111,7 +112,13 @@ String name() {
111112
}
112113

113114
String sql() throws SQLException {
114-
return view.pipelineSql().apply(SqlDialect.ANSI);
115+
try {
116+
return view.pipelineSql().apply(SqlDialect.ANSI);
117+
} catch (MissingConnectorException e) {
118+
// A source or sink has no connector (the pipeline is moved by a non-SQL job rather than
119+
// Flink SQL), so there is no pipeline SQL to stamp on the Pipeline resource.
120+
return null;
121+
}
115122
}
116123

117124
@Override

hoptimator-k8s/src/main/java/com/linkedin/hoptimator/k8s/K8sPipelineDeployer.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ class K8sPipelineDeployer extends K8sDeployer<V1alpha1Pipeline, V1alpha1Pipeline
3838
super(context, K8sApiEndpoints.PIPELINES);
3939
this.name = name;
4040
this.yaml = String.join("\n---\n", specs);
41-
this.sql = sql;
41+
this.sql = sql == null ? "" : sql;
4242
this.sources = sources == null ? Collections.emptyList() : sources;
4343
this.sink = sink;
4444
}

hoptimator-k8s/src/test/java/com/linkedin/hoptimator/k8s/K8sJobDeployerTest.java

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import com.linkedin.hoptimator.DeploymentContext;
44

55
import com.linkedin.hoptimator.Job;
6+
import com.linkedin.hoptimator.MissingConnectorException;
67
import com.linkedin.hoptimator.Sink;
78
import com.linkedin.hoptimator.Source;
89
import com.linkedin.hoptimator.SqlDialect;
@@ -75,8 +76,12 @@ K8sYamlApi createYamlApi(K8sContext context) {
7576
}
7677

7778
private Job createTestJob(Sink sink) {
79+
return createTestJob(sink, dialect -> "INSERT INTO sink SELECT * FROM source");
80+
}
81+
82+
private Job createTestJob(Sink sink, ThrowingFunction<SqlDialect, String> sql) {
7883
Map<String, ThrowingFunction<SqlDialect, String>> lazyEvals = new HashMap<>();
79-
lazyEvals.put("sql", dialect -> "INSERT INTO sink SELECT * FROM source");
84+
lazyEvals.put("sql", sql);
8085
lazyEvals.put("fieldMap", dialect -> "{\"a\":\"b\"}");
8186
Source source = new Source("srcdb", Arrays.asList("schema", "src_table"), Collections.emptyMap());
8287
return new Job("test-job", new HashSet<>(Collections.singleton(source)), sink, lazyEvals);
@@ -273,4 +278,57 @@ void specifyConditionalRenderedTemplateNotNull() throws SQLException {
273278
// The name should be canonicalized from "sinkdb" + "test-job"
274279
assertTrue(specs.get(0).contains("sinkdb"), "rendered template must contain database name");
275280
}
281+
282+
@Test
283+
void specifyWithoutSinkConnectorSkipsSqlTemplate() throws SQLException {
284+
// Arrange: the sink has no connector, so the pipeline SQL function throws.
285+
ThrowingFunction<SqlDialect, String> throwingSql = dialect -> {
286+
throw new MissingConnectorException("sinkdb.schema.sink_table");
287+
};
288+
// A SQL-based JobTemplate (references {{flinksql}})...
289+
templates.add(new V1alpha1JobTemplate()
290+
.metadata(new V1ObjectMeta().name("flink-template"))
291+
.spec(new V1alpha1JobTemplateSpec()
292+
.yaml("kind: SqlJob\nname: {{name}}\nsql:\n - {{flinksql}}")));
293+
// ...and a non-SQL JobTemplate (references no SQL).
294+
templates.add(new V1alpha1JobTemplate()
295+
.metadata(new V1ObjectMeta().name("nonsql-template"))
296+
.spec(new V1alpha1JobTemplateSpec()
297+
.yaml("kind: BatchJob\nname: {{name}}-job\nsinkTable: {{table}}")));
298+
299+
Sink sink = new Sink("sinkdb", Arrays.asList("schema", "sink_table"),
300+
Collections.emptyMap());
301+
Job job = createTestJob(sink, throwingSql);
302+
K8sJobDeployer deployer = makeDeployer(job);
303+
304+
// Act
305+
List<String> specs = deployer.specify();
306+
307+
// Assert: the SQL template is skipped; only the non-SQL template renders.
308+
assertEquals(1, specs.size());
309+
assertTrue(specs.get(0).contains("BatchJob"), "only the non-SQL template should render");
310+
assertFalse(specs.get(0).contains("SqlJob"), "SQL-based template must be skipped when sink has no connector");
311+
}
312+
313+
@Test
314+
void specifyWithSinkConnectorRendersSqlTemplate() throws SQLException {
315+
// Arrange: the sink has a connector, so the pipeline SQL function returns SQL.
316+
templates.add(new V1alpha1JobTemplate()
317+
.metadata(new V1ObjectMeta().name("flink-template"))
318+
.spec(new V1alpha1JobTemplateSpec()
319+
.yaml("kind: SqlJob\nname: {{name}}\nsql:\n - {{flinksql}}")));
320+
321+
Sink sink = new Sink("sinkdb", Arrays.asList("schema", "sink_table"),
322+
Collections.emptyMap());
323+
Job job = createTestJob(sink);
324+
K8sJobDeployer deployer = makeDeployer(job);
325+
326+
// Act
327+
List<String> specs = deployer.specify();
328+
329+
// Assert
330+
assertEquals(1, specs.size());
331+
assertTrue(specs.get(0).contains("SqlJob"), "SQL-based template must render when sink has a connector");
332+
assertTrue(specs.get(0).contains("INSERT INTO sink SELECT * FROM source"));
333+
}
276334
}

hoptimator-logical/src/main/java/com/linkedin/hoptimator/logical/LogicalTableDeployer.java

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import java.util.Map;
1212
import java.util.Properties;
1313

14+
import com.linkedin.hoptimator.MissingConnectorException;
1415
import com.linkedin.hoptimator.SqlDialect;
1516
import com.linkedin.hoptimator.k8s.models.V1alpha1DatabaseSpec;
1617
import com.linkedin.hoptimator.k8s.models.V1alpha1JobTemplate;
@@ -520,7 +521,15 @@ void deployPipelineBundle(String fromTier, String toTier, Map<String, Source> ti
520521
throw new SQLNonTransientException(message, e);
521522
}
522523

523-
String pipelineSql = pipeline.job().sql().apply(SqlDialect.ANSI);
524+
String pipelineSql;
525+
try {
526+
pipelineSql = pipeline.job().sql().apply(SqlDialect.ANSI);
527+
} catch (MissingConnectorException e) {
528+
// A tier has no connector so there is no pipeline SQL. The non-SQL job specs are
529+
// still emitted below via DeploymentService.specify(pipeline.job(), ...).
530+
log.info("No connector for pipeline {}; skipping pipeline SQL.", pipelineName);
531+
pipelineSql = null;
532+
}
524533
List<String> pipelineSpecs = new ArrayList<>();
525534
for (Source src : pipeline.sources()) {
526535
pipelineSpecs.addAll(DeploymentService.specify(src, context.deploymentContext()));

hoptimator-util/src/main/java/com/linkedin/hoptimator/util/planner/PipelineRel.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import com.fasterxml.jackson.databind.ObjectMapper;
44
import com.linkedin.hoptimator.DeploymentContext;
55
import com.linkedin.hoptimator.Job;
6+
import com.linkedin.hoptimator.MissingConnectorException;
67
import com.linkedin.hoptimator.Pipeline;
78
import com.linkedin.hoptimator.Sink;
89
import com.linkedin.hoptimator.Source;
@@ -126,6 +127,12 @@ private ScriptImplementor script(DeploymentContext context) throws SQLException
126127
script = script.catalog(source.getKey().catalog());
127128
script = script.database(source.getKey().catalog(), source.getKey().schema());
128129
Map<String, String> configs = ConnectionService.configure(source.getKey(), context);
130+
// A source with no connector configuration cannot be read by a SQL job. As with a
131+
// connector-less sink, such a table is moved by a non-SQL job (with source and sink
132+
// reversed). Signal this to callers so they can skip SQL generation.
133+
if (configs.isEmpty()) {
134+
throw new MissingConnectorException(source.getKey().pathString());
135+
}
129136
String suffix = needsSuffixes ? "_source" : null;
130137
script = script.connector(source.getKey().catalog(), source.getKey().schema(), source.getKey().table(), suffix, source.getValue(), configs);
131138
}
@@ -161,6 +168,11 @@ public ThrowingFunction<SqlDialect, String> sql(DeploymentContext context) throw
161168
validateFieldMapping(targetRowType);
162169
}
163170
Map<String, String> sinkConfigs = ConnectionService.configure(sink, context);
171+
// A sink with no connector configuration cannot be materialized by a SQL job. Signal
172+
// this to callers so they can skip SQL generation while still emitting non-SQL jobs.
173+
if (sinkConfigs.isEmpty()) {
174+
throw new MissingConnectorException(sink.pathString());
175+
}
164176
script = script.catalog(sink.catalog());
165177
script = script.database(sink.catalog(), sink.schema());
166178
// Check if we need to add suffixes to avoid table name collisions

0 commit comments

Comments
 (0)