Skip to content

Commit aa6ba4e

Browse files
rakhiagrclaude
andauthored
Fix Gap 4: asset-level deletion invisible to backfill staleness checks (#612)
* Fix Gap 4: asset-level deletion invisible to backfill staleness checks When an entity is deleted via softDeleteAsset(), deleted_ts is set on the entity row but batchGetUnion() unconditionally filters deleted_ts IS NULL, making deleted rows invisible. getLatest() returns null (as if entity never existed), allowing stale backfill events to resurrect deleted entities. Fix: - SQLStatementUtils.createAspectReadSql(): only append deleted_ts IS NULL when includeSoftDeleted=false (was unconditional before) - SQL_READ_ASPECT_WITH_SOFT_DELETED_TEMPLATE: add deleted_ts to SELECT - EBeanDAOUtils.readSqlRow(): when deleted_ts is non-null, mark aspect as soft-deleted using deleted_ts as the deletion timestamp See docs/backfill-gap4-asset-level-deletion.md for detailed explanation. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Fix Gap 4: ensure asset-level deletion clears deleted_ts on legitimate writes * clarify soft delete behavior in documentation and tests --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 67132bc commit aa6ba4e

5 files changed

Lines changed: 243 additions & 13 deletions

File tree

dao-impl/ebean-dao/src/main/java/com/linkedin/metadata/dao/utils/EBeanDAOUtils.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,6 +382,11 @@ private static <ASPECT extends RecordTemplate> EbeanMetadataAspect readSqlRow(Sq
382382
} catch (URISyntaxException e) {
383383
throw new RuntimeException("Invalid urn format: " + urn, e);
384384
}
385+
// Check for asset-level deletion: deleted_ts is non-null means the entire entity was deleted
386+
// via softDeleteAsset(). deleted_ts is only in the SELECT when includeSoftDeleted=true.
387+
final Timestamp assetDeletedTs = sqlRow.keySet().contains("deleted_ts") ? sqlRow.getTimestamp("deleted_ts") : null;
388+
final boolean isAssetLevelDeleted = assetDeletedTs != null;
389+
385390
if (isSoftDeletedAspect(sqlRow, columnName)) {
386391
primaryKey = new EbeanMetadataAspect.PrimaryKey(urn, aspectClass.getCanonicalName(), LATEST_VERSION);
387392

@@ -399,6 +404,14 @@ private static <ASPECT extends RecordTemplate> EbeanMetadataAspect readSqlRow(Sq
399404
ebeanMetadataAspect.setCreatedOn(deletionTimestamp);
400405
ebeanMetadataAspect.setCreatedFor(sqlRow.getString("createdfor"));
401406
ebeanMetadataAspect.setMetadata(sqlRow.getString(columnName));
407+
} else if (isAssetLevelDeleted) {
408+
// Asset-level deletion: aspect column still has its value but entity row has deleted_ts set.
409+
// Mark as soft-deleted using deleted_ts as the deletion timestamp.
410+
primaryKey = new EbeanMetadataAspect.PrimaryKey(urn, aspectClass.getCanonicalName(), LATEST_VERSION);
411+
ebeanMetadataAspect.setCreatedBy(sqlRow.getString("lastmodifiedby"));
412+
ebeanMetadataAspect.setCreatedOn(assetDeletedTs);
413+
ebeanMetadataAspect.setCreatedFor(sqlRow.getString("createdfor"));
414+
ebeanMetadataAspect.setMetadata(DELETED_VALUE);
402415
} else {
403416
AuditedAspect auditedAspect = RecordUtils.toRecordTemplate(AuditedAspect.class, sqlRow.getString(columnName));
404417
primaryKey = new EbeanMetadataAspect.PrimaryKey(urn, auditedAspect.getCanonicalName(), LATEST_VERSION);

dao-impl/ebean-dao/src/main/java/com/linkedin/metadata/dao/utils/SQLStatementUtils.java

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -104,20 +104,22 @@ public class SQLStatementUtils {
104104
// "JSON_EXTRACT(%s, '$.gma_deleted') IS NOT NULL" is used to exclude soft-deleted entity which has no lastmodifiedon.
105105
// for details, see the known limitations on https://github.com/linkedin/datahub-gma/pull/311. Same reason for
106106
// SQL_UPDATE_ASPECT_WITH_URN_TEMPLATE
107+
// All UPDATE templates include deleted_ts = NULL to ensure any successful write revives an asset-deleted entity.
108+
// Without this, the optimistic locking UPDATE path leaves deleted_ts set, making the entity invisible to reads.
107109
private static final String SQL_UPDATE_ASPECT_TEMPLATE =
108-
"UPDATE %s SET %s = :metadata, lastmodifiedon = :lastmodifiedon, lastmodifiedby = :lastmodifiedby "
110+
"UPDATE %s SET %s = :metadata, lastmodifiedon = :lastmodifiedon, lastmodifiedby = :lastmodifiedby, deleted_ts = NULL "
109111
+ "WHERE urn = :urn and (JSON_EXTRACT(%s, '$.lastmodifiedon') = :oldTimestamp OR JSON_EXTRACT(%s, '$.gma_deleted') IS NOT NULL);";
110112

111113
private static final String SQL_UPDATE_ASPECT_WITH_URN_TEMPLATE =
112-
"UPDATE %s SET %s = :metadata, a_urn = :a_urn, lastmodifiedon = :lastmodifiedon, lastmodifiedby = :lastmodifiedby "
114+
"UPDATE %s SET %s = :metadata, a_urn = :a_urn, lastmodifiedon = :lastmodifiedon, lastmodifiedby = :lastmodifiedby, deleted_ts = NULL "
113115
+ "WHERE urn = :urn and (JSON_EXTRACT(%s, '$.lastmodifiedon') = :oldTimestamp OR JSON_EXTRACT(%s, '$.gma_deleted') IS NOT NULL);";
114116

115117
private static final String SQL_UPDATE_ASPECT_TEMPLATE_WITH_SOFT_DELETE_OVERWRITE =
116-
"UPDATE %s SET %s = :metadata, lastmodifiedon = :lastmodifiedon, lastmodifiedby = :lastmodifiedby "
118+
"UPDATE %s SET %s = :metadata, lastmodifiedon = :lastmodifiedon, lastmodifiedby = :lastmodifiedby, deleted_ts = NULL "
117119
+ "WHERE urn = :urn ;";
118120

119121
private static final String SQL_UPDATE_ASPECT_WITH_URN_TEMPLATE_WITH_SOFT_DELETE_OVERWRITE = "UPDATE %s SET %s = "
120-
+ ":metadata, a_urn = :a_urn, lastmodifiedon = :lastmodifiedon, lastmodifiedby = :lastmodifiedby WHERE urn = :urn;";
122+
+ ":metadata, a_urn = :a_urn, lastmodifiedon = :lastmodifiedon, lastmodifiedby = :lastmodifiedby, deleted_ts = NULL WHERE urn = :urn;";
121123

122124
private static final String SQL_READ_ASPECT_TEMPLATE =
123125
String.format("SELECT urn, %%s, lastmodifiedon, lastmodifiedby FROM %%s WHERE %s AND urn IN (", SOFT_DELETED_CHECK);
@@ -137,7 +139,7 @@ public class SQLStatementUtils {
137139
+ "as _total_count FROM %%s WHERE %s LIMIT %%s OFFSET %%s", NONNULL_CHECK, NONNULL_CHECK);
138140

139141
private static final String SQL_READ_ASPECT_WITH_SOFT_DELETED_TEMPLATE =
140-
"SELECT urn, %s, lastmodifiedon, lastmodifiedby FROM %s WHERE urn IN (";
142+
"SELECT urn, %s, lastmodifiedon, lastmodifiedby, deleted_ts FROM %s WHERE urn IN (";
141143

142144
private static final String INDEX_GROUP_BY_CRITERION = "SELECT count(*) as COUNT, %s FROM %s";
143145

@@ -229,8 +231,10 @@ public static <ASPECT extends RecordTemplate> String createAspectReadSql(@Nonnul
229231
stringBuilder.append(String.format(sqlTemplate, columnName, tableName, columnName));
230232
stringBuilder.append(urnList);
231233
stringBuilder.append(RIGHT_PARENTHESIS);
232-
stringBuilder.append(" AND ");
233-
stringBuilder.append(DELETED_TS_IS_NULL_CHECK);
234+
if (!includeSoftDeleted) {
235+
stringBuilder.append(" AND ");
236+
stringBuilder.append(DELETED_TS_IS_NULL_CHECK);
237+
}
234238
return stringBuilder.toString();
235239
}
236240

dao-impl/ebean-dao/src/test/java/com/linkedin/metadata/dao/EbeanLocalAccessTest.java

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -924,4 +924,82 @@ public void testBatchSoftDeleteAssetsRejectsSqlInjection() {
924924
List<FooUrn> urns = Collections.singletonList(makeFooUrn(0));
925925
_ebeanLocalAccessFoo.batchSoftDeleteAssets(urns, "'; DROP TABLE metadata_entity_foo; --", false);
926926
}
927+
928+
// ===== Gap 4: Asset-level deletion visibility in batchGetUnion =====
929+
930+
@Test
931+
public void testAssetDeletedEntityVisibleWithIncludeSoftDeleted() {
932+
// Gap 4: After softDeleteAsset(), batchGetUnion(includeSoftDeleted=true) should return the row
933+
// so that shouldBackfill() can detect the entity is deleted and reject stale backfills.
934+
// softDeleteAsset() only sets deleted_ts; readSqlRow() synthesizes the soft-delete marker in memory.
935+
FooUrn fooUrn = makeFooUrn(400);
936+
AspectFoo aspectFoo = new AspectFoo().setValue("gap4test");
937+
AuditStamp auditStamp = makeAuditStamp("actor", System.currentTimeMillis());
938+
939+
// Step 1: Create entity with an aspect
940+
_ebeanLocalAccessFoo.add(fooUrn, aspectFoo, AspectFoo.class, auditStamp, null, false);
941+
942+
// Verify: aspect is readable normally
943+
AspectKey<FooUrn, AspectFoo> aspectKey = new AspectKey<>(AspectFoo.class, fooUrn, 0L);
944+
List<EbeanMetadataAspect> results =
945+
_ebeanLocalAccessFoo.batchGetUnion(Collections.singletonList(aspectKey), 1000, 0, false, false);
946+
assertEquals(1, results.size());
947+
assertFalse(EBeanDAOUtils.isSoftDeletedMetadata(results.get(0).getMetadata()));
948+
949+
// Step 2: Asset-level delete (sets deleted_ts only; aspect columns are untouched at DB level)
950+
int deleted = _ebeanLocalAccessFoo.softDeleteAsset(fooUrn, false);
951+
assertEquals(1, deleted);
952+
953+
// Step 3: batchGetUnion with includeSoftDeleted=false should NOT return the row (deleted_ts filters it)
954+
results = _ebeanLocalAccessFoo.batchGetUnion(Collections.singletonList(aspectKey), 1000, 0, false, false);
955+
assertEquals(0, results.size());
956+
957+
// Step 4: batchGetUnion with includeSoftDeleted=true SHOULD return the row (Gap 4 fix —
958+
// no longer filtered by deleted_ts IS NULL)
959+
results = _ebeanLocalAccessFoo.batchGetUnion(Collections.singletonList(aspectKey), 1000, 0, true, false);
960+
assertEquals(1, results.size());
961+
962+
// Step 5: The returned aspect should be marked as soft-deleted (readSqlRow() synthesizes {"gma_deleted": true})
963+
EbeanMetadataAspect result = results.get(0);
964+
assertEquals(fooUrn.toString(), result.getKey().getUrn());
965+
assertTrue(EBeanDAOUtils.isSoftDeletedMetadata(result.getMetadata()));
966+
}
967+
968+
@Test
969+
public void testWriteToAssetDeletedEntityClearsDeletedTs() {
970+
// Verify that a legitimate write to an asset-deleted entity clears deleted_ts,
971+
// reviving the entity and making it visible to normal reads again.
972+
FooUrn fooUrn = makeFooUrn(401);
973+
AspectFoo aspectFoo = new AspectFoo().setValue("gap4_revive_test");
974+
AuditStamp auditStamp = makeAuditStamp("actor", System.currentTimeMillis());
975+
976+
// Step 1: Create entity with an aspect
977+
_ebeanLocalAccessFoo.add(fooUrn, aspectFoo, AspectFoo.class, auditStamp, null, false);
978+
979+
// Step 2: Asset-level delete
980+
int deleted = _ebeanLocalAccessFoo.softDeleteAsset(fooUrn, false);
981+
assertEquals(1, deleted);
982+
983+
// Verify deleted_ts is set in DB
984+
SqlRow row = _server.createSqlQuery(
985+
"SELECT deleted_ts FROM metadata_entity_foo WHERE urn = '" + fooUrn + "'").findOne();
986+
assertNotNull(row.getTimestamp("deleted_ts"));
987+
988+
// Step 3: Write a new aspect value (simulates a legitimate, non-stale write)
989+
AspectFoo updatedAspect = new AspectFoo().setValue("gap4_revived");
990+
AuditStamp newAuditStamp = makeAuditStamp("actor", System.currentTimeMillis() + 1000);
991+
_ebeanLocalAccessFoo.add(fooUrn, updatedAspect, AspectFoo.class, newAuditStamp, null, false);
992+
993+
// Step 4: Verify deleted_ts is cleared in DB
994+
row = _server.createSqlQuery(
995+
"SELECT deleted_ts FROM metadata_entity_foo WHERE urn = '" + fooUrn + "'").findOne();
996+
assertNull(row.getTimestamp("deleted_ts"));
997+
998+
// Step 5: Entity should now be visible to normal reads (includeSoftDeleted=false)
999+
AspectKey<FooUrn, AspectFoo> aspectKey = new AspectKey<>(AspectFoo.class, fooUrn, 0L);
1000+
List<EbeanMetadataAspect> results =
1001+
_ebeanLocalAccessFoo.batchGetUnion(Collections.singletonList(aspectKey), 1000, 0, false, false);
1002+
assertEquals(1, results.size());
1003+
assertFalse(EBeanDAOUtils.isSoftDeletedMetadata(results.get(0).getMetadata()));
1004+
}
9271005
}

dao-impl/ebean-dao/src/test/java/com/linkedin/metadata/dao/utils/SQLStatementUtilsTest.java

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -146,12 +146,11 @@ public void testCreateAspectReadSql() {
146146
+ "AND deleted_ts IS NULL";
147147
assertEquals(SQLStatementUtils.createAspectReadSql(AspectFoo.class, set, false, false), expectedSql);
148148

149-
//test when includedSoftDeleted is true
149+
//test when includedSoftDeleted is true — should NOT filter deleted_ts, and should SELECT deleted_ts
150150
expectedSql =
151-
"SELECT urn, a_aspectfoo, lastmodifiedon, lastmodifiedby "
151+
"SELECT urn, a_aspectfoo, lastmodifiedon, lastmodifiedby, deleted_ts "
152152
+ "FROM metadata_entity_foo "
153-
+ "WHERE urn IN ('urn:li:foo:1', 'urn:li:foo:2') "
154-
+ "AND deleted_ts IS NULL";
153+
+ "WHERE urn IN ('urn:li:foo:1', 'urn:li:foo:2')";
155154
assertEquals(SQLStatementUtils.createAspectReadSql(AspectFoo.class, set, true, false), expectedSql);
156155
}
157156

@@ -707,18 +706,33 @@ public void testUpdateAspectWithOptimisticLockSql() {
707706
FooUrn fooUrn = makeFooUrn(1);
708707
String expectedSql =
709708
"UPDATE metadata_entity_foo SET a_aspectfoo = :metadata, a_urn = :a_urn, lastmodifiedon = :lastmodifiedon, "
710-
+ "lastmodifiedby = :lastmodifiedby WHERE urn = :urn and (JSON_EXTRACT(a_aspectfoo, '$.lastmodifiedon') = "
709+
+ "lastmodifiedby = :lastmodifiedby, deleted_ts = NULL WHERE urn = :urn and (JSON_EXTRACT(a_aspectfoo, '$.lastmodifiedon') = "
711710
+ ":oldTimestamp OR JSON_EXTRACT(a_aspectfoo, '$.gma_deleted') IS NOT NULL);";
712711
assertEquals(SQLStatementUtils.createAspectUpdateWithOptimisticLockSql(fooUrn, AspectFoo.class, true, false, false),
713712
expectedSql);
714713

715714
expectedSql =
716715
"UPDATE metadata_entity_foo SET a_aspectfoo = :metadata, lastmodifiedon = :lastmodifiedon, lastmodifiedby = "
717-
+ ":lastmodifiedby WHERE urn = :urn and (JSON_EXTRACT(a_aspectfoo, '$.lastmodifiedon') = :oldTimestamp "
716+
+ ":lastmodifiedby, deleted_ts = NULL WHERE urn = :urn and (JSON_EXTRACT(a_aspectfoo, '$.lastmodifiedon') = :oldTimestamp "
718717
+ "OR JSON_EXTRACT(a_aspectfoo, '$.gma_deleted') IS NOT NULL);";
719718
assertEquals(
720719
SQLStatementUtils.createAspectUpdateWithOptimisticLockSql(fooUrn, AspectFoo.class, false, false, false),
721720
expectedSql);
721+
722+
// softDeleteOverwrite=true with urnExtraction — should include deleted_ts = NULL
723+
expectedSql =
724+
"UPDATE metadata_entity_foo SET a_aspectfoo = "
725+
+ ":metadata, a_urn = :a_urn, lastmodifiedon = :lastmodifiedon, lastmodifiedby = :lastmodifiedby, deleted_ts = NULL WHERE urn = :urn;";
726+
assertEquals(SQLStatementUtils.createAspectUpdateWithOptimisticLockSql(fooUrn, AspectFoo.class, true, false, true),
727+
expectedSql);
728+
729+
// softDeleteOverwrite=true without urnExtraction — should include deleted_ts = NULL
730+
expectedSql =
731+
"UPDATE metadata_entity_foo SET a_aspectfoo = :metadata, lastmodifiedon = :lastmodifiedon, lastmodifiedby = :lastmodifiedby, deleted_ts = NULL "
732+
+ "WHERE urn = :urn ;";
733+
assertEquals(
734+
SQLStatementUtils.createAspectUpdateWithOptimisticLockSql(fooUrn, AspectFoo.class, false, false, true),
735+
expectedSql);
722736
}
723737

724738
@Test
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
# Gap 4: Asset-Level Deletion Invisible to Backfill Staleness Checks
2+
3+
## Problem
4+
5+
When an entire entity is deleted via `deleteAll()`, each aspect is first marked with `{"gma_deleted": true}` (via
6+
individual `delete()` calls), then `softDeleteAsset()` sets `deleted_ts = NOW()` on the entity row. The row becomes
7+
invisible to read queries because `batchGetUnion()` unconditionally filters `deleted_ts IS NULL` — even when
8+
`includeSoftDeleted=true`.
9+
10+
When a stale backfill event (e.g., DLQ replay) arrives for an aspect on this deleted entity:
11+
12+
1. `getLatest()` calls `batchGetUnion(..., includeSoftDeleted=true)` to fetch the current state
13+
2. `batchGetUnion()` generates SQL via `createAspectReadSql()`
14+
3. **Bug**: `createAspectReadSql()` unconditionally appends `AND deleted_ts IS NULL` regardless of the
15+
`includeSoftDeleted` parameter
16+
4. The deleted row is filtered out, query returns empty
17+
5. `getLatest()` returns `AspectEntry(null, null)` — null aspect, `isSoftDeleted=false`
18+
6. `shouldBackfill()` sees `oldValue == null && !isSoftDeleted` → "aspect never existed" → allows backfill
19+
7. The write resets `deleted_ts` to NULL, **resurrecting the deleted entity** with stale data
20+
21+
Note: Even though `deleteAll()` also sets aspect-level `{"gma_deleted": true}` markers (which PR #602's Gap 1 fix would
22+
catch), those markers are never reached because the entire row is invisible at the SQL level.
23+
24+
## Root Cause
25+
26+
**File**: `SQLStatementUtils.java`, method `createAspectReadSql()`
27+
28+
```java
29+
stringBuilder.append(urnList);
30+
stringBuilder.append(RIGHT_PARENTHESIS);
31+
stringBuilder.append(" AND ");
32+
stringBuilder.append(DELETED_TS_IS_NULL_CHECK); // Always appended — ignores includeSoftDeleted!
33+
```
34+
35+
## Fix
36+
37+
**One change in `SQLStatementUtils.createAspectReadSql()`**: make `AND deleted_ts IS NULL` conditional on
38+
`!includeSoftDeleted`:
39+
40+
```java
41+
stringBuilder.append(urnList);
42+
stringBuilder.append(RIGHT_PARENTHESIS);
43+
if (!includeSoftDeleted) {
44+
stringBuilder.append(" AND ");
45+
stringBuilder.append(DELETED_TS_IS_NULL_CHECK);
46+
}
47+
```
48+
49+
This matches the pattern already used correctly in `createListAspectByUrnSql()`.
50+
51+
### Generated SQL (after fix)
52+
53+
```sql
54+
-- includeSoftDeleted=false (normal reads — unchanged)
55+
SELECT urn, a_aspectfoo, lastmodifiedon, lastmodifiedby
56+
FROM metadata_entity_foo
57+
WHERE JSON_EXTRACT(a_aspectfoo, '$.gma_deleted') IS NULL
58+
AND urn IN ('urn:li:foo:1')
59+
AND deleted_ts IS NULL
60+
61+
-- includeSoftDeleted=true (backfill path — now includes deleted rows + deleted_ts)
62+
SELECT urn, a_aspectfoo, lastmodifiedon, lastmodifiedby, deleted_ts
63+
FROM metadata_entity_foo
64+
WHERE urn IN ('urn:li:foo:1')
65+
```
66+
67+
## How This Solves the Issue
68+
69+
After the fix, when a stale backfill arrives for an asset-deleted entity:
70+
71+
1. `getLatest()` calls `batchGetUnion(..., includeSoftDeleted=true)`
72+
2. `createAspectReadSql()` generates SQL **without** `deleted_ts IS NULL` filter
73+
3. The deleted row is returned — if `deleteAll()` was used, aspects have `{"gma_deleted": true}` markers which the
74+
existing aspect-level soft-delete detection in `readSqlRow()` handles
75+
4. If `softDeleteAsset()` was called directly (without aspect markers), `readSqlRow()` detects `deleted_ts` is non-null
76+
and marks the aspect as soft-deleted using `deleted_ts` as the deletion timestamp
77+
5. `getLatest()` returns `AspectEntry(null, extraInfo, isSoftDeleted=true)`
78+
6. `shouldBackfill()` (from PR #602) compares `emitTime > deletionTimestamp` → stale backfill rejected
79+
80+
Additionally, all UPDATE SQL templates now include `deleted_ts = NULL` so that any successful write (including via the
81+
optimistic locking / changelog path in `saveLatest()`) properly revives an asset-deleted entity. Without this, the
82+
`saveLatest()` changelog path would write the aspect but leave `deleted_ts` set, making the entity invisible to
83+
subsequent reads.
84+
85+
## Files Changed
86+
87+
| File | Change |
88+
| ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
89+
| `dao-impl/ebean-dao/src/main/java/.../utils/SQLStatementUtils.java` | Conditional `deleted_ts IS NULL` filter + `deleted_ts` in SELECT + `deleted_ts = NULL` in UPDATE |
90+
| `dao-impl/ebean-dao/src/main/java/.../utils/EBeanDAOUtils.java` | Handle asset-level deletion in `readSqlRow()` when `deleted_ts` is non-null |
91+
| `dao-impl/ebean-dao/src/test/java/.../dao/EbeanLocalAccessTest.java` | New test: asset-deleted entity visible with `includeSoftDeleted=true` |
92+
| `dao-impl/ebean-dao/src/test/java/.../utils/SQLStatementUtilsTest.java` | Update expected SQL for `includeSoftDeleted=true` and optimistic lock UPDATE |
93+
94+
## Relationship to Other Gaps
95+
96+
| Gap | What | Status |
97+
| --------- | ------------------------------------------------------ | ---------------- |
98+
| Gap 1 | `shouldBackfill()` blind to soft-deletes | Fixed in PR #602 |
99+
| Gap 2 | Missing per-aspect deletion timestamp | Fixed in PR #602 |
100+
| Gap 3 | Old schema loses emitTime on deletion | Fixed by Gap 2 |
101+
| **Gap 4** | **Asset-level deletion invisible to staleness checks** | **This PR** |
102+
103+
Gap 4 depends on PR #602: once the row is visible (Gap 4 fix), the aspect-level `{"gma_deleted": true}` markers are
104+
detected by `readSqlRow()`, and PR #602's `shouldBackfill()` logic rejects stale backfills.
105+
106+
## EI Verification (April 13, 2026)
107+
108+
**Entity:** `urn:li:demo:rakagraw_gap4_test_004` **Environment:** ei-ltx1 (mg_db_1_ei)
109+
110+
Confirmed the bug on current EI (without PR #602 or #612):
111+
112+
1. Created entity with Status + DemoInfo
113+
2. Asset-level delete (`delete` with empty aspectTypes) — `deleted_ts` set, both aspects marked `{"gma_deleted": true}`
114+
3. GET returns NOT FOUND (correct)
115+
4. Stale backfill (emitTime=1000, before deletion) — **entity resurrected**:
116+
- `deleted_ts` reset to NULL
117+
- `a_demo_info` overwritten with stale data
118+
- `a_status` still `{"gma_deleted": true}` (only backfilled aspect resurrected)
119+
120+
Full test results:
121+
[Backfill Gap Fix Verification Results](https://docs.google.com/document/d/17XNuwhBBanPuT5w5Bo-40ozN5bhTSSqyHxSTYaiXybs/edit)

0 commit comments

Comments
 (0)