Skip to content

Conversation

@olsoloviov
Copy link
Contributor

Flatten Polaris events structure to have a single PolarisEvent implementation with enum type and custom attributes.
This is an implementation of @adutra proposal: https://lists.apache.org/thread/xonxwf9b38t9cxo841r0hn1b34plf7og

Checklist

  • 🛡️ Don't disclose security issues! (contact security@apache.org)
  • 🔗 Clearly explained why the changes are needed, or linked related issues: Fixes #
  • 🧪 Added/updated tests with good coverage, or manually tested (and explained how)
  • 💡 Added comments for complex logic
  • 🧾 Updated CHANGELOG.md (if needed)
  • 📚 Updated documentation in site/content/in-dev/unreleased (if needed)

Copy link
Contributor

@adutra adutra left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very nice work @olsoloviov, that is very much what I had in mind. Left a few minor comments Thanks!

@SuppressWarnings("unchecked")
public static final AttributeKey<Map<String, String>> NAMESPACE_PROPERTIES =
(AttributeKey<Map<String, String>>)
(AttributeKey<?>) AttributeKey.of("namespace_properties", Map.class);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm wondering: in order to increase portability, shouldn't we restrict the types of attributes that we can put in this map? An attribute of type Map<String, String> is still OK, I guess, but what if the attribute doesn't have a clear serialized format, e.g. Optional or Function? Restricting to types that can be safely serialized to Json (and maybe gRPC) would imho make things easier for listener implementors.

Copy link
Contributor Author

@olsoloviov olsoloviov Dec 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't it be handled during serialization phase? We will have pruning anyway. Also, not sure if it is a desirable scenario, but some non-serializable helper context could be passed with an event.
If you strongly prefer to restrict the types, we could whitelist the types during attributes creation, wdyt @adutra?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Admittedly I haven't thought through all the ramifications, but initially I agree with @adutra on this one. Maybe we just keep the bar that whatever the object is, it must be implementing Serializable?

Personally, I'd rather have this enforced at the time of the event generation than having to deal with it later in the Event lifecycle - but not a strong opinion.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have added a white list for allowed attributes types

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While widely adopted in the Hadoop ecosystem, Serializable is generally viewed as a legacy feature in Java. Leveraging this marker interface here imho wouldn't achieve our core objective, which is to ensure the attributes map can be easily serialized using common wire formats, particularly JSON. For example, many objects that we want to allow aren't Serializable, e.g. the request and response types.

All of this to say: if my suggestion proves too difficult to implement, I am fine if we don't enforce anything.

}

protected abstract void processEvent(String realmId, PolarisEvent event);
private <T> T getRequiredAttribute(PolarisEvent event, AttributeKey<T> key) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could be declared in PolarisEvent, I think it's going to be useful for many listeners, not just this one.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved to PolarisEvent, also switched to throw instead of logging

}
}

private void handleAfterCreateTable(PolarisEvent event) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Again, with flattened events hierarchy there is not a huge difference between these two handleXYZ methods. It would certainly be possible to create a unified handleEvent method that is valid for all events, thus saving us the hassle of writing 150+ methods (just a thought for later, not for this PR though).

Copy link
Contributor

@adnanhemani adnanhemani left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A very nice job done on this one, @olsoloviov - thank you so much! I left a handful of nit comments and a few minor ones as well, but I think this is very close to merging!

@SuppressWarnings("unchecked")
public static final AttributeKey<Map<String, String>> NAMESPACE_PROPERTIES =
(AttributeKey<Map<String, String>>)
(AttributeKey<?>) AttributeKey.of("namespace_properties", Map.class);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Admittedly I haven't thought through all the ramifications, but initially I agree with @adutra on this one. Maybe we just keep the bar that whatever the object is, it must be implementing Serializable?

Personally, I'd rather have this enforced at the time of the event generation than having to deal with it later in the Event lifecycle - but not a strong opinion.

Assertions.assertThat(beforeRefreshEvent.tableIdentifier()).isEqualTo(TestData.TABLE);
PolarisEvent beforeRefreshEvent =
testPolarisEventListener.getLatest(PolarisEventType.BEFORE_REFRESH_TABLE);
Assertions.assertThat(beforeRefreshEvent.attribute(EventAttributes.TABLE_IDENTIFIER))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Let's test these using requiredAttribute rather than attribute?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Commenting in case you forgot to make this change :)

}

@Test
void testTypeCastValidValue() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure I see value in this test case. Maybe do something like making an AttributeKey of a Long value, then give it an int and see if it casted that properly?

IOW, we shouldn't really be testing cast if we didn't write it. We should be testing the code that we wrote.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, @adnanhemani, for your comments, I agree that it does not make any sense to test Class.cast()

}

@Test
void testTypeCastInvalidType() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to above, I would move this test into the test for PolarisEvent and test that sending an int to an AttributeKey would throw when calling key.attribute(..., 123).

