You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@polaris.apache.org by di...@apache.org on 2026/10/19 18:54:48 UTC

(polaris) branch main updated: Persistence: DynamoDB (atomic) single-table data model (#5707)

This is an automated email from the ASF dual-hosted git repository.

dimas-b pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/polaris.git


The following commit(s) were added to refs/heads/main by this push:
     new 87adc3c0b2 Persistence: DynamoDB (atomic) single-table data model (#5707)
87adc3c0b2 is described below

commit 87adc3c0b2d174d8826f0d3b5d2f3b4edac31ac7
Author: Yuewei Zhou <91...@users.noreply.github.com>
AuthorDate: Mon Oct 19 11:54:42 2026 -0700

    Persistence: DynamoDB (atomic) single-table data model (#5707)
    
    Adds the DynamoDB item layout for the dynamodb-atomic backend: key encodings and attribute names (DynamoDbConstants) plus the entity, grant, and name-sentinel item mappers, with unit tests. Pure serialization layer; persistence logic and runtime wiring follow in later PRs.
---
 persistence/dynamodb-atomic/build.gradle.kts       |   7 +-
 .../atomic/dynamodb/DynamoDbLayout.java            |  97 ++++++++++++++
 .../atomic/dynamodb/models/EntityItem.java         | 146 +++++++++++++++++++++
 .../atomic/dynamodb/models/NameSentinelItem.java   |  60 +++++++++
 .../atomic/dynamodb/DynamoDbLayoutTest.java        |  51 +++++++
 .../atomic/dynamodb/models/EntityItemTest.java     | 109 +++++++++++++++
 .../dynamodb/models/NameSentinelItemTest.java      |  52 ++++++++
 7 files changed, 521 insertions(+), 1 deletion(-)

diff --git a/persistence/dynamodb-atomic/build.gradle.kts b/persistence/dynamodb-atomic/build.gradle.kts
index fcd5e6c637..fb72a2b62d 100644
--- a/persistence/dynamodb-atomic/build.gradle.kts
+++ b/persistence/dynamodb-atomic/build.gradle.kts
@@ -22,4 +22,9 @@ plugins {
   id("org.kordamp.gradle.jandex")
 }
 
-dependencies { implementation(project(":polaris-core")) }
+dependencies {
+  implementation(project(":polaris-core"))
+
+  implementation(platform(libs.awssdk.bom))
+  implementation("software.amazon.awssdk:dynamodb")
+}
diff --git a/persistence/dynamodb-atomic/src/main/java/org/apache/polaris/persistence/atomic/dynamodb/DynamoDbLayout.java b/persistence/dynamodb-atomic/src/main/java/org/apache/polaris/persistence/atomic/dynamodb/DynamoDbLayout.java
new file mode 100644
index 0000000000..e6cabc8276
--- /dev/null
+++ b/persistence/dynamodb-atomic/src/main/java/org/apache/polaris/persistence/atomic/dynamodb/DynamoDbLayout.java
@@ -0,0 +1,97 @@
+/*
+ * 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.polaris.persistence.atomic.dynamodb;
+
+/**
+ * Attribute names, item-type markers, and key encodings for the single-table DynamoDB layout.
+ *
+ * <p>One base table holds the Polaris item types, each partitioned by its own identity so load
+ * spreads across partitions; a sparse GSI serves the by-name lookup. The {@code realmId} is only a
+ * key prefix (tenant isolation), never a partition key on its own.
+ */
+public final class DynamoDbLayout {
+
+  private DynamoDbLayout() {}
+
+  // ---- Key attributes --------------------------------------------------------------------------
+  public static final String PK = "PK";
+  public static final String SK = "SK";
+
+  // by-name GSI (list children): gsi1pk = realmId#catalogId#parentId#typeCode, gsi1sk = name
+  public static final String GSI1 = "gsi1";
+  public static final String GSI1_PK = "gsi1pk";
+  public static final String GSI1_SK = "gsi1sk";
+
+  // ---- Sort-key / item-type markers ------------------------------------------------------------
+  public static final String SK_ENTITY = "ENTITY";
+  public static final String SK_NAME = "NAME";
+
+  // ---- Entity item attributes ------------------------------------------------------------------
+  public static final String A_CATALOG_ID = "catalogId";
+  public static final String A_PARENT_ID = "parentId";
+  public static final String A_TYPE_CODE = "typeCode";
+  public static final String A_SUB_TYPE_CODE = "subTypeCode";
+  public static final String A_NAME = "name";
+  public static final String A_ENTITY_VERSION = "entityVersion";
+  public static final String A_GRANT_RECORDS_VERSION = "grantRecordsVersion";
+  public static final String A_CREATE_TIMESTAMP = "createTimestamp";
+  public static final String A_DROP_TIMESTAMP = "dropTimestamp";
+  public static final String A_PURGE_TIMESTAMP = "purgeTimestamp";
+  public static final String A_TO_PURGE_TIMESTAMP = "toPurgeTimestamp";
+  public static final String A_LAST_UPDATE_TIMESTAMP = "lastUpdateTimestamp";
+  public static final String A_PROPERTIES = "properties";
+  public static final String A_INTERNAL_PROPERTIES = "internalProperties";
+
+  // ---- Name-sentinel attributes ----------------------------------------------------------------
+  public static final String A_ID = "id";
+
+  private static final String SEP = "#";
+
+  // ---- Key encodings ---------------------------------------------------------------------------
+
+  /** Entity partition key: {@code realmId#id} (one partition per entity). */
+  public static String entityPk(String realmId, long id) {
+    return realmId + SEP + id;
+  }
+
+  /**
+   * Name-sentinel partition key. {@code realmId} is length-prefixed so the key stays unambiguous
+   * when {@code realmId} or {@code name} contains the separator; both are free-form, whereas the
+   * numeric coordinates cannot contain it.
+   */
+  public static String sentinelPk(
+      String realmId, long catalogId, long parentId, int typeCode, String name) {
+    return realmId.length()
+        + SEP
+        + realmId
+        + SEP
+        + catalogId
+        + SEP
+        + parentId
+        + SEP
+        + typeCode
+        + SEP
+        + name;
+  }
+
+  /** by-name GSI partition key: {@code realmId#catalogId#parentId#typeCode}. */
+  public static String byNameGsiPk(String realmId, long catalogId, long parentId, int typeCode) {
+    return realmId + SEP + catalogId + SEP + parentId + SEP + typeCode;
+  }
+}
diff --git a/persistence/dynamodb-atomic/src/main/java/org/apache/polaris/persistence/atomic/dynamodb/models/EntityItem.java b/persistence/dynamodb-atomic/src/main/java/org/apache/polaris/persistence/atomic/dynamodb/models/EntityItem.java
new file mode 100644
index 0000000000..2c8da2e048
--- /dev/null
+++ b/persistence/dynamodb-atomic/src/main/java/org/apache/polaris/persistence/atomic/dynamodb/models/EntityItem.java
@@ -0,0 +1,146 @@
+/*
+ * 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.polaris.persistence.atomic.dynamodb.models;
+
+import static org.apache.polaris.persistence.atomic.dynamodb.DynamoDbLayout.A_CATALOG_ID;
+import static org.apache.polaris.persistence.atomic.dynamodb.DynamoDbLayout.A_CREATE_TIMESTAMP;
+import static org.apache.polaris.persistence.atomic.dynamodb.DynamoDbLayout.A_DROP_TIMESTAMP;
+import static org.apache.polaris.persistence.atomic.dynamodb.DynamoDbLayout.A_ENTITY_VERSION;
+import static org.apache.polaris.persistence.atomic.dynamodb.DynamoDbLayout.A_GRANT_RECORDS_VERSION;
+import static org.apache.polaris.persistence.atomic.dynamodb.DynamoDbLayout.A_ID;
+import static org.apache.polaris.persistence.atomic.dynamodb.DynamoDbLayout.A_INTERNAL_PROPERTIES;
+import static org.apache.polaris.persistence.atomic.dynamodb.DynamoDbLayout.A_LAST_UPDATE_TIMESTAMP;
+import static org.apache.polaris.persistence.atomic.dynamodb.DynamoDbLayout.A_NAME;
+import static org.apache.polaris.persistence.atomic.dynamodb.DynamoDbLayout.A_PARENT_ID;
+import static org.apache.polaris.persistence.atomic.dynamodb.DynamoDbLayout.A_PROPERTIES;
+import static org.apache.polaris.persistence.atomic.dynamodb.DynamoDbLayout.A_PURGE_TIMESTAMP;
+import static org.apache.polaris.persistence.atomic.dynamodb.DynamoDbLayout.A_SUB_TYPE_CODE;
+import static org.apache.polaris.persistence.atomic.dynamodb.DynamoDbLayout.A_TO_PURGE_TIMESTAMP;
+import static org.apache.polaris.persistence.atomic.dynamodb.DynamoDbLayout.A_TYPE_CODE;
+import static org.apache.polaris.persistence.atomic.dynamodb.DynamoDbLayout.GSI1_PK;
+import static org.apache.polaris.persistence.atomic.dynamodb.DynamoDbLayout.GSI1_SK;
+import static org.apache.polaris.persistence.atomic.dynamodb.DynamoDbLayout.PK;
+import static org.apache.polaris.persistence.atomic.dynamodb.DynamoDbLayout.SK;
+import static org.apache.polaris.persistence.atomic.dynamodb.DynamoDbLayout.SK_ENTITY;
+import static org.apache.polaris.persistence.atomic.dynamodb.DynamoDbLayout.byNameGsiPk;
+import static org.apache.polaris.persistence.atomic.dynamodb.DynamoDbLayout.entityPk;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.stream.Collectors;
+import org.apache.polaris.core.entity.PolarisBaseEntity;
+import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
+
+/**
+ * Maps a {@link PolarisBaseEntity} to and from its DynamoDB "entity item" representation.
+ *
+ * <p>The item is keyed by {@code PK = realmId#id}, {@code SK = "ENTITY"}, and carries the by-name
+ * GSI keys so a parent's children can be listed.
+ */
+public final class EntityItem {
+
+  private EntityItem() {}
+
+  /** Serialize an entity to a DynamoDB item map for the given realm. */
+  public static Map<String, AttributeValue> toItem(String realmId, PolarisBaseEntity e) {
+    Map<String, AttributeValue> item = new HashMap<>();
+    item.put(PK, stringAttr(entityPk(realmId, e.getId())));
+    item.put(SK, stringAttr(SK_ENTITY));
+
+    item.put(A_ID, numberAttr(e.getId()));
+    item.put(A_CATALOG_ID, numberAttr(e.getCatalogId()));
+    item.put(A_PARENT_ID, numberAttr(e.getParentId()));
+    item.put(A_TYPE_CODE, numberAttr(e.getTypeCode()));
+    item.put(A_SUB_TYPE_CODE, numberAttr(e.getSubTypeCode()));
+    item.put(A_NAME, stringAttr(e.getName()));
+    item.put(A_ENTITY_VERSION, numberAttr(e.getEntityVersion()));
+    item.put(A_GRANT_RECORDS_VERSION, numberAttr(e.getGrantRecordsVersion()));
+    item.put(A_CREATE_TIMESTAMP, numberAttr(e.getCreateTimestamp()));
+    item.put(A_DROP_TIMESTAMP, numberAttr(e.getDropTimestamp()));
+    item.put(A_PURGE_TIMESTAMP, numberAttr(e.getPurgeTimestamp()));
+    item.put(A_TO_PURGE_TIMESTAMP, numberAttr(e.getToPurgeTimestamp()));
+    item.put(A_LAST_UPDATE_TIMESTAMP, numberAttr(e.getLastUpdateTimestamp()));
+    item.put(A_PROPERTIES, mapAttr(e.getPropertiesAsMap()));
+    item.put(A_INTERNAL_PROPERTIES, mapAttr(e.getInternalPropertiesAsMap()));
+
+    // by-name GSI keys (sparse: only entity items carry them) -> serve "list children".
+    item.put(
+        GSI1_PK,
+        stringAttr(byNameGsiPk(realmId, e.getCatalogId(), e.getParentId(), e.getTypeCode())));
+    item.put(GSI1_SK, stringAttr(e.getName()));
+    return Map.copyOf(item);
+  }
+
+  /** Deserialize a DynamoDB entity item back into a {@link PolarisBaseEntity}. */
+  public static PolarisBaseEntity toEntity(Map<String, AttributeValue> item) {
+    return new PolarisBaseEntity.Builder()
+        .id(num(item, A_ID))
+        .catalogId(num(item, A_CATALOG_ID))
+        .parentId(num(item, A_PARENT_ID))
+        .typeCode((int) num(item, A_TYPE_CODE))
+        .subTypeCode((int) num(item, A_SUB_TYPE_CODE))
+        .name(str(item, A_NAME))
+        .entityVersion((int) num(item, A_ENTITY_VERSION))
+        .grantRecordsVersion((int) num(item, A_GRANT_RECORDS_VERSION))
+        .createTimestamp(num(item, A_CREATE_TIMESTAMP))
+        .dropTimestamp(num(item, A_DROP_TIMESTAMP))
+        .purgeTimestamp(num(item, A_PURGE_TIMESTAMP))
+        .toPurgeTimestamp(num(item, A_TO_PURGE_TIMESTAMP))
+        .lastUpdateTimestamp(num(item, A_LAST_UPDATE_TIMESTAMP))
+        .propertiesAsMap(strMap(item, A_PROPERTIES))
+        .internalPropertiesAsMap(strMap(item, A_INTERNAL_PROPERTIES))
+        .build();
+  }
+
+  // ---- small AttributeValue helpers (shared shape across item mappers) -------------------------
+
+  static AttributeValue stringAttr(String v) {
+    return AttributeValue.fromS(v);
+  }
+
+  static AttributeValue numberAttr(long v) {
+    return AttributeValue.fromN(Long.toString(v));
+  }
+
+  static AttributeValue mapAttr(Map<String, String> m) {
+    Map<String, AttributeValue> out =
+        m.entrySet().stream()
+            .collect(Collectors.toMap(Map.Entry::getKey, e -> stringAttr(e.getValue())));
+    return AttributeValue.fromM(out);
+  }
+
+  static String str(Map<String, AttributeValue> item, String key) {
+    AttributeValue v = item.get(key);
+    return v == null ? null : v.s();
+  }
+
+  static long num(Map<String, AttributeValue> item, String key) {
+    AttributeValue v = item.get(key);
+    return v == null || v.n() == null ? 0L : Long.parseLong(v.n());
+  }
+
+  static Map<String, String> strMap(Map<String, AttributeValue> item, String key) {
+    AttributeValue v = item.get(key);
+    if (v == null || v.m() == null) {
+      return Map.of();
+    }
+    return v.m().entrySet().stream()
+        .collect(Collectors.toMap(Map.Entry::getKey, e -> e.getValue().s()));
+  }
+}
diff --git a/persistence/dynamodb-atomic/src/main/java/org/apache/polaris/persistence/atomic/dynamodb/models/NameSentinelItem.java b/persistence/dynamodb-atomic/src/main/java/org/apache/polaris/persistence/atomic/dynamodb/models/NameSentinelItem.java
new file mode 100644
index 0000000000..f745e0d168
--- /dev/null
+++ b/persistence/dynamodb-atomic/src/main/java/org/apache/polaris/persistence/atomic/dynamodb/models/NameSentinelItem.java
@@ -0,0 +1,60 @@
+/*
+ * 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.polaris.persistence.atomic.dynamodb.models;
+
+import static org.apache.polaris.persistence.atomic.dynamodb.DynamoDbLayout.A_ID;
+import static org.apache.polaris.persistence.atomic.dynamodb.DynamoDbLayout.PK;
+import static org.apache.polaris.persistence.atomic.dynamodb.DynamoDbLayout.SK;
+import static org.apache.polaris.persistence.atomic.dynamodb.DynamoDbLayout.SK_NAME;
+import static org.apache.polaris.persistence.atomic.dynamodb.DynamoDbLayout.sentinelPk;
+import static org.apache.polaris.persistence.atomic.dynamodb.models.EntityItem.num;
+import static org.apache.polaris.persistence.atomic.dynamodb.models.EntityItem.numberAttr;
+import static org.apache.polaris.persistence.atomic.dynamodb.models.EntityItem.stringAttr;
+
+import java.util.HashMap;
+import java.util.Map;
+import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
+
+/**
+ * The name-sentinel item: a stand-in for a multi-column UNIQUE constraint. A small guard item keyed
+ * by the name's realm, catalog, parent, type, and the name itself, whose sole payload is the owning
+ * entity's {@code id}.
+ *
+ * <p>Its presence "claims" the name: a duplicate create fails its {@code attribute_not_exists}
+ * condition inside the same {@code TransactWriteItems} that writes the entity, so uniqueness holds
+ * atomically.
+ */
+public final class NameSentinelItem {
+
+  private NameSentinelItem() {}
+
+  public static Map<String, AttributeValue> toItem(
+      String realmId, long catalogId, long parentId, int typeCode, String name, long entityId) {
+    Map<String, AttributeValue> item = new HashMap<>();
+    item.put(PK, stringAttr(sentinelPk(realmId, catalogId, parentId, typeCode, name)));
+    item.put(SK, stringAttr(SK_NAME));
+    item.put(A_ID, numberAttr(entityId));
+    return Map.copyOf(item);
+  }
+
+  /** The entity id a sentinel points at, or 0 if absent. */
+  public static long entityId(Map<String, AttributeValue> item) {
+    return num(item, A_ID);
+  }
+}
diff --git a/persistence/dynamodb-atomic/src/test/java/org/apache/polaris/persistence/atomic/dynamodb/DynamoDbLayoutTest.java b/persistence/dynamodb-atomic/src/test/java/org/apache/polaris/persistence/atomic/dynamodb/DynamoDbLayoutTest.java
new file mode 100644
index 0000000000..304bebe0d9
--- /dev/null
+++ b/persistence/dynamodb-atomic/src/test/java/org/apache/polaris/persistence/atomic/dynamodb/DynamoDbLayoutTest.java
@@ -0,0 +1,51 @@
+/*
+ * 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.polaris.persistence.atomic.dynamodb;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import org.junit.jupiter.api.Test;
+
+class DynamoDbLayoutTest {
+
+  @Test
+  void entityPkCombinesRealmAndId() {
+    assertThat(DynamoDbLayout.entityPk("realm1", 42L)).isEqualTo("realm1#42");
+  }
+
+  @Test
+  void sentinelPkCombinesParentCoordinatesAndName() {
+    assertThat(DynamoDbLayout.sentinelPk("realm1", 7L, 3L, 2, "my_table"))
+        .isEqualTo("6#realm1#7#3#2#my_table");
+  }
+
+  @Test
+  void sentinelPkStaysUniqueWhenRealmIdContainsSeparator() {
+    // Without the realmId length prefix these two distinct entities both encode to
+    // realm1#7#3#2#0#my_table and collide.
+    String separatorInRealmId = DynamoDbLayout.sentinelPk("realm1#7", 3L, 2L, 0, "my_table");
+    String separatorInName = DynamoDbLayout.sentinelPk("realm1", 7L, 3L, 2, "0#my_table");
+    assertThat(separatorInRealmId).isNotEqualTo(separatorInName);
+  }
+
+  @Test
+  void byNameGsiPkCombinesParentCoordinates() {
+    assertThat(DynamoDbLayout.byNameGsiPk("realm1", 7L, 3L, 2)).isEqualTo("realm1#7#3#2");
+  }
+}
diff --git a/persistence/dynamodb-atomic/src/test/java/org/apache/polaris/persistence/atomic/dynamodb/models/EntityItemTest.java b/persistence/dynamodb-atomic/src/test/java/org/apache/polaris/persistence/atomic/dynamodb/models/EntityItemTest.java
new file mode 100644
index 0000000000..0f4bf3f4da
--- /dev/null
+++ b/persistence/dynamodb-atomic/src/test/java/org/apache/polaris/persistence/atomic/dynamodb/models/EntityItemTest.java
@@ -0,0 +1,109 @@
+/*
+ * 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.polaris.persistence.atomic.dynamodb.models;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.util.Map;
+import org.apache.polaris.core.entity.PolarisBaseEntity;
+import org.apache.polaris.persistence.atomic.dynamodb.DynamoDbLayout;
+import org.junit.jupiter.api.Test;
+import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
+
+class EntityItemTest {
+
+  private static PolarisBaseEntity sampleEntity() {
+    return new PolarisBaseEntity.Builder()
+        .id(42L)
+        .catalogId(7L)
+        .parentId(3L)
+        .typeCode(2)
+        .subTypeCode(5)
+        .name("my_table")
+        .entityVersion(4)
+        .grantRecordsVersion(6)
+        .createTimestamp(1000L)
+        .dropTimestamp(0L)
+        .purgeTimestamp(0L)
+        .toPurgeTimestamp(0L)
+        .lastUpdateTimestamp(2000L)
+        .propertiesAsMap(Map.of("k1", "v1", "k2", "v2"))
+        .internalPropertiesAsMap(Map.of("ik", "iv"))
+        .build();
+  }
+
+  @Test
+  void toItemWritesKeysAndGsiAttributes() {
+    Map<String, AttributeValue> item = EntityItem.toItem("realm1", sampleEntity());
+
+    assertThat(item.get(DynamoDbLayout.PK).s()).isEqualTo("realm1#42");
+    assertThat(item.get(DynamoDbLayout.SK).s()).isEqualTo(DynamoDbLayout.SK_ENTITY);
+    assertThat(item.get(DynamoDbLayout.GSI1_PK).s()).isEqualTo("realm1#7#3#2");
+    assertThat(item.get(DynamoDbLayout.GSI1_SK).s()).isEqualTo("my_table");
+  }
+
+  @Test
+  void roundTripPreservesAllFields() {
+    PolarisBaseEntity original = sampleEntity();
+
+    PolarisBaseEntity back = EntityItem.toEntity(EntityItem.toItem("realm1", original));
+
+    assertThat(back.getId()).isEqualTo(42L);
+    assertThat(back.getCatalogId()).isEqualTo(7L);
+    assertThat(back.getParentId()).isEqualTo(3L);
+    assertThat(back.getTypeCode()).isEqualTo(2);
+    assertThat(back.getSubTypeCode()).isEqualTo(5);
+    assertThat(back.getName()).isEqualTo("my_table");
+    assertThat(back.getEntityVersion()).isEqualTo(4);
+    assertThat(back.getGrantRecordsVersion()).isEqualTo(6);
+    assertThat(back.getCreateTimestamp()).isEqualTo(1000L);
+    assertThat(back.getLastUpdateTimestamp()).isEqualTo(2000L);
+    assertThat(back.getPropertiesAsMap())
+        .containsExactlyInAnyOrderEntriesOf(Map.of("k1", "v1", "k2", "v2"));
+    assertThat(back.getInternalPropertiesAsMap())
+        .containsExactlyInAnyOrderEntriesOf(Map.of("ik", "iv"));
+  }
+
+  @Test
+  void emptyPropertyMapsRoundTripToEmpty() {
+    PolarisBaseEntity entity =
+        new PolarisBaseEntity.Builder()
+            .id(1L)
+            .catalogId(1L)
+            .parentId(0L)
+            .typeCode(1)
+            .subTypeCode(0)
+            .name("n")
+            .entityVersion(1)
+            .grantRecordsVersion(1)
+            .createTimestamp(1L)
+            .dropTimestamp(0L)
+            .purgeTimestamp(0L)
+            .toPurgeTimestamp(0L)
+            .lastUpdateTimestamp(1L)
+            .propertiesAsMap(Map.of())
+            .internalPropertiesAsMap(Map.of())
+            .build();
+
+    PolarisBaseEntity back = EntityItem.toEntity(EntityItem.toItem("realm1", entity));
+
+    assertThat(back.getPropertiesAsMap()).isEmpty();
+    assertThat(back.getInternalPropertiesAsMap()).isEmpty();
+  }
+}
diff --git a/persistence/dynamodb-atomic/src/test/java/org/apache/polaris/persistence/atomic/dynamodb/models/NameSentinelItemTest.java b/persistence/dynamodb-atomic/src/test/java/org/apache/polaris/persistence/atomic/dynamodb/models/NameSentinelItemTest.java
new file mode 100644
index 0000000000..a8ef401ba1
--- /dev/null
+++ b/persistence/dynamodb-atomic/src/test/java/org/apache/polaris/persistence/atomic/dynamodb/models/NameSentinelItemTest.java
@@ -0,0 +1,52 @@
+/*
+ * 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.polaris.persistence.atomic.dynamodb.models;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.util.Map;
+import org.apache.polaris.persistence.atomic.dynamodb.DynamoDbLayout;
+import org.junit.jupiter.api.Test;
+import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
+
+class NameSentinelItemTest {
+
+  @Test
+  void toItemKeysByNameCoordinatesAndCarriesEntityId() {
+    Map<String, AttributeValue> item =
+        NameSentinelItem.toItem("realm1", 7L, 3L, 2, "my_table", 42L);
+
+    assertThat(item.get(DynamoDbLayout.PK).s()).isEqualTo("6#realm1#7#3#2#my_table");
+    assertThat(item.get(DynamoDbLayout.SK).s()).isEqualTo(DynamoDbLayout.SK_NAME);
+    assertThat(item.get(DynamoDbLayout.A_ID).n()).isEqualTo("42");
+  }
+
+  @Test
+  void entityIdReadsBackTheOwningId() {
+    Map<String, AttributeValue> item =
+        NameSentinelItem.toItem("realm1", 7L, 3L, 2, "my_table", 42L);
+
+    assertThat(NameSentinelItem.entityId(item)).isEqualTo(42L);
+  }
+
+  @Test
+  void entityIdDefaultsToZeroWhenAbsent() {
+    assertThat(NameSentinelItem.entityId(Map.of())).isZero();
+  }
+}