diff --git a/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/RecordMetaData.java b/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/RecordMetaData.java index 0f62f05e4f..ed68473676 100644 --- a/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/RecordMetaData.java +++ b/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/RecordMetaData.java @@ -87,6 +87,8 @@ public class RecordMetaData implements RecordMetaDataProvider { @Nonnull private final Map viewMap; @Nonnull + private final Map storedQueries; + @Nonnull private final Map indexes; @Nonnull private final Map universalIndexes; @@ -118,6 +120,7 @@ protected RecordMetaData(@Nonnull RecordMetaData orig) { Collections.unmodifiableList(orig.formerIndexes), Collections.unmodifiableMap(orig.userDefinedFunctionMap), Collections.unmodifiableMap(orig.viewMap), + Collections.unmodifiableMap(orig.storedQueries), orig.splitLongRecords, orig.storeRecordVersions, orig.version, @@ -139,6 +142,7 @@ protected RecordMetaData(@Nonnull Descriptors.FileDescriptor recordsDescriptor, @Nonnull List formerIndexes, @Nonnull Map userDefinedFunctionMap, @Nonnull Map viewMap, + @Nonnull Map storedQueries, boolean splitLongRecords, boolean storeRecordVersions, int version, @@ -157,6 +161,7 @@ protected RecordMetaData(@Nonnull Descriptors.FileDescriptor recordsDescriptor, this.formerIndexes = formerIndexes; this.userDefinedFunctionMap = userDefinedFunctionMap; this.viewMap = viewMap; + this.storedQueries = storedQueries; this.splitLongRecords = splitLongRecords; this.storeRecordVersions = storeRecordVersions; this.version = version; @@ -704,6 +709,7 @@ public RecordMetaDataProto.MetaData toProto(@Nullable Descriptors.FileDescriptor builder.addAllUserDefinedFunctions(userDefinedFunctionMap.values().stream().map(UserDefinedFunction::toProto).collect(Collectors.toList())); builder.addAllViews(viewMap.values().stream().map(View::toProto).collect(Collectors.toList())); + builder.putAllStoredQueries(storedQueries); builder.setSplitLongRecords(splitLongRecords); builder.setStoreRecordVersions(storeRecordVersions); builder.setVersion(version); @@ -728,6 +734,11 @@ public Map getViewMap() { return viewMap; } + @Nonnull + public Map getStoredQueries() { + return storedQueries; + } + @Nonnull public Type.Record getPlannerType(@Nonnull String recordTypeName) { final RecordType recordType = getRecordType(recordTypeName); diff --git a/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/RecordMetaDataBuilder.java b/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/RecordMetaDataBuilder.java index 206e73c01e..684318f751 100644 --- a/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/RecordMetaDataBuilder.java +++ b/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/RecordMetaDataBuilder.java @@ -116,6 +116,8 @@ public class RecordMetaDataBuilder implements RecordMetaDataProvider { @Nonnull private final Map viewMap; @Nonnull + private final Map storedQueries; + @Nonnull private final Map indexes; @Nonnull private final Map universalIndexes; @@ -152,6 +154,7 @@ public class RecordMetaDataBuilder implements RecordMetaDataProvider { syntheticRecordTypes = new HashMap<>(); userDefinedFunctionMap = new HashMap<>(); viewMap = new HashMap<>(); + storedQueries = new HashMap<>(); } private void processSchemaOptions(boolean processExtensionOptions) { @@ -238,6 +241,7 @@ private void loadProtoExceptRecords(@Nonnull RecordMetaDataProto.MetaData metaDa final View view = View.fromProto(viewProto); viewMap.put(view.getName(), view); } + storedQueries.putAll(metaDataProto.getStoredQueriesMap()); if (metaDataProto.hasSplitLongRecords()) { splitLongRecords = metaDataProto.getSplitLongRecords(); } @@ -1215,6 +1219,15 @@ public void addView(@Nonnull View view) { viewMap.put(view.getName(), view); } + @Nonnull + public Map getStoredQueries() { + return storedQueries; + } + + public void addStoredQuery(@Nonnull String name, @Nonnull String storedQuery) { + storedQueries.put(name, storedQuery); + } + public boolean isSplitLongRecords() { return splitLongRecords; } @@ -1456,7 +1469,7 @@ public RecordMetaData build(boolean validate) { Map> recordTypeKeyToSyntheticRecordTypeMap = Maps.newHashMapWithExpectedSize(syntheticRecordTypes.size()); RecordMetaData metaData = new RecordMetaData(recordsDescriptor, getUnionDescriptor(), unionFields, builtRecordTypes, builtSyntheticRecordTypes, recordTypeKeyToSyntheticRecordTypeMap, - indexes, universalIndexes, formerIndexes, userDefinedFunctionMap, viewMap, + indexes, universalIndexes, formerIndexes, userDefinedFunctionMap, viewMap, storedQueries, splitLongRecords, storeRecordVersions, version, subspaceKeyCounter, usesSubspaceKeyCounter, recordCountKey, localFileDescriptor != null); for (RecordTypeBuilder recordTypeBuilder : recordTypes.values()) { KeyExpression primaryKey = recordTypeBuilder.getPrimaryKey(); diff --git a/fdb-record-layer-core/src/main/proto/record_metadata.proto b/fdb-record-layer-core/src/main/proto/record_metadata.proto index d4f4174443..e11626cebc 100644 --- a/fdb-record-layer-core/src/main/proto/record_metadata.proto +++ b/fdb-record-layer-core/src/main/proto/record_metadata.proto @@ -210,6 +210,7 @@ message MetaData { repeated UnnestedRecordType unnested_record_types = 13; repeated PUserDefinedFunction user_defined_functions = 14; repeated PView views = 15; + map stored_queries = 16; extensions 1000 to 2000; } diff --git a/fdb-record-layer-core/src/test/java/com/apple/foundationdb/record/provider/foundationdb/MetaDataProtoEditorUnitTest.java b/fdb-record-layer-core/src/test/java/com/apple/foundationdb/record/provider/foundationdb/MetaDataProtoEditorUnitTest.java index 95d4dfb6ff..c21ab76b9a 100644 --- a/fdb-record-layer-core/src/test/java/com/apple/foundationdb/record/provider/foundationdb/MetaDataProtoEditorUnitTest.java +++ b/fdb-record-layer-core/src/test/java/com/apple/foundationdb/record/provider/foundationdb/MetaDataProtoEditorUnitTest.java @@ -587,6 +587,7 @@ void validateMetaDataCoverage() { assertEquals(Set.of( "split_long_records", "version", "former_indexes", "record_count_key", "store_record_versions", "dependencies", "subspace_key_counter", "uses_subspace_key_counter", + "stored_queries", // the below reference record types "records", "indexes", "record_types", "joined_record_types", "unnested_record_types", "user_defined_functions", "views"), diff --git a/fdb-relational-api/src/main/java/com/apple/foundationdb/relational/api/metadata/SchemaTemplate.java b/fdb-relational-api/src/main/java/com/apple/foundationdb/relational/api/metadata/SchemaTemplate.java index 29cd7ab878..fca58099ec 100644 --- a/fdb-relational-api/src/main/java/com/apple/foundationdb/relational/api/metadata/SchemaTemplate.java +++ b/fdb-relational-api/src/main/java/com/apple/foundationdb/relational/api/metadata/SchemaTemplate.java @@ -27,6 +27,7 @@ import javax.annotation.Nonnull; import java.util.BitSet; import java.util.Collection; +import java.util.Map; import java.util.Optional; import java.util.Set; @@ -130,6 +131,14 @@ public interface SchemaTemplate extends Metadata { @Nonnull Collection getTemporaryInvokedRoutines() throws RelationalException; + /** + * Returns the stored queries defined in this schema template. + * + * @return A map of stored query names to their SQL strings. + */ + @Nonnull + Map getStoredQueries(); + @Nonnull String getTransactionBoundMetadataAsString() throws RelationalException; diff --git a/fdb-relational-core/src/main/antlr/RelationalParser.g4 b/fdb-relational-core/src/main/antlr/RelationalParser.g4 index 0953b149bf..3186e2affa 100644 --- a/fdb-relational-core/src/main/antlr/RelationalParser.g4 +++ b/fdb-relational-core/src/main/antlr/RelationalParser.g4 @@ -91,7 +91,7 @@ utilityStatement templateClause : - CREATE ( structDefinition | tableDefinition | enumDefinition | indexDefinition | sqlInvokedFunction | viewDefinition ) + CREATE ( structDefinition | tableDefinition | enumDefinition | indexDefinition | sqlInvokedFunction | viewDefinition | queryDefinition ) ; createStatement @@ -246,6 +246,10 @@ viewDefinition : VIEW viewName=fullId AS viewQuery=query ; +queryDefinition + : QUERY queryName=uid AS storedQuery=query + ; + tempSqlInvokedFunction : functionSpecification ON COMMIT DROP FUNCTION routineBody ; diff --git a/fdb-relational-core/src/main/java/com/apple/foundationdb/relational/recordlayer/RecordLayerEngine.java b/fdb-relational-core/src/main/java/com/apple/foundationdb/relational/recordlayer/RecordLayerEngine.java index f923010ac8..2c96643a9c 100644 --- a/fdb-relational-core/src/main/java/com/apple/foundationdb/relational/recordlayer/RecordLayerEngine.java +++ b/fdb-relational-core/src/main/java/com/apple/foundationdb/relational/recordlayer/RecordLayerEngine.java @@ -29,6 +29,7 @@ import com.apple.foundationdb.relational.api.catalog.StoreCatalog; import com.apple.foundationdb.relational.api.metrics.NoOpMetricRegistry; import com.apple.foundationdb.relational.recordlayer.ddl.RecordLayerMetadataOperationsFactory; +import com.apple.foundationdb.relational.recordlayer.query.OfflineStoredQueriesProcessor; import com.apple.foundationdb.relational.recordlayer.query.cache.RelationalPlanCache; import com.codahale.metrics.MetricRegistry; @@ -52,8 +53,17 @@ public static EmbeddedRelationalEngine makeEngine(@Nonnull RecordLayerConfig cfg MetricRegistry mEngine = convertToRecordLayerEngine(metricsEngine); - List clusters = databases.stream().map(db -> - new RecordLayerStorageCluster(new DirectFdbConnection(db, mEngine), baseKeySpace, cfg, schemaCatalog, planCache, ddlFactory)).collect(Collectors.toList()); + List connections = databases.stream() + .map(db -> new DirectFdbConnection(db, mEngine)) + .collect(Collectors.toList()); + + List clusters = connections.stream() + .map(conn -> new RecordLayerStorageCluster(conn, baseKeySpace, cfg, schemaCatalog, planCache, ddlFactory)) + .collect(Collectors.toList()); + + if (planCache != null && !connections.isEmpty()) { + new OfflineStoredQueriesProcessor(planCache, schemaCatalog, connections.get(0), mEngine).run(); + } return new EmbeddedRelationalEngine(clusters, mEngine); } diff --git a/fdb-relational-core/src/main/java/com/apple/foundationdb/relational/recordlayer/metadata/NoOpSchemaTemplate.java b/fdb-relational-core/src/main/java/com/apple/foundationdb/relational/recordlayer/metadata/NoOpSchemaTemplate.java index 2a053aabb2..b303d26e3a 100644 --- a/fdb-relational-core/src/main/java/com/apple/foundationdb/relational/recordlayer/metadata/NoOpSchemaTemplate.java +++ b/fdb-relational-core/src/main/java/com/apple/foundationdb/relational/recordlayer/metadata/NoOpSchemaTemplate.java @@ -35,6 +35,8 @@ import javax.annotation.Nonnull; import java.util.BitSet; import java.util.Collection; +import java.util.Collections; +import java.util.Map; import java.util.Optional; import java.util.Set; @@ -139,6 +141,12 @@ public Collection getTemporaryInvokedRoutines() throws Relationa throw new RelationalException("NoOpSchemaTemplate doesn't have temporary invoked routines!", ErrorCode.INVALID_PARAMETER); } + @Nonnull + @Override + public Map getStoredQueries() { + return Collections.emptyMap(); + } + @Nonnull @Override public String getTransactionBoundMetadataAsString() throws RelationalException { diff --git a/fdb-relational-core/src/main/java/com/apple/foundationdb/relational/recordlayer/metadata/RecordLayerSchemaTemplate.java b/fdb-relational-core/src/main/java/com/apple/foundationdb/relational/recordlayer/metadata/RecordLayerSchemaTemplate.java index e9c6d72b0e..6fa02a536c 100644 --- a/fdb-relational-core/src/main/java/com/apple/foundationdb/relational/recordlayer/metadata/RecordLayerSchemaTemplate.java +++ b/fdb-relational-core/src/main/java/com/apple/foundationdb/relational/recordlayer/metadata/RecordLayerSchemaTemplate.java @@ -80,6 +80,9 @@ public final class RecordLayerSchemaTemplate implements SchemaTemplate { @Nonnull private final Set views; + @Nonnull + private final Map storedQueries; + private final int version; private final boolean enableLongRows; @@ -107,6 +110,7 @@ private RecordLayerSchemaTemplate(@Nonnull final String name, @Nonnull final Set tables, @Nonnull final Set invokedRoutines, @Nonnull final Set views, + @Nonnull final Map storedQueries, int version, boolean enableLongRows, boolean storeRowVersions, @@ -115,6 +119,7 @@ private RecordLayerSchemaTemplate(@Nonnull final String name, this.tables = ImmutableSet.copyOf(tables); this.invokedRoutines = ImmutableSet.copyOf(invokedRoutines); this.views = ImmutableSet.copyOf(views); + this.storedQueries = ImmutableMap.copyOf(storedQueries); this.version = version; this.enableLongRows = enableLongRows; this.storeRowVersions = storeRowVersions; @@ -130,6 +135,7 @@ private RecordLayerSchemaTemplate(@Nonnull final String name, @Nonnull final Set tables, @Nonnull final Set invokedRoutines, @Nonnull final Set views, + @Nonnull final Map storedQueries, int version, boolean enableLongRows, boolean storeRowVersions, @@ -140,6 +146,7 @@ private RecordLayerSchemaTemplate(@Nonnull final String name, this.tables = ImmutableSet.copyOf(tables); this.invokedRoutines = ImmutableSet.copyOf(invokedRoutines); this.views = ImmutableSet.copyOf(views); + this.storedQueries = ImmutableMap.copyOf(storedQueries); this.enableLongRows = enableLongRows; this.storeRowVersions = storeRowVersions; this.intermingleTables = intermingleTables; @@ -338,6 +345,12 @@ public Set getViews() { return views; } + @Nonnull + @Override + public Map getStoredQueries() { + return storedQueries; + } + @Nonnull @Override public Optional findViewByName(@Nonnull final String viewName) { @@ -414,6 +427,9 @@ public static final class Builder { @Nonnull private final Map views; + @Nonnull + private final Map storedQueries; + private RecordMetaData cachedMetadata; @@ -422,6 +438,7 @@ private Builder() { auxiliaryTypes = new LinkedHashMap<>(); invokedRoutines = new LinkedHashMap<>(); views = new LinkedHashMap<>(); + storedQueries = new LinkedHashMap<>(); // enable long rows is TRUE by default enableLongRows = true; } @@ -540,6 +557,18 @@ public Builder addViews(@Nonnull final Collection views) { return this; } + @Nonnull + public Builder addStoredQuery(@Nonnull final String name, @Nonnull final String storedQuery) { + storedQueries.put(name, storedQuery); + return this; + } + + @Nonnull + public Builder addStoredQueries(@Nonnull final Map storedQueries) { + this.storedQueries.putAll(storedQueries); + return this; + } + /** * Adds an auxiliary type, an auxiliary type is a type that is merely created, so it can be referenced later on * in a table definition. Any {@link DataType.Named} data type can be added as an auxiliary type such as {@code enum}s @@ -632,10 +661,10 @@ public RecordLayerSchemaTemplate build() { if (cachedMetadata != null) { return new RecordLayerSchemaTemplate(name, new LinkedHashSet<>(tables.values()), - new LinkedHashSet<>(invokedRoutines.values()), new LinkedHashSet<>(views.values()), version, enableLongRows, storeRowVersions, intermingleTables, cachedMetadata); + new LinkedHashSet<>(invokedRoutines.values()), new LinkedHashSet<>(views.values()), storedQueries, version, enableLongRows, storeRowVersions, intermingleTables, cachedMetadata); } else { return new RecordLayerSchemaTemplate(name, new LinkedHashSet<>(tables.values()), - new LinkedHashSet<>(invokedRoutines.values()), new LinkedHashSet<>(views.values()), version, enableLongRows, storeRowVersions, intermingleTables); + new LinkedHashSet<>(invokedRoutines.values()), new LinkedHashSet<>(views.values()), storedQueries, version, enableLongRows, storeRowVersions, intermingleTables); } } @@ -763,6 +792,7 @@ public Builder toBuilder() { .setIntermingleTables(intermingleTables) .addTables(getTables()) .addInvokedRoutines(getInvokedRoutines()) - .addViews(getViews()); + .addViews(getViews()) + .addStoredQueries(getStoredQueries()); } } diff --git a/fdb-relational-core/src/main/java/com/apple/foundationdb/relational/recordlayer/metadata/serde/RecordMetadataDeserializer.java b/fdb-relational-core/src/main/java/com/apple/foundationdb/relational/recordlayer/metadata/serde/RecordMetadataDeserializer.java index 68b4c15bdc..9c731585f8 100644 --- a/fdb-relational-core/src/main/java/com/apple/foundationdb/relational/recordlayer/metadata/serde/RecordMetadataDeserializer.java +++ b/fdb-relational-core/src/main/java/com/apple/foundationdb/relational/recordlayer/metadata/serde/RecordMetadataDeserializer.java @@ -125,6 +125,7 @@ private static RecordLayerSchemaTemplate.Builder deserializeRecordMetaData(@Nonn schemaTemplateBuilder.addView(generateViewBuilder(metadataProvider, view.getKey(), view.getValue().getDefinition()).build()); } } + schemaTemplateBuilder.addStoredQueries(recordMetaData.getStoredQueries()); schemaTemplateBuilder.setCachedMetadata(recordMetaData); return schemaTemplateBuilder; } diff --git a/fdb-relational-core/src/main/java/com/apple/foundationdb/relational/recordlayer/metadata/serde/RecordMetadataSerializer.java b/fdb-relational-core/src/main/java/com/apple/foundationdb/relational/recordlayer/metadata/serde/RecordMetadataSerializer.java index 11b80a071c..62951ddd3a 100644 --- a/fdb-relational-core/src/main/java/com/apple/foundationdb/relational/recordlayer/metadata/serde/RecordMetadataSerializer.java +++ b/fdb-relational-core/src/main/java/com/apple/foundationdb/relational/recordlayer/metadata/serde/RecordMetadataSerializer.java @@ -107,9 +107,13 @@ public void visit(@Nonnull final View view) { @Override public void visit(@Nonnull SchemaTemplate schemaTemplate) { Assert.thatUnchecked(schemaTemplate instanceof RecordLayerSchemaTemplate); + final var recLayerSchemaTemplate = (RecordLayerSchemaTemplate) schemaTemplate; getBuilder().setSplitLongRecords(schemaTemplate.isEnableLongRows()); getBuilder().setStoreRecordVersions(schemaTemplate.isStoreRowVersions()); getBuilder().setVersion(schemaTemplate.getVersion()); + for (final var entry : recLayerSchemaTemplate.getStoredQueries().entrySet()) { + getBuilder().addStoredQuery(entry.getKey(), entry.getValue()); + } } @Nonnull diff --git a/fdb-relational-core/src/main/java/com/apple/foundationdb/relational/recordlayer/query/OfflineStoredQueriesProcessor.java b/fdb-relational-core/src/main/java/com/apple/foundationdb/relational/recordlayer/query/OfflineStoredQueriesProcessor.java new file mode 100644 index 0000000000..a7f5276d59 --- /dev/null +++ b/fdb-relational-core/src/main/java/com/apple/foundationdb/relational/recordlayer/query/OfflineStoredQueriesProcessor.java @@ -0,0 +1,151 @@ +/* + * OfflineStoredQueriesProcessor.java + * + * This source file is part of the FoundationDB open source project + * + * Copyright 2021-2026 Apple Inc. and the FoundationDB project authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.apple.foundationdb.relational.recordlayer.query; + +import com.apple.foundationdb.annotation.API; +import com.apple.foundationdb.record.RecordStoreState; +import com.apple.foundationdb.record.logging.KeyValueLogMessage; +import com.apple.foundationdb.relational.api.Options; +import com.apple.foundationdb.relational.api.Transaction; +import com.apple.foundationdb.relational.api.catalog.StoreCatalog; +import com.apple.foundationdb.relational.api.exceptions.RelationalException; +import com.apple.foundationdb.relational.recordlayer.FdbConnection; +import com.apple.foundationdb.relational.recordlayer.catalog.systables.SystemTable; +import com.apple.foundationdb.relational.recordlayer.metadata.RecordLayerSchemaTemplate; +import com.apple.foundationdb.relational.recordlayer.query.cache.OfflineMetricCollector; +import com.apple.foundationdb.relational.recordlayer.query.cache.RelationalPlanCache; +import com.codahale.metrics.MetricRegistry; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import javax.annotation.Nonnull; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +/** + * Pre-warms the shared {@link RelationalPlanCache} at engine startup by planning every + * stored query declared on every schema template in the catalog. + * + *

Two-phase to respect FDB's transaction time bound: + *

    + *
  1. {@link #getSchemaTemplates()} — read-only catalog transaction. It iterates all + * schema templates in the catalog and stores only with + * non-empty {@code storedQueries}
  2. + *
  3. {@link #planStoredQueriesForSchemaTemplate} — Iterates stored schema templates, + * for each of them builds an offline {@link PlanGenerator} and delegates to + * {@link PlanGenerator#planStoredQueries()} which idempotently populates the cache.
  4. + *
+ * + *

Per-template failures are swallowed so a single bad template cannot abort startup.

+ */ +@API(API.Status.EXPERIMENTAL) +public final class OfflineStoredQueriesProcessor { + private static final Logger logger = LogManager.getLogger(OfflineStoredQueriesProcessor.class); + + @Nonnull + private final RelationalPlanCache cache; + + @Nonnull + private final StoreCatalog storeCatalog; + + @Nonnull + private final FdbConnection fdbConnection; + + @Nonnull + private final OfflineMetricCollector metricCollector; + + public OfflineStoredQueriesProcessor(@Nonnull final RelationalPlanCache cache, + @Nonnull final StoreCatalog storeCatalog, + @Nonnull final FdbConnection fdbConnection, + @Nonnull final MetricRegistry metricRegistry) { + this.cache = cache; + this.storeCatalog = storeCatalog; + this.fdbConnection = fdbConnection; + this.metricCollector = new OfflineMetricCollector(metricRegistry); + } + + public void run() { + final long startNanos = System.nanoTime(); + final List templates; + try { + templates = getSchemaTemplates(); + } catch (RelationalException e) { + if (logger.isErrorEnabled()) { + logger.error(KeyValueLogMessage.of("OfflineStoredQueriesProcessor failed to read catalog"), e); + } + return; + } + int queriesPlanned = 0; + int templatesFailed = 0; + for (final RecordLayerSchemaTemplate template : templates) { + try { + planStoredQueriesForSchemaTemplate(template); + queriesPlanned += template.getStoredQueries().size(); + } catch (RelationalException e) { + templatesFailed++; + if (logger.isErrorEnabled()) { + logger.error(KeyValueLogMessage.of("OfflineStoredQueriesProcessor failed to process schema template", + "schemaTemplate", template.getName() + ":" + template.getVersion()), e); + } + } + } + if (logger.isInfoEnabled()) { + logger.info(KeyValueLogMessage.of("OfflineStoredQueriesProcessor finished", + "templates", templates.size(), + "templatesFailed", templatesFailed, + "queriesPlanned", queriesPlanned, + "durationMicros", TimeUnit.NANOSECONDS.toMicros(System.nanoTime() - startNanos))); + } + } + + @Nonnull + private List getSchemaTemplates() throws RelationalException { + final List result = new ArrayList<>(); + try (Transaction txn = fdbConnection.getTransactionManager().createTransaction(Options.NONE)) { + try (var rs = storeCatalog.getSchemaTemplateCatalog().listTemplates(txn)) { + while (rs.next()) { + final var template = storeCatalog.getSchemaTemplateCatalog() + .loadSchemaTemplate(txn, rs.getString(SystemTable.TEMPLATE_NAME)) + .unwrap(RecordLayerSchemaTemplate.class); + if (!template.getStoredQueries().isEmpty()) { + result.add(template); + } + } + } catch (java.sql.SQLException e) { + throw new RelationalException(e); + } + txn.commit(); + } + return result; + } + + private void planStoredQueriesForSchemaTemplate(@Nonnull final RecordLayerSchemaTemplate template) throws RelationalException { + final var generator = PlanGenerator.create( + Optional.of(cache), + template, + new RecordStoreState(null, null), + metricCollector, + Options.NONE); // assume this is standard set of options + generator.planStoredQueries(); + } +} diff --git a/fdb-relational-core/src/main/java/com/apple/foundationdb/relational/recordlayer/query/PlanGenerator.java b/fdb-relational-core/src/main/java/com/apple/foundationdb/relational/recordlayer/query/PlanGenerator.java index 0b466770d0..de124165f8 100644 --- a/fdb-relational-core/src/main/java/com/apple/foundationdb/relational/recordlayer/query/PlanGenerator.java +++ b/fdb-relational-core/src/main/java/com/apple/foundationdb/relational/recordlayer/query/PlanGenerator.java @@ -130,8 +130,12 @@ private PlanGenerator(@Nonnull final Optional cache, */ @Nonnull public Plan getPlan(@Nonnull final String query) throws RelationalException { + return getPlanAndLog(query, KeyValueLogMessage.build("PlanGenerator")); + } + + @Nonnull + private Plan getPlanAndLog(@Nonnull final String query, @Nonnull KeyValueLogMessage message) throws RelationalException { resetTimer(); - KeyValueLogMessage message = KeyValueLogMessage.build("PlanGenerator"); Plan plan = null; RelationalException exception = null; try { @@ -145,6 +149,38 @@ public Plan getPlan(@Nonnull final String query) throws RelationalException { return plan; } + /** + * Pre-generates and caches plans for the stored queries defined in the schema template. + * This method is idempotent per template name and version — subsequent calls for the same + * template are no-ops. + */ + public void planStoredQueries() { + if (cache.isEmpty()) { + return; + } + final var schemaTemplate = planContext.getSchemaTemplate(); + if (schemaTemplate.getStoredQueries().isEmpty()) { + return; + } + final var templateKey = schemaTemplate.getName() + ":" + schemaTemplate.getVersion(); + if (cache.get().isPrepared(templateKey)) { + return; + } + for (final var storedQuery : schemaTemplate.getStoredQueries().entrySet()) { + try { + KeyValueLogMessage message = KeyValueLogMessage.build("PlanStoredQueries"); + message.addKeyAndValue("schemaTemplate", templateKey); + message.addKeyAndValue("storedQueryName", storedQuery.getKey()); + message.addKeyAndValue("storedQuerySql", storedQuery.getValue()); + getPlanAndLog(storedQuery.getValue(), message); + } catch (RelationalException e) { + // do nothing here, error is already logged + assert e != null; + } + } + cache.get().markPrepared(templateKey); + } + private boolean isCaseSensitive() { return options.getOption(Options.Name.CASE_SENSITIVE_IDENTIFIERS); } diff --git a/fdb-relational-core/src/main/java/com/apple/foundationdb/relational/recordlayer/query/cache/OfflineMetricCollector.java b/fdb-relational-core/src/main/java/com/apple/foundationdb/relational/recordlayer/query/cache/OfflineMetricCollector.java new file mode 100644 index 0000000000..c8ababb46d --- /dev/null +++ b/fdb-relational-core/src/main/java/com/apple/foundationdb/relational/recordlayer/query/cache/OfflineMetricCollector.java @@ -0,0 +1,96 @@ +/* + * OfflineMetricCollector.java + * + * This source file is part of the FoundationDB open source project + * + * Copyright 2021-2026 Apple Inc. and the FoundationDB project authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.apple.foundationdb.relational.recordlayer.query.cache; + +import com.apple.foundationdb.annotation.API; +import com.apple.foundationdb.record.provider.common.StoreTimer; +import com.apple.foundationdb.relational.api.exceptions.ErrorCode; +import com.apple.foundationdb.relational.api.exceptions.RelationalException; +import com.apple.foundationdb.relational.api.metrics.MetricCollector; +import com.apple.foundationdb.relational.api.metrics.RelationalMetric; +import com.apple.foundationdb.relational.recordlayer.util.MetricRegistryStoreTimer; +import com.apple.foundationdb.relational.util.Assert; +import com.apple.foundationdb.relational.util.Supplier; +import com.codahale.metrics.MetricRegistry; + +import javax.annotation.Nonnull; +import java.util.concurrent.TimeUnit; + +/** + * A {@link MetricCollector} for offline planning paths that have no transaction context. + * + *

Mirrors {@link com.apple.foundationdb.relational.recordlayer.metric.RecordLayerMetricCollector}'s + * design but owns its own {@link MetricRegistryStoreTimer} (rather than borrowing one from an + * {@link com.apple.foundationdb.record.provider.foundationdb.FDBRecordContext}). Increments and + * timings are forwarded to the wrapped {@link MetricRegistry} as Codahale Counter/Timer updates, + * so they end up alongside online-query metrics under the same event names.

+ */ +@API(API.Status.EXPERIMENTAL) +public final class OfflineMetricCollector implements MetricCollector { + + @Nonnull + private final MetricRegistryStoreTimer storeTimer; + + public OfflineMetricCollector(@Nonnull final MetricRegistry registry) { + this.storeTimer = new MetricRegistryStoreTimer(registry); + } + + @Override + public void increment(@Nonnull final RelationalMetric.RelationalCount count, final int val) { + storeTimer.increment(count, val); + } + + @Override + public T clock(@Nonnull final RelationalMetric.RelationalEvent event, final Supplier supplier) throws RelationalException { + final long startNanos = System.nanoTime(); + try { + return supplier.get(); + } finally { + storeTimer.record(event, System.nanoTime() - startNanos); + } + } + + @Override + public double getAverageTimeMicrosForEvent(@Nonnull final RelationalMetric.RelationalEvent event) { + final StoreTimer.Counter maybeCounter = storeTimer.getCounter(event); + Assert.notNullUnchecked(maybeCounter, ErrorCode.INTERNAL_ERROR, + "Cannot find metrics associated for requested event: %s", event.title()); + if (maybeCounter.getCount() == 0) { + return 0.0; + } + return ((double) TimeUnit.NANOSECONDS.toMicros(maybeCounter.getTimeNanos())) / maybeCounter.getCount(); + } + + @Override + public long getCountsForCounter(@Nonnull final RelationalMetric.RelationalCount count) { + Assert.thatUnchecked(hasCounter(count), ErrorCode.INTERNAL_ERROR, + "Cannot find metrics associated for requested event: %s", count.title()); + final StoreTimer.Counter counter = storeTimer.getCounter(count); + Assert.thatUnchecked(counter.getTimeNanos() == 0, ErrorCode.INTERNAL_ERROR, + "Event: %s records time and is probably a event timer", count.title()); + return counter.getCount(); + } + + @Override + public boolean hasCounter(@Nonnull final RelationalMetric.RelationalCount count) { + return storeTimer.getCounter(count) != null; + } +} diff --git a/fdb-relational-core/src/main/java/com/apple/foundationdb/relational/recordlayer/query/cache/RelationalPlanCache.java b/fdb-relational-core/src/main/java/com/apple/foundationdb/relational/recordlayer/query/cache/RelationalPlanCache.java index 13e8192d8a..02f8872c0f 100644 --- a/fdb-relational-core/src/main/java/com/apple/foundationdb/relational/recordlayer/query/cache/RelationalPlanCache.java +++ b/fdb-relational-core/src/main/java/com/apple/foundationdb/relational/recordlayer/query/cache/RelationalPlanCache.java @@ -27,6 +27,8 @@ import javax.annotation.Nonnull; import javax.annotation.Nullable; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.Executor; import java.util.concurrent.TimeUnit; @@ -45,6 +47,9 @@ public final class RelationalPlanCache extends MultiStageCache preparedTemplates = ConcurrentHashMap.newKeySet(); + private RelationalPlanCache(int size, int secondarySize, int tertiarySize, @@ -102,4 +107,12 @@ public static RelationalPlanCache buildWithDefaults() { return newRelationalCacheBuilder().build(); } + public boolean isPrepared(@Nonnull String templateKey) { + return preparedTemplates.contains(templateKey); + } + + public void markPrepared(@Nonnull String templateKey) { + preparedTemplates.add(templateKey); + } + } diff --git a/fdb-relational-core/src/main/java/com/apple/foundationdb/relational/recordlayer/query/visitors/DdlVisitor.java b/fdb-relational-core/src/main/java/com/apple/foundationdb/relational/recordlayer/query/visitors/DdlVisitor.java index 41c0216d86..036f991eb3 100644 --- a/fdb-relational-core/src/main/java/com/apple/foundationdb/relational/recordlayer/query/visitors/DdlVisitor.java +++ b/fdb-relational-core/src/main/java/com/apple/foundationdb/relational/recordlayer/query/visitors/DdlVisitor.java @@ -414,6 +414,14 @@ public ProceduralPlan visitCreateSchemaTemplateStatement(@Nonnull RelationalPars sqlInvokedFunctionClauses.add(templateClause.sqlInvokedFunction()); } else if (templateClause.viewDefinition() != null) { viewClauses.add(templateClause.viewDefinition()); + } else if (templateClause.queryDefinition() != null) { + final var queryCtx = templateClause.queryDefinition(); + final var name = visitUid(queryCtx.queryName).getName(); + final var sourceText = getDelegate().getPlanGenerationContext().getQuery(); + final var start = queryCtx.storedQuery.start.getStartIndex(); + final var stop = queryCtx.storedQuery.stop.getStopIndex() + 1; + final var queryString = sourceText.substring(start, stop); + metadataBuilder.addStoredQuery(name, queryString); } else { Assert.thatUnchecked(templateClause.indexDefinition() != null); indexClauses.add(templateClause.indexDefinition()); diff --git a/fdb-relational-core/src/main/java/com/apple/foundationdb/relational/recordlayer/query/visitors/DelegatingVisitor.java b/fdb-relational-core/src/main/java/com/apple/foundationdb/relational/recordlayer/query/visitors/DelegatingVisitor.java index 5f41b70c75..2f906d365a 100644 --- a/fdb-relational-core/src/main/java/com/apple/foundationdb/relational/recordlayer/query/visitors/DelegatingVisitor.java +++ b/fdb-relational-core/src/main/java/com/apple/foundationdb/relational/recordlayer/query/visitors/DelegatingVisitor.java @@ -304,6 +304,11 @@ public Object visitViewDefinition(final RelationalParser.ViewDefinitionContext c return getDelegate().visitViewDefinition(ctx); } + @Override + public Object visitQueryDefinition(final RelationalParser.QueryDefinitionContext ctx) { + return getDelegate().visitQueryDefinition(ctx); + } + @Override public CompiledSqlFunction visitTempSqlInvokedFunction(final RelationalParser.TempSqlInvokedFunctionContext ctx) { return getDelegate().visitTempSqlInvokedFunction(ctx); diff --git a/fdb-relational-core/src/test/java/com/apple/foundationdb/relational/recordlayer/EmbeddedRelationalExtension.java b/fdb-relational-core/src/test/java/com/apple/foundationdb/relational/recordlayer/EmbeddedRelationalExtension.java index 9df631bbce..7944579ff0 100644 --- a/fdb-relational-core/src/test/java/com/apple/foundationdb/relational/recordlayer/EmbeddedRelationalExtension.java +++ b/fdb-relational-core/src/test/java/com/apple/foundationdb/relational/recordlayer/EmbeddedRelationalExtension.java @@ -162,6 +162,11 @@ public RelationalDriver getDriver() { return driver; } + @Nonnull + public MetricRegistry getMetricRegistry() { + return storeTimer; + } + @Nonnull public static Resource newAsResource() throws Exception { return new Resource(new EmbeddedRelationalExtension()); diff --git a/fdb-relational-core/src/test/java/com/apple/foundationdb/relational/recordlayer/metadata/NoOpSchemaTemplateTests.java b/fdb-relational-core/src/test/java/com/apple/foundationdb/relational/recordlayer/metadata/NoOpSchemaTemplateTests.java index 2698dbb7da..a314f16132 100644 --- a/fdb-relational-core/src/test/java/com/apple/foundationdb/relational/recordlayer/metadata/NoOpSchemaTemplateTests.java +++ b/fdb-relational-core/src/test/java/com/apple/foundationdb/relational/recordlayer/metadata/NoOpSchemaTemplateTests.java @@ -234,4 +234,11 @@ public void testGenerateSchemaWithDifferentParameters() { assertEquals("schema1", schema1.getName()); assertEquals("schema2", schema2.getName()); } + + @Test + public void testGetStoredQueriesReturnsEmptyMap() { + final NoOpSchemaTemplate template = new NoOpSchemaTemplate("test", 1); + assertNotNull(template.getStoredQueries()); + assertEquals(0, template.getStoredQueries().size()); + } } diff --git a/fdb-relational-core/src/test/java/com/apple/foundationdb/relational/recordlayer/query/DelegatingVisitorTest.java b/fdb-relational-core/src/test/java/com/apple/foundationdb/relational/recordlayer/query/DelegatingVisitorTest.java index 7b046c90b8..323cae1fc5 100644 --- a/fdb-relational-core/src/test/java/com/apple/foundationdb/relational/recordlayer/query/DelegatingVisitorTest.java +++ b/fdb-relational-core/src/test/java/com/apple/foundationdb/relational/recordlayer/query/DelegatingVisitorTest.java @@ -229,6 +229,21 @@ public Object visitViewDefinition(RelationalParser.ViewDefinitionContext ctx) { }); } + @Test + void visitQueryDefinitionTest() { + testSimple("QUERY q AS SELECT * FROM table1", + RelationalParser::queryDefinition, + DelegatingVisitor::visitQueryDefinition, + called -> new BaseVisitor(new MutablePlanGenerationContext(PreparedParams.empty(), PlanHashable.PlanHashMode.VC0, "", "", 42), + generateMetadata(), NoOpQueryFactory.INSTANCE, NoOpMetadataOperationsFactory.INSTANCE, URI.create("/FDB/FRL1"), false) { + @Override + public Object visitQueryDefinition(RelationalParser.QueryDefinitionContext ctx) { + called.setTrue(); + return null; + } + }); + } + @Test void visitUserDefinedScalarFunctionCallTest() { testSimple("myFunction(123)", diff --git a/fdb-relational-core/src/test/java/com/apple/foundationdb/relational/recordlayer/query/SchemaTemplateStoredQueriesTest.java b/fdb-relational-core/src/test/java/com/apple/foundationdb/relational/recordlayer/query/SchemaTemplateStoredQueriesTest.java new file mode 100644 index 0000000000..e7e37068b8 --- /dev/null +++ b/fdb-relational-core/src/test/java/com/apple/foundationdb/relational/recordlayer/query/SchemaTemplateStoredQueriesTest.java @@ -0,0 +1,393 @@ +/* + * SchemaTemplateStoredQueriesTest.java + * + * This source file is part of the FoundationDB open source project + * + * Copyright 2021-2026 Apple Inc. and the FoundationDB project authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.apple.foundationdb.relational.recordlayer.query; + +import com.apple.foundationdb.relational.api.RelationalConnection; +import com.apple.foundationdb.relational.api.RelationalResultSet; +import com.apple.foundationdb.relational.api.exceptions.ErrorCode; +import com.apple.foundationdb.relational.api.metrics.RelationalMetric; +import com.apple.foundationdb.relational.recordlayer.EmbeddedRelationalConnection; +import com.apple.foundationdb.relational.recordlayer.EmbeddedRelationalExtension; +import com.apple.foundationdb.relational.recordlayer.metadata.RecordLayerSchemaTemplate; +import com.apple.foundationdb.relational.recordlayer.query.cache.QueryCacheKey; +import com.apple.foundationdb.relational.recordlayer.query.cache.RelationalPlanCache; +import com.apple.foundationdb.relational.utils.ConnectionUtils; +import com.apple.foundationdb.relational.utils.Ddl; +import com.apple.foundationdb.relational.utils.RelationalAssertions; +import com.codahale.metrics.MetricFilter; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import java.net.URI; +import java.sql.SQLException; + +public class SchemaTemplateStoredQueriesTest { + + private static final String SCHEMA_TEMPLATE = + "CREATE TABLE t1(id bigint, col1 bigint, col2 bigint, PRIMARY KEY(id))" + + " CREATE INDEX i1 AS SELECT col1 FROM t1" + + " CREATE QUERY by_col1 AS select * from t1 where col1 = 10" + + " CREATE QUERY by_id AS select * from t1 where id = 1"; + + @RegisterExtension + @Order(0) + public final EmbeddedRelationalExtension relationalExtension = new EmbeddedRelationalExtension(); + + @BeforeEach + void clearMetrics() { + relationalExtension.getMetricRegistry().removeMatching(MetricFilter.ALL); + } + + private long eventCounterCount(RelationalMetric.RelationalCount count) { + return relationalExtension.getMetricRegistry().counter(count.title()).getCount(); + } + + private long countCachedPlans(RelationalConnection connection, String templateName) throws SQLException { + final var embeddedConnection = connection.unwrap(EmbeddedRelationalConnection.class); + final RelationalPlanCache cache = embeddedConnection.getRecordLayerDatabase().getPlanCache(); + if (cache == null) { + return 0; + } + long total = 0; + for (QueryCacheKey secondaryKey : cache.getStats().getAllSecondaryKeys(templateName)) { + total += cache.getStats().getAllTertiaryMappings(templateName, secondaryKey).size(); + } + return total; + } + + private void showCache(RelationalConnection connection) throws SQLException { + final var embeddedConnection = connection.unwrap(EmbeddedRelationalConnection.class); + final RelationalPlanCache cache = embeddedConnection.getRecordLayerDatabase().getPlanCache(); + if (cache == null) { + System.out.println("[CACHE] no plan cache"); + return; + } + for (String key : cache.getStats().getAllKeys()) { + System.out.println("[CACHE] template: " + key); + for (QueryCacheKey secondaryKey : cache.getStats().getAllSecondaryKeys(key)) { + System.out.println("[CACHE] query: " + secondaryKey.getCanonicalQueryString() + + " (version=" + secondaryKey.getSchemaTemplateVersion() + ")"); + var tertiaryMappings = cache.getStats().getAllTertiaryMappings(key, secondaryKey); + for (var entry : tertiaryMappings.entrySet()) { + System.out.println("[CACHE] key: " + entry.getKey().toString()); + System.out.println("[CACHE] plan: " + entry.getValue().explain()); + } + } + } + } + + @Test + void storedQueriesInTemplate() throws Exception { + try (var ddl = Ddl.builder() + .database(URI.create("/TEST/STOREDQUERIES_DB")) + .relationalExtension(relationalExtension) + .schemaTemplate(SCHEMA_TEMPLATE) + .build()) { + final var connection = ddl.setSchemaAndGetConnection(); + final var embeddedConnection = connection.unwrap(EmbeddedRelationalConnection.class); + embeddedConnection.setAutoCommit(false); + embeddedConnection.createNewTransaction(); + final var schemaTemplate = embeddedConnection.getSchemaTemplate().unwrap(RecordLayerSchemaTemplate.class); + embeddedConnection.rollback(); + embeddedConnection.setAutoCommit(true); + final var storedQueries = schemaTemplate.getStoredQueries(); + Assertions.assertEquals(2, storedQueries.size()); + Assertions.assertEquals("select * from t1 where col1 = 10", storedQueries.get("BY_COL1")); + Assertions.assertEquals("select * from t1 where id = 1", storedQueries.get("BY_ID")); + Assertions.assertEquals(0, countCachedPlans(connection, ddl.getSchemaTemplateName())); // we do not generate plans at ddl execution for now + } + } + + @Test + void startupPlanGeneration() throws Exception { + try (var ddl = Ddl.builder() + .database(URI.create("/TEST/RESTART_DB")) + .relationalExtension(relationalExtension) + .schemaTemplate(SCHEMA_TEMPLATE) + .build()) { + final String templateName = ddl.getSchemaTemplateName(); + + // create a new engine + final var freshDriver = relationalExtension.getDriver( + com.apple.foundationdb.record.provider.foundationdb.FormatVersion.getDefaultFormatVersion()); + + // Connect via the fresh driver and verify the fresh engine's cache has the plans + Assertions.assertEquals(Long.valueOf(2), new ConnectionUtils(freshDriver).getFromCatalog( + conn -> countCachedPlans(conn, templateName))); + } + } + + @Test + void storedQueriesUsage() throws Exception { + final String dbUri = "/TEST/STOREDQUERIES_DB2"; + try (var ddl = Ddl.builder() + .database(URI.create(dbUri)) + .relationalExtension(relationalExtension) + .schemaTemplate(SCHEMA_TEMPLATE) + .build()) { + final var connection = ddl.setSchemaAndGetConnection(); + final String templateName = ddl.getSchemaTemplateName(); + final String schemaName = connection.getSchema(); + + Assertions.assertEquals(0, countCachedPlans(connection, templateName)); + + try (var stmt = connection.createStatement()) { + stmt.execute("INSERT INTO T1 VALUES (1, 10, 1)"); + stmt.execute("INSERT INTO T1 VALUES (2, 20, 2)"); + stmt.execute("INSERT INTO T1 VALUES (3, 30, 3)"); + } + Assertions.assertEquals(0, countCachedPlans(connection, templateName)); // we do not generate plans at ddl execution for now + + // create a new engine + final var freshDriver = relationalExtension.getDriver( + com.apple.foundationdb.record.provider.foundationdb.FormatVersion.getDefaultFormatVersion()); + final var freshUtils = new ConnectionUtils(freshDriver); + + // OfflineStoredQueriesProcessor ran during fresh-engine construction and + // warmed both stored queries: 2 L3 cache misses. + Assertions.assertEquals(2, eventCounterCount(RelationalMetric.RelationalCount.PLAN_CACHE_TERTIARY_MISS)); + Assertions.assertEquals(0, eventCounterCount(RelationalMetric.RelationalCount.PLAN_CACHE_TERTIARY_HIT)); + // Connect via the fresh driver and verify the fresh engine's cache has 2 plans + Assertions.assertEquals(Long.valueOf(2), freshUtils.getFromCatalog(c -> countCachedPlans(c, templateName))); + + // select statement should hit the cache, no new entries + freshUtils.runAgainstConnection(dbUri, schemaName, c -> { + try (var stmt = c.createStatement(); RelationalResultSet rs = stmt.executeQuery("select * from t1 where col1 = 10")) { + Assertions.assertTrue(rs.next()); + Assertions.assertEquals(1, rs.getLong("ID")); + Assertions.assertFalse(rs.next()); + } + }); + // query hit the cache: hit counter +1, miss counter unchanged. + Assertions.assertEquals(1, eventCounterCount(RelationalMetric.RelationalCount.PLAN_CACHE_TERTIARY_HIT)); + Assertions.assertEquals(2, eventCounterCount(RelationalMetric.RelationalCount.PLAN_CACHE_TERTIARY_MISS)); + // 2 plans in the cache + Assertions.assertEquals(Long.valueOf(2), freshUtils.getFromCatalog(c -> countCachedPlans(c, templateName))); + + // select statement should hit another cache, no new entries + freshUtils.runAgainstConnection(dbUri, schemaName, c -> { + try (var stmt = c.createStatement(); RelationalResultSet rs = stmt.executeQuery("select * from t1 where id = 1")) { + Assertions.assertTrue(rs.next()); + Assertions.assertEquals(1, rs.getLong("ID")); + Assertions.assertFalse(rs.next()); + } + }); + // query hit the cache too: hit counter +1, miss counter still unchanged. + Assertions.assertEquals(2, eventCounterCount(RelationalMetric.RelationalCount.PLAN_CACHE_TERTIARY_HIT)); + Assertions.assertEquals(2, eventCounterCount(RelationalMetric.RelationalCount.PLAN_CACHE_TERTIARY_MISS)); + // 2 plans in the cache + Assertions.assertEquals(Long.valueOf(2), freshUtils.getFromCatalog(c -> countCachedPlans(c, templateName))); + + // non-stored query, new record in the cache + freshUtils.runAgainstConnection(dbUri, schemaName, c -> { + try (var stmt = c.createStatement(); RelationalResultSet rs = stmt.executeQuery("select * from t1 where col2 = 1")) { + Assertions.assertTrue(rs.next()); + Assertions.assertEquals(1, rs.getLong("ID")); + Assertions.assertFalse(rs.next()); + } + }); + // new (3) plan in the cache + Assertions.assertEquals(Long.valueOf(3), freshUtils.getFromCatalog(c -> countCachedPlans(c, templateName))); + // SELECT col2 is NOT pre-warmed: miss counter +1, hit counter unchanged. + Assertions.assertEquals(2, eventCounterCount(RelationalMetric.RelationalCount.PLAN_CACHE_TERTIARY_HIT)); + Assertions.assertEquals(3, eventCounterCount(RelationalMetric.RelationalCount.PLAN_CACHE_TERTIARY_MISS)); + } + } + + @Test + void storedQueriesUsageParams() throws Exception { + final String dbUri = "/TEST/STOREDQUERIES_DB3"; + try (var ddl = Ddl.builder() + .database(URI.create(dbUri)) + .relationalExtension(relationalExtension) + .schemaTemplate(SCHEMA_TEMPLATE) + .build()) { + final var connection = ddl.setSchemaAndGetConnection(); + final String templateName = ddl.getSchemaTemplateName(); + final String schemaName = connection.getSchema(); + + Assertions.assertEquals(0, countCachedPlans(connection, templateName)); + + try (var stmt = connection.createStatement()) { + stmt.execute("INSERT INTO T1 VALUES (1, 10, 1)"); + stmt.execute("INSERT INTO T1 VALUES (2, 20, 2)"); + stmt.execute("INSERT INTO T1 VALUES (3, 30, 3)"); + } + Assertions.assertEquals(0, countCachedPlans(connection, templateName)); // we do not generate plans at ddl execution for now + + // create a new engine + final var freshDriver = relationalExtension.getDriver( + com.apple.foundationdb.record.provider.foundationdb.FormatVersion.getDefaultFormatVersion()); + final var freshUtils = new ConnectionUtils(freshDriver); + + // Connect via the fresh driver and verify the fresh engine's cache has the plans + Assertions.assertEquals(Long.valueOf(2), freshUtils.getFromCatalog(c -> countCachedPlans(c, templateName))); + + // select with different literal than stored — canonical SQL matches, cache hit + freshUtils.runAgainstConnection(dbUri, schemaName, c -> { + try (var stmt = c.createStatement(); RelationalResultSet rs = stmt.executeQuery("select * from t1 where col1 = 20")) { + Assertions.assertTrue(rs.next()); + Assertions.assertEquals(2, rs.getLong("ID")); + Assertions.assertFalse(rs.next()); + } + }); + Assertions.assertEquals(Long.valueOf(2), freshUtils.getFromCatalog(c -> countCachedPlans(c, templateName))); + + // select with different literal than stored — canonical SQL matches, cache hit + freshUtils.runAgainstConnection(dbUri, schemaName, c -> { + try (var stmt = c.createStatement(); RelationalResultSet rs = stmt.executeQuery("select * from t1 where id = 2")) { + Assertions.assertTrue(rs.next()); + Assertions.assertEquals(2, rs.getLong("ID")); + Assertions.assertFalse(rs.next()); + } + }); + Assertions.assertEquals(Long.valueOf(2), freshUtils.getFromCatalog(c -> countCachedPlans(c, templateName))); + } + } + + @Test + void storedQueriesUsageJdbcPrepare() throws Exception { + final String dbUri = "/TEST/STOREDQUERIES_DB4"; + try (var ddl = Ddl.builder() + .database(URI.create(dbUri)) + .relationalExtension(relationalExtension) + .schemaTemplate(SCHEMA_TEMPLATE) + .build()) { + final var connection = ddl.setSchemaAndGetConnection(); + final String templateName = ddl.getSchemaTemplateName(); + final String schemaName = connection.getSchema(); + + Assertions.assertEquals(0, countCachedPlans(connection, templateName)); + + try (var stmt = connection.createStatement()) { + stmt.execute("INSERT INTO T1 VALUES (1, 10, 1)"); + stmt.execute("INSERT INTO T1 VALUES (2, 20, 2)"); + stmt.execute("INSERT INTO T1 VALUES (3, 30, 3)"); + } + Assertions.assertEquals(0, countCachedPlans(connection, templateName)); // we do not generate plans at ddl execution for now + + // create a new engine + final var freshDriver = relationalExtension.getDriver( + com.apple.foundationdb.record.provider.foundationdb.FormatVersion.getDefaultFormatVersion()); + final var freshUtils = new ConnectionUtils(freshDriver); + + // Connect via the fresh driver and verify the fresh engine's cache has the plans + Assertions.assertEquals(Long.valueOf(2), freshUtils.getFromCatalog(c -> countCachedPlans(c, templateName))); + + // JDBC PreparedStatement on col1 with bound parameter — canonical SQL matches stored BY_COL1, cache hit + freshUtils.runAgainstConnection(dbUri, schemaName, c -> { + try (var ps = c.prepareStatement("select * from t1 where col1 = ?")) { + ps.setInt(1, 20); + try (RelationalResultSet rs = ps.executeQuery()) { + Assertions.assertTrue(rs.next()); + Assertions.assertEquals(2, rs.getLong("ID")); + Assertions.assertFalse(rs.next()); + } + } + }); + Assertions.assertEquals(Long.valueOf(2), freshUtils.getFromCatalog(c -> countCachedPlans(c, templateName))); + + // JDBC PreparedStatement on id with bound parameter — canonical SQL matches stored BY_ID, cache hit + freshUtils.runAgainstConnection(dbUri, schemaName, c -> { + try (var ps = c.prepareStatement("select * from t1 where id = ?")) { + ps.setInt(1, 2); + try (RelationalResultSet rs = ps.executeQuery()) { + Assertions.assertTrue(rs.next()); + Assertions.assertEquals(2, rs.getLong("ID")); + Assertions.assertFalse(rs.next()); + } + } + }); + Assertions.assertEquals(Long.valueOf(2), freshUtils.getFromCatalog(c -> countCachedPlans(c, templateName))); + } + } + + @Test + void badStoredQuery() { + final String badTemplate = + "CREATE TABLE t1(id bigint, col1 bigint, col2 bigint, PRIMARY KEY(id))" + + " CREATE QUERY by_col1 AS select1 * from t1 where col1 = 10"; + RelationalAssertions.assertThrowsSqlException(() -> + Ddl.builder() + .database(URI.create("/TEST/BADSTOREDQUERY_DB")) + .relationalExtension(relationalExtension) + .schemaTemplate(badTemplate) + .build()) + .hasErrorCode(ErrorCode.SYNTAX_ERROR); + } + + @Test + void storedQueryDdl() { + final String badTemplate = + "CREATE TABLE t1(id bigint, col1 bigint, col2 bigint, PRIMARY KEY(id))" + + " CREATE QUERY ddl_t AS CREATE TABLE t2(id bigint, col1 bigint, PRIMARY KEY(id))"; + RelationalAssertions.assertThrowsSqlException(() -> + Ddl.builder() + .database(URI.create("/TEST/DDLSTOREDQUERY_DB")) + .relationalExtension(relationalExtension) + .schemaTemplate(badTemplate) + .build()) + .hasErrorCode(ErrorCode.SYNTAX_ERROR); + } + + @Test + void storedQueryBadColumn() throws Exception { + final String template = + "CREATE TABLE t1(id bigint, col1 bigint, col2 bigint, PRIMARY KEY(id))" + + " CREATE INDEX i1 AS SELECT col1 FROM t1" + + " CREATE QUERY by_col1 AS select * from t1 where col3 = 10" + // col3 does not exit + " CREATE QUERY by_id AS select * from t1 where id = 1"; + final String dbUri = "/TEST/STOREDQUERIES_DB5"; + try (var ddl = Ddl.builder() + .database(URI.create(dbUri)) + .relationalExtension(relationalExtension) + .schemaTemplate(template) + .build()) { + final var connection = ddl.setSchemaAndGetConnection(); + final String templateName = ddl.getSchemaTemplateName(); + + Assertions.assertEquals(0, countCachedPlans(connection, templateName)); + + try (var stmt = connection.createStatement()) { + stmt.execute("INSERT INTO T1 VALUES (1, 10, 1)"); + stmt.execute("INSERT INTO T1 VALUES (2, 20, 2)"); + stmt.execute("INSERT INTO T1 VALUES (3, 30, 3)"); + } + Assertions.assertEquals(0, countCachedPlans(connection, templateName)); // we do not generate plans at ddl execution for now + + // create a new engine + final var freshDriver = relationalExtension.getDriver( + com.apple.foundationdb.record.provider.foundationdb.FormatVersion.getDefaultFormatVersion()); + final var freshUtils = new ConnectionUtils(freshDriver); + + // OfflineStoredQueriesProcessor ran during fresh-engine construction and + // both stored queries attempted to generate plan + Assertions.assertEquals(2, eventCounterCount(RelationalMetric.RelationalCount.PLAN_CACHE_TERTIARY_MISS)); + Assertions.assertEquals(0, eventCounterCount(RelationalMetric.RelationalCount.PLAN_CACHE_TERTIARY_HIT)); + + // but only one query has valid column and was planned + Assertions.assertEquals(Long.valueOf(1), freshUtils.getFromCatalog(c -> countCachedPlans(c, templateName))); + } + } +} diff --git a/fdb-relational-core/src/test/java/com/apple/foundationdb/relational/recordlayer/query/cache/OfflineMetricCollectorTest.java b/fdb-relational-core/src/test/java/com/apple/foundationdb/relational/recordlayer/query/cache/OfflineMetricCollectorTest.java new file mode 100644 index 0000000000..151b95180a --- /dev/null +++ b/fdb-relational-core/src/test/java/com/apple/foundationdb/relational/recordlayer/query/cache/OfflineMetricCollectorTest.java @@ -0,0 +1,72 @@ +/* + * OfflineMetricCollectorTest.java + * + * This source file is part of the FoundationDB open source project + * + * Copyright 2021-2026 Apple Inc. and the FoundationDB project authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.apple.foundationdb.relational.recordlayer.query.cache; + +import com.apple.foundationdb.relational.api.exceptions.UncheckedRelationalException; +import com.apple.foundationdb.relational.api.metrics.RelationalMetric; +import com.codahale.metrics.MetricRegistry; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public class OfflineMetricCollectorTest { + + @Test + void hasCounterReturnsFalseBeforeAndTrueAfterIncrement() { + final var collector = new OfflineMetricCollector(new MetricRegistry()); + Assertions.assertFalse(collector.hasCounter(RelationalMetric.RelationalCount.PLAN_CACHE_TERTIARY_HIT)); + + collector.increment(RelationalMetric.RelationalCount.PLAN_CACHE_TERTIARY_HIT, 1); + Assertions.assertTrue(collector.hasCounter(RelationalMetric.RelationalCount.PLAN_CACHE_TERTIARY_HIT)); + } + + @Test + void getCountsForCounterReturnsAccumulatedValue() { + final var collector = new OfflineMetricCollector(new MetricRegistry()); + collector.increment(RelationalMetric.RelationalCount.PLAN_CACHE_TERTIARY_HIT, 2); + collector.increment(RelationalMetric.RelationalCount.PLAN_CACHE_TERTIARY_HIT, 3); + Assertions.assertEquals(5L, collector.getCountsForCounter(RelationalMetric.RelationalCount.PLAN_CACHE_TERTIARY_HIT)); + } + + @Test + void getCountsForCounterThrowsWhenAbsent() { + final var collector = new OfflineMetricCollector(new MetricRegistry()); + Assertions.assertThrows(UncheckedRelationalException.class, () -> + collector.getCountsForCounter(RelationalMetric.RelationalCount.PLAN_CACHE_TERTIARY_HIT)); + } + + @Test + void getAverageTimeMicrosForEventReturnsAverageOfClockedSamples() throws Exception { + final var collector = new OfflineMetricCollector(new MetricRegistry()); + collector.clock(RelationalMetric.RelationalEvent.LEX_PARSE, () -> null); + collector.clock(RelationalMetric.RelationalEvent.LEX_PARSE, () -> null); + // Two samples were recorded; both ran to completion, so the average must be a finite non-negative number. + final double averageMicros = collector.getAverageTimeMicrosForEvent(RelationalMetric.RelationalEvent.LEX_PARSE); + Assertions.assertTrue(averageMicros >= 0.0, + "average time should be non-negative, got: " + averageMicros); + } + + @Test + void getAverageTimeMicrosForEventThrowsWhenAbsent() { + final var collector = new OfflineMetricCollector(new MetricRegistry()); + Assertions.assertThrows(UncheckedRelationalException.class, () -> + collector.getAverageTimeMicrosForEvent(RelationalMetric.RelationalEvent.LEX_PARSE)); + } +} diff --git a/yaml-tests/src/test/java/YamlIntegrationTests.java b/yaml-tests/src/test/java/YamlIntegrationTests.java index ef3d79e3d9..d6952ed45d 100644 --- a/yaml-tests/src/test/java/YamlIntegrationTests.java +++ b/yaml-tests/src/test/java/YamlIntegrationTests.java @@ -392,6 +392,11 @@ public void standardTests(YamlTest.Runner runner) throws Exception { runner.runYamsql("standard-tests.yamsql"); } + @TestTemplate + public void schemaTemplateStoredQueries(YamlTest.Runner runner) throws Exception { + runner.runYamsql("schema-template-stored-queries.yamsql"); + } + @TestTemplate public void standardTestsWithMetaData(YamlTest.Runner runner) throws Exception { runner.runYamsql("standard-tests-metadata.yamsql"); diff --git a/yaml-tests/src/test/resources/schema-template-stored-queries.yamsql b/yaml-tests/src/test/resources/schema-template-stored-queries.yamsql new file mode 100644 index 0000000000..12f5c90af4 --- /dev/null +++ b/yaml-tests/src/test/resources/schema-template-stored-queries.yamsql @@ -0,0 +1,48 @@ +# +# schema-template-stored-queries.yamsql +# +# This source file is part of the FoundationDB open source project +# +# Copyright 2021-2026 Apple Inc. and the FoundationDB project authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +--- +options: + supported_version: !current_version +--- +schema_template: + create table t1(id bigint, col1 bigint, col2 bigint, primary key(id)) + create index i1 as select col1 from t1 + create query by_col1 as select * from t1 where col1 = 1 + create query by_id as select * from t1 where id = 1 +--- +setup: + steps: + - query: INSERT INTO T1 + VALUES (1, 10, 1), + (2, 10, 2), + (3, 20, 3), + (4, 20, 4), + (5, 30, 5) +--- +test_block: + name: schema-template-stored-queries-tests + tests: + - + - query: select * from T1 where col1 = 10 + - result: [{ID: 1, COL1: 10, COL2: 1}, + {ID: 2, COL1: 10, COL2: 2}] + - + - query: select * from T1 where id = 3 + - result: [{ID: 3, COL1: 20, COL2: 3}] \ No newline at end of file