Copilot commented on code in PR #12374:
URL: https://github.com/apache/gravitino/pull/12374#discussion_r3719029460
##########
core/src/main/java/org/apache/gravitino/storage/relational/JDBCBackend.java:
##########
@@ -178,95 +183,45 @@ public boolean exists(NameIdentifier ident,
Entity.EntityType entityType) throws
@Override
public <E extends Entity & HasIdentifier> void insert(E e, boolean
overwritten)
throws EntityAlreadyExistsException, IOException {
- if (e instanceof BaseMetalake) {
- MetalakeMetaService.getInstance().insertMetalake((BaseMetalake) e,
overwritten);
- } else if (e instanceof CatalogEntity) {
- CatalogMetaService.getInstance().insertCatalog((CatalogEntity) e,
overwritten);
- } else if (e instanceof SchemaEntity) {
- SchemaMetaService.getInstance().insertSchema((SchemaEntity) e,
overwritten);
- } else if (e instanceof TableEntity) {
- TableMetaService.getInstance().insertTable((TableEntity) e, overwritten);
- } else if (e instanceof FilesetEntity) {
- FilesetMetaService.getInstance().insertFileset((FilesetEntity) e,
overwritten);
- } else if (e instanceof TopicEntity) {
- TopicMetaService.getInstance().insertTopic((TopicEntity) e, overwritten);
- } else if (e instanceof UserEntity) {
- UserMetaService.getInstance().insertUser((UserEntity) e, overwritten);
- } else if (e instanceof RoleEntity) {
- RoleMetaService.getInstance().insertRole((RoleEntity) e, overwritten);
- } else if (e instanceof GroupEntity) {
- GroupMetaService.getInstance().insertGroup((GroupEntity) e, overwritten);
- } else if (e instanceof TagEntity) {
- TagMetaService.getInstance().insertTag((TagEntity) e, overwritten);
- } else if (e instanceof ModelEntity) {
- ModelMetaService.getInstance().insertModel((ModelEntity) e, overwritten);
- } else if (e instanceof ModelVersionEntity) {
- if (overwritten) {
- LOG.warn(
- "'overwritten' is not supported for model version meta, ignoring
this flag and "
- + "inserting the new model version.");
+ if (!overwritten || !BaseEntityCache.isCacheable(e.type())) {
+ insertEntity(e, overwritten);
+ return;
+ }
+
+ SessionUtils.beginTransaction();
+ boolean committed = false;
+ try {
+ insertEntity(e, true);
+ insertEntityChange(e.nameIdentifier(), e.type(), OperateType.ALTER);
+ SessionUtils.commitTransaction();
+ committed = true;
+ } finally {
+ if (!committed) {
+ SessionUtils.rollbackTransaction();
}
-
ModelVersionMetaService.getInstance().insertModelVersion((ModelVersionEntity)
e);
- } else if (e instanceof FunctionEntity) {
- FunctionMetaService.getInstance().insertFunction((FunctionEntity) e,
overwritten);
- } else if (e instanceof PolicyEntity) {
- PolicyMetaService.getInstance().insertPolicy((PolicyEntity) e,
overwritten);
- } else if (e instanceof JobTemplateEntity) {
-
JobTemplateMetaService.getInstance().insertJobTemplate((JobTemplateEntity) e,
overwritten);
- } else if (e instanceof JobEntity) {
- JobMetaService.getInstance().insertJob((JobEntity) e, overwritten);
- } else if (e instanceof ViewEntity) {
- ViewMetaService.getInstance().insertView((ViewEntity) e, overwritten);
- } else if (e instanceof GenericEntity) {
- GenericEntity genericEntity = (GenericEntity) e;
- throw new UnsupportedEntityTypeException(
- "Unsupported entity type: %s for insert operation",
genericEntity.type());
- } else {
- throw new UnsupportedEntityTypeException(
- "Unsupported entity type: %s for insert operation", e.getClass());
}
}
@Override
public <E extends Entity & HasIdentifier> E update(
NameIdentifier ident, Entity.EntityType entityType, Function<E, E>
updater)
throws IOException, NoSuchEntityException, EntityAlreadyExistsException {
- switch (entityType) {
- case METALAKE:
- return (E) MetalakeMetaService.getInstance().updateMetalake(ident,
updater);
- case CATALOG:
- return (E) CatalogMetaService.getInstance().updateCatalog(ident,
updater);
- case SCHEMA:
- return (E) SchemaMetaService.getInstance().updateSchema(ident,
updater);
- case TABLE:
- return (E) TableMetaService.getInstance().updateTable(ident, updater);
- case FILESET:
- return (E) FilesetMetaService.getInstance().updateFileset(ident,
updater);
- case TOPIC:
- return (E) TopicMetaService.getInstance().updateTopic(ident, updater);
- case USER:
- return (E) UserMetaService.getInstance().updateUser(ident, updater);
- case GROUP:
- return (E) GroupMetaService.getInstance().updateGroup(ident, updater);
- case ROLE:
- return (E) RoleMetaService.getInstance().updateRole(ident, updater);
- case TAG:
- return (E) TagMetaService.getInstance().updateTag(ident, updater);
- case MODEL:
- return (E) ModelMetaService.getInstance().updateModel(ident, updater);
- case MODEL_VERSION:
- return (E)
ModelVersionMetaService.getInstance().updateModelVersion(ident, updater);
- case FUNCTION:
- return (E) FunctionMetaService.getInstance().updateFunction(ident,
updater);
- case POLICY:
- return (E) PolicyMetaService.getInstance().updatePolicy(ident,
updater);
- case JOB_TEMPLATE:
- return (E)
JobTemplateMetaService.getInstance().updateJobTemplate(ident, updater);
- case VIEW:
- return (E) ViewMetaService.getInstance().updateView(ident, updater);
- default:
- throw new UnsupportedEntityTypeException(
- "Unsupported entity type: %s for update operation", entityType);
+ if (!BaseEntityCache.isCacheable(entityType)) {
+ return updateEntity(ident, entityType, updater);
+ }
+
+ SessionUtils.beginTransaction();
+ boolean committed = false;
+ try {
+ E updatedEntity = updateEntity(ident, entityType, updater);
+ insertEntityChange(ident, entityType, OperateType.ALTER);
+ SessionUtils.commitTransaction();
+ committed = true;
+ return updatedEntity;
+ } finally {
+ if (!committed) {
+ SessionUtils.rollbackTransaction();
+ }
}
Review Comment:
The backend now unconditionally starts/commits/rolls back a transaction for
cacheable updates. This breaks caller-managed transaction semantics unless
`SessionUtils` explicitly supports nested transactions with correct ownership
(e.g., savepoints or reference counting). In this PR, tests also begin a
transaction and then call `backend.update()`, which implies nesting is
expected. Recommendation (mandatory): make transaction ownership explicit by
only beginning/committing/rolling back when there is no existing transaction
(e.g., via a `SessionUtils.isInTransaction()`/`inTransaction()` check), and
otherwise participate in the caller’s transaction without committing it.
##########
core/src/main/java/org/apache/gravitino/storage/relational/EntityCacheChangeLogListener.java:
##########
@@ -0,0 +1,107 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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 org.apache.gravitino.storage.relational;
+
+import com.google.common.base.Preconditions;
+import java.util.List;
+import java.util.Locale;
+import org.apache.gravitino.Entity.EntityType;
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.cache.EntityCache;
+import org.apache.gravitino.storage.relational.po.cache.EntityChangeRecord;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Keeps a per-node {@link EntityCache} coherent across a multi-node cluster
by replaying {@code
+ * entity_change_log} rows written by other nodes.
+ *
+ * <p>Every ALTER/DROP row is replayed as a direct {@link
EntityCache#invalidate(NameIdentifier,
+ * EntityType)} for exactly the changed entity key. Because the cache indexes
its keys by identifier
+ * prefix, invalidating a container (for example a schema) cascades to its
cached children on the
+ * local node through that forward prefix scan; no reverse index is involved.
+ *
+ * <p>This listener is registered only for a {@link
+ * org.apache.gravitino.cache.Coherence#LOCAL_PER_NODE} cache: a shared cache
has a single
+ * cluster-wide copy and nothing per-node to invalidate. It is called
<em>synchronously</em> on the
+ * poller thread, so it performs only fast, in-memory, idempotent
invalidations.
+ */
+public class EntityCacheChangeLogListener implements EntityChangeLogListener {
+
+ private static final Logger LOG =
LoggerFactory.getLogger(EntityCacheChangeLogListener.class);
+
+ private final EntityCache cache;
+
+ /**
+ * Creates a listener that invalidates the given entity store cache.
+ *
+ * @param cache the per-node entity store cache to keep coherent
+ */
+ public EntityCacheChangeLogListener(EntityCache cache) {
+ Preconditions.checkArgument(cache != null, "cache cannot be null");
+ this.cache = cache;
+ }
+
+ @Override
+ public void onEntityChange(List<EntityChangeRecord> changes) {
+ for (EntityChangeRecord change : changes) {
+ try {
+ EntityType type = entityType(change);
+ NameIdentifier ident = identifier(change);
+ if (type == null || ident == null) {
+ continue;
+ }
+
+ LOG.debug("Invalidating entity cache due to entity change log: {}
({})", ident, type);
+ cache.invalidate(ident, type);
+ } catch (RuntimeException e) {
+ LOG.warn(
+ "Failed to process entity change log record: fullName={},
entityType={}",
+ change.getFullName(),
+ change.getEntityType(),
+ e);
+ }
+ }
+ }
+
+ private EntityType entityType(EntityChangeRecord change) {
+ if (change.getEntityType() == null) {
+ LOG.warn("Invalid entity type in entity change log: null");
+ return null;
+ }
+ try {
+ return
EntityType.valueOf(change.getEntityType().toUpperCase(Locale.ROOT));
+ } catch (IllegalArgumentException e) {
+ LOG.warn("Unknown entity type in entity change log: {}",
change.getEntityType());
+ return null;
+ }
+ }
+
+ private NameIdentifier identifier(EntityChangeRecord change) {
+ String fullName = change.getFullName();
+ if (fullName == null || fullName.isEmpty()) {
+ LOG.warn("Invalid full name in entity change log: {}", fullName);
+ return null;
+ }
+ // The change log stores the entity's NameIdentifier#toString(), a
dot-joined full name. Split
+ // it back into levels the same way the catalog cache listener does; this
is exact as long as
+ // no name segment contains a dot.
+ return NameIdentifier.of(fullName.split("\\."));
Review Comment:
Reconstructing `NameIdentifier` by splitting on '.' is only correct if name
segments are guaranteed not to contain dots. If dots are allowed in any
segment, this will invalidate the wrong key(s) and can leave stale cache
entries. Recommendation (mandatory): either enforce 'no dots in names' at
validation time (and reference that invariant here), or change the change-log
encoding/parsing to a lossless format (e.g., store structured name levels or an
escaped/encoded representation).
##########
core/src/main/java/org/apache/gravitino/cache/CaffeineEntityCache.java:
##########
@@ -56,10 +54,9 @@
* schemas and tables under it).
*
* <p>Relation query results are NOT cached by this implementation; relation
and list operations
- * always fall back to the {@code EntityStore}. Entity types whose
materialized form embeds
- * relation-derived data ({@code USER}, {@code GROUP}, {@code ROLE}) are
excluded from caching
- * entirely by {@link BaseEntityCache#put}, because without relation tracking
their entries could
- * not be invalidated when the referenced entities change.
+ * always fall back to the {@code EntityStore}. Only the self-contained
metadata objects listed in
+ * {@link BaseEntityCache#isCacheable} are cached; every other type
(user/group/role, model/model
+ * version, function, and operational entities) is read straight from the
{@code EntityStore}.
Review Comment:
The Javadoc claims 'operational entities' are read straight from the store,
but `BaseEntityCache` now explicitly includes `EntityType.JOB` in the cacheable
allowlist. Recommendation (mandatory): update this Javadoc to match actual
behavior (e.g., remove 'operational entities' or spell out which operational
types are excluded while noting that jobs are cacheable).
##########
core/src/main/java/org/apache/gravitino/storage/relational/RelationalEntityStore.java:
##########
@@ -115,6 +121,14 @@ public void initialize(Config config) throws
RuntimeException {
TimeUnit.SECONDS.toMillis(config.get(Configs.ENTITY_CHANGE_LOG_RETENTION_SECS)),
TimeUnit.SECONDS.toMillis(config.get(Configs.ENTITY_CHANGE_LOG_CLEANUP_INTERVAL_SECS)),
TimeUnit.SECONDS.toMillis(config.get(Configs.ENTITY_CHANGE_LOG_POLL_INTERVAL_SECS)));
+
+ // The coherence gate: a LOCAL_PER_NODE cache keeps its own copy per node,
so changes made on
+ // other nodes must be replayed here through the change log. A SHARED
cache (or a disabled
+ // NoOpsCache) has nothing per-node to invalidate, so no listener is
registered.
+ if (cache.coherence() == Coherence.LOCAL_PER_NODE && !(cache instanceof
NoOpsCache)) {
+ this.entityCacheChangeLogListener = new
EntityCacheChangeLogListener(cache);
+
this.entityChangeLogPoller.registerListener(entityCacheChangeLogListener);
+ }
Review Comment:
Needing `!(cache instanceof NoOpsCache)` alongside `coherence() ==
LOCAL_PER_NODE` suggests the `Coherence` model can’t fully express 'no
caching'. Recommendation (optional): have `NoOpsCache` override `coherence()`
to a value that naturally skips listener registration (e.g., introduce a `NONE`
coherence or define `NoOpsCache` as `SHARED`/non-propagating), so the
registration condition doesn’t require type checks.
##########
core/src/test/java/org/apache/gravitino/storage/relational/service/TestEntityChangeLogService.java:
##########
@@ -64,20 +75,20 @@ private void assertEntityChange(
Entity.EntityType entityType,
String fullName,
OperateType operateType) {
- Assertions.assertTrue(
- listEntityChanges(lastConsumedId).stream()
- .anyMatch(
- record ->
- record.getMetalakeName().equals(metalakeName)
- && record.getEntityType().equals(entityType.name())
- && record.getFullName().equals(fullName)
- && record.getOperateType() == operateType),
- String.format("Missing %s %s changelog for %s", entityType,
operateType, fullName));
+ List<EntityChangeRecord> entityChanges = listEntityChanges(lastConsumedId);
+ Assertions.assertEquals(1, entityChanges.size());
+ EntityChangeRecord entityChange = entityChanges.get(0);
+ Assertions.assertEquals(metalakeName, entityChange.getMetalakeName());
+ Assertions.assertEquals(entityType.name(), entityChange.getEntityType());
+ Assertions.assertEquals(fullName, entityChange.getFullName());
+ Assertions.assertEquals(operateType, entityChange.getOperateType());
Review Comment:
Asserting `entityChanges.size() == 1` makes the tests sensitive to any
additional change-log rows written after `lastConsumedId` (e.g., if tests ever
run concurrently against a shared DB, or if background activity writes to the
log). Recommendation (optional but strongly suggested): assert that the
expected record exists (filter/match on metalake/type/fullName/operateType)
rather than requiring the list to contain exactly one element.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]