void testBuilderCreatesEvent() {
PolarisEvent event =
PolarisEvent.builder(PolarisEventType.BEFORE_CREATE_TABLE, TEST_METADATA)
.attribute(EventAttributes.CATALOG_NAME, "my-catalog")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: extract the magic variables for maintainability


assertThatThrownBy(() -> event.requiredAttribute(EventAttributes.CATALOG_NAME))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("catalog_name")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Can we test for a more descriptive message? Not looking for exact string matching but something similar to "Required attribute" and/or "not found in event".

Boolean.class,
Integer.class,
Long.class,
Double.class,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing Float? Also: we could probably authorize any type that extends Number, including BigInteger and BigDecimal.

Long.class,
Double.class,
// Collections
List.class,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is probably too loose. We are not checking the collections element types here, so one could include a list or a set of any element type, effectively beating the purpose of this class in the first place. I would suggest to also check the element type and the map's key and value types: these should conform to the list specified here as well.

TableMetadata.class,
ViewMetadata.class,
// Iceberg REST request types
CreateNamespaceRequest.class,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe you could check if the type implements RESTMessage?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here is a suggestion that leverages TypeToken in order to check element types of List, Set and Map:

  static final Set<Class<?>> ALLOWED_TYPES =
      Set.of(
          String.class,
          Boolean.class,
          Number.class,
          RESTMessage.class,
          Namespace.class,
          TableIdentifier.class,
          TableMetadata.class,
          ViewMetadata.class,
          Catalog.class,
          Principal.class,
          PrincipalRole.class,
          PrincipalWithCredentials.class,
          CatalogRole.class,
          GrantResource.class,
          PolarisPrivilege.class,
          GenericTable.class);

  static boolean isAllowed(TypeToken<?> type) {
    Class<?> rawType = type.getRawType();
    if (rawType.equals(List.class)) {
      TypeToken<?> elementType = type.resolveType(List.class.getTypeParameters()[0]);
      return isSubtypeOfAllowedType(elementType.getRawType());
    } else if (rawType.equals(Set.class)) {
      TypeToken<?> elementType = type.resolveType(Set.class.getTypeParameters()[0]);
      return isSubtypeOfAllowedType(elementType.getRawType());
    } else if (rawType.equals(Map.class)) {
      TypeToken<?> keyType = type.resolveType(Map.class.getTypeParameters()[0]);
      TypeToken<?> valueType = type.resolveType(Map.class.getTypeParameters()[1]);
      return isSubtypeOfAllowedType(keyType.getRawType())
          && isSubtypeOfAllowedType(valueType.getRawType());
    } else {
      return isSubtypeOfAllowedType(rawType);
    }
  }

  private static boolean isSubtypeOfAllowedType(Class<?> rawType) {
    return ALLOWED_TYPES.stream().anyMatch(t -> t.isAssignableFrom(rawType));
  }

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've seen the updated version, that's much better, thanks! But I'm now concerned about the performance aspect. All this computation is done for every event attribute. I am worried that this is a bit too much. @adnanhemani and @olsoloviov, wdyt? Am I overthinking this?

*/
public final class AttributeKey<T> {
private final String name;
private final Class<T> type;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suggest that we use Guava's com.google.common.reflect.TypeToken here, as it handles generic types as well.

*
* @param <T> the type of the attribute value
*/
public final class AttributeKey<T> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This class could be a record. Then the constructor could be public and we wouldn't need the static of method.

The following should be enough (also introducing support for TypeToken):

public record AttributeKey<T>(@JsonValue String name, TypeToken<T> type) {

  public AttributeKey(String name, Class<T> type) {
    this(name, TypeToken.of(type));
  }

  public AttributeKey(String name, TypeToken<T> type) {
    this.name = Objects.requireNonNull(name, "name");
    this.type = Objects.requireNonNull(type, "type");
    if (!AllowedAttributeTypes.isAllowed(type)) {
      throw new IllegalArgumentException("Type " + type + " is not allowed for event attributes");
    }
  }
}

AttributeKey.of("register_table_request", RegisterTableRequest.class);
public static final AttributeKey<RenameTableRequest> RENAME_TABLE_REQUEST =
AttributeKey.of("rename_table_request", RenameTableRequest.class);
public static final AttributeKey<TableMetadata> TABLE_METADATA_BEFORE =
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My IDE is flagging some attributes as unused, which is intriguing. This is one of them.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for catching, these are not needed after ...CommitTable events were removed

* Represents an event emitted by Polaris. Events have a type, metadata, and a map of typed
* attributes. Use {@link #builder(PolarisEventType, PolarisEventMetadata)} to create instances.
*/
public record PolarisEvent(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FYI we can leverage the Java Immutables library to create the builder class for us. Here is how to do it:

@Value.Style(depluralize = true)
public record PolarisEvent(
    PolarisEventType type, PolarisEventMetadata metadata, Map<AttributeKey<?>, Object> attributes) {

  @Builder.Constructor
  public PolarisEvent { ...  }

}

Then we can create an event like this:

PolarisEvent event =
  new PolarisEventBuilder()
      .type(PolarisEventType.BEFORE_LIMIT_REQUEST_RATE)
      .metadata(eventMetadataFactory.create())
      .putAttribute(EventAttributes.HTTP_METHOD, ctx.getMethod())
      .putAttribute(
          EventAttributes.REQUEST_URI, ctx.getUriInfo().getAbsolutePath().toString())
      .build();

Another option is to transform the record into an interface and annotate with @PolarisImmutable.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the notion. I see only one issue with such approach - putAttribute() will not be type-safe, e.g. something like .putAttributes(EventAttributes.REQUEST_URI, 123) will compile

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's right indeed. But now that we have AttributeMap, shouldn't we move those put methods to AttributeMap?

Copy link
Contributor

@adutra adutra Jan 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I implemented this idea, let me know what you all think:

adutra@2286bfd68

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think @adutra's idea is likely better. Let's go with it.

PolarisEventType type, PolarisEventMetadata metadata, Map<AttributeKey<?>, Object> attributes) {

public PolarisEvent {
attributes = Collections.unmodifiableMap(new HashMap<>(attributes));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
attributes = Collections.unmodifiableMap(new HashMap<>(attributes));
attributes = Map.copyOf(attributes);

* attributes. Use {@link #builder(PolarisEventType, PolarisEventMetadata)} to create instances.
*/
public record PolarisEvent(
PolarisEventType type, PolarisEventMetadata metadata, Map<AttributeKey<?>, Object> attributes) {
Copy link
Contributor

@adutra adutra Jan 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: exposing a Map<AttributeKey<?>, Object> parameter is a bit low-level. It would be better to introduce an AttributeMap class with type-safe operations for get and put. That's what Netty does, cf. io.netty.util.AttributeMap.

Very simple impl suggestion:

public final class AttributeMap {

  private final Map<AttributeKey<?>, Object> attributes = new HashMap<>();

  @SuppressWarnings("unchecked")
  public <T> Optional<T> get(AttributeKey<T> key) {
    return Optional.ofNullable((T) attributes.get(key));
  }

  public <T> T getRequired(AttributeKey<T> key) {
    return get(key)
        .orElseThrow(() -> new IllegalStateException("Required attribute " + key + " not found"));
  }
}

Copy link
Contributor

@adnanhemani adnanhemani Jan 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm 50/50 on this being a requirement, but willing to go with @adutra's suggestion here :)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, that makes sense. It is not compatible with using Immutables on PolarisEvent though - putAttribute() will not be generated, so we will have to stick with manual builder.

@olsoloviov
Copy link
Contributor Author

Thanks for your comprehensive comments, @adutra! I have implemented most of your suggestions in the new commit. Immutables-generated builder does not work nice with AttributeMap though, so I stuck with manual builder.

Namespace namespace = event.requiredAttribute(EventAttributes.NAMESPACE);
String tableName = event.requiredAttribute(EventAttributes.TABLE_NAME);

org.apache.polaris.core.entity.PolarisEvent polarisEvent =
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: not sure why we had to write the full class hierarchy here. Was there a change?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, yeah. This is another PolarisEvent from polaris-core. It was imported before, but now we had to import our org.apache.polaris.service.events.PolarisEvent, so now we have to fully qualify that one.

}

private static boolean isSubtypeOfAllowedType(Class<?> rawType) {
return ALLOWED_TYPES.stream().anyMatch(t -> t.isAssignableFrom(rawType));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if this is too nit-picky - but maybe something like this to ensure we don't waste a lot of cycles going through this check for the same handful of classes?

private static final ClassValue<Boolean> IS_ALLOWED =
    new ClassValue<>() {
      @Override
      protected Boolean computeValue(Class<?> type) {
        for (Class<?> allowed : ALLOWED_TYPES) {
          if (allowed.isAssignableFrom(type)) {
            return true;
          }
        }
        return false;
      }
    };

private static boolean isSubtypeOfAllowedType(Class<?> rawType) {
  return IS_ALLOWED.get(rawType);
}

Assertions.assertThat(beforeRefreshEvent.tableIdentifier()).isEqualTo(TestData.TABLE);
PolarisEvent beforeRefreshEvent =
testPolarisEventListener.getLatest(PolarisEventType.BEFORE_REFRESH_TABLE);
Assertions.assertThat(beforeRefreshEvent.attribute(EventAttributes.TABLE_IDENTIFIER))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Commenting in case you forgot to make this change :)

* Represents an event emitted by Polaris. Events have a type, metadata, and a map of typed
* attributes. Use {@link #builder(PolarisEventType, PolarisEventMetadata)} to create instances.
*/
public record PolarisEvent(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think @adutra's idea is likely better. Let's go with it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants