Skip to content

Commit 5cdd3d7

Browse files
committed
feat(cache-analytics): add FieldPath dimension for value-type traversal
Extends entity field-hash analytics to scalars reached from an entity through pure value-type traversal (e.g. User.address.street where Address has no @key). Hashes attribute to the nearest enclosing entity for cache identity; the new FieldPath dimension disambiguates leaves at arbitrary nesting depth (User.address.street vs User.profile.location.street). - EntityFieldHash gains FieldPath []string (schema names, no array indices) - HashFieldValue takes a fieldPath param and defensively copies the slice so callers can reuse a scratch buffer - plan/visitor.go marks scalars under value-type ancestors via a new hasEntityAncestor() walk over Walker.TypeDefinitions - resolvable.go tracks currentEntityFieldPath as a push/pop stack across walkObject; reset to nil at every entity boundary, restored on exit - renderFieldValue eligibility check trusts the plan-time CacheAnalyticsHash flag and falls back to a polymorphic interface-on-entity match
1 parent def3ff8 commit 5cdd3d7

6 files changed

Lines changed: 241 additions & 53 deletions

File tree

v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_federation_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2061,6 +2061,7 @@ func TestGraphQLDataSourceFederation(t *testing.T) {
20612061
Names: []string{"account.service"},
20622062
},
20632063
ExactParentTypeName: "ShippingInfo",
2064+
CacheAnalyticsHash: true,
20642065
},
20652066
Value: &resolve.String{
20662067
Path: []string{"z"},

v2/pkg/engine/plan/visitor.go

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -550,10 +550,20 @@ func (v *Visitor) resolveFieldInfo(ref, typeRef int, onTypeNames [][]byte) *reso
550550
}
551551

552552
// Mark non-key fields on concrete entity types for cache analytics hashing;
553-
// polymorphic parents fall through to the runtime fallback.
553+
// polymorphic parents fall through to the runtime fallback. Also mark scalar
554+
// fields on value types reachable from an entity through pure value-type
555+
// traversal (e.g. User.address.street where Address has no @key) — these
556+
// are attributed to the nearest enclosing entity for cache identity.
554557
if v.Walker.EnclosingTypeDefinition.Kind == ast.NodeKindObjectTypeDefinition {
555558
if analytics := v.entityCacheAnalytics(enclosingTypeName); analytics != nil {
556559
fieldInfo.CacheAnalyticsHash = !analytics.IsKeyField(fieldName)
560+
} else if v.hasEntityAncestor() {
561+
// Value-type scalar nested under an entity scope. Plan-time
562+
// CacheAnalyticsHash gates the runtime emission; the resolvable
563+
// will attribute the hash to currentEntityTypeName (the nearest
564+
// enclosing entity preserved by walkObject's save/restore) with
565+
// ExactParentTypeName recorded separately for disambiguation.
566+
fieldInfo.CacheAnalyticsHash = true
557567
}
558568
}
559569

@@ -2614,6 +2624,33 @@ func (v *Visitor) entityCacheAnalytics(typeName string) *resolve.ObjectCacheAnal
26142624
return nil
26152625
}
26162626

2627+
// hasEntityAncestor reports whether the current field's enclosing scope sits
2628+
// inside an entity reached via pure value-type traversal (no nested entity
2629+
// boundary above it). The immediate enclosing type is assumed already checked
2630+
// by the caller and not to be an entity. We walk the type-definition chain
2631+
// backwards and return true at the first entity found; an interface ancestor
2632+
// is conservatively treated as a non-entity stop (the runtime polymorphic
2633+
// fallback handles those).
2634+
func (v *Visitor) hasEntityAncestor() bool {
2635+
td := v.Walker.TypeDefinitions
2636+
// The last entry is the current EnclosingTypeDefinition (the immediate
2637+
// parent of the field). Walk strictly above it.
2638+
for i := len(td) - 2; i >= 0; i-- {
2639+
node := td[i]
2640+
if node.Kind != ast.NodeKindObjectTypeDefinition {
2641+
continue
2642+
}
2643+
typeName := node.NameString(v.Definition)
2644+
if typeName == "" {
2645+
continue
2646+
}
2647+
if analytics := v.entityCacheAnalytics(typeName); analytics != nil {
2648+
return true
2649+
}
2650+
}
2651+
return false
2652+
}
2653+
26172654
// polymorphicEntityCacheAnalytics returns per-concrete-type cache analytics for an
26182655
// interface/union object. Returns nil when none of the possible types is an entity
26192656
// (so the caller can assign unconditionally).

v2/pkg/engine/resolve/cache_analytics.go

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -97,16 +97,21 @@ type SubgraphErrorEvent struct {
9797
Code string // error code from errors[0].extensions.code (empty if not present)
9898
}
9999

100-
// EntityFieldHash stores an xxhash of a scalar field value on an entity type,
101-
// along with the entity's key data and the source of the data.
100+
// EntityFieldHash stores an xxhash of a scalar field value reached from inside
101+
// an entity scope. KeyRaw/KeyHash always refer to the enclosing entity's @key,
102+
// even when the field lives on a nested value type. FieldPath is the schema-name
103+
// chain from the entity down to (but not including) the leaf — nil for direct
104+
// entity scalars, e.g. User.address.street → []string{"address"}. Array indexes
105+
// are omitted; nested entities reset the path.
102106
type EntityFieldHash struct {
103107
Timestamp time.Time // when the hash was computed; stamped by HashFieldValue if zero
104-
EntityType string
108+
EntityType string // enclosing entity type name (cache identity)
109+
FieldPath []string // schema-name field path from the enclosing entity to the parent (nil for direct entity scalars)
105110
FieldName string
106111
FieldHash uint64 // xxhash of the non-key field value
107112
KeyRaw string // raw key JSON e.g. {"id":"1234"} (when HashKeys=false)
108113
KeyHash uint64 // xxhash of key JSON (when HashKeys=true)
109-
Source FieldSource // where the entity data came from (L1/L2/Subgraph)
114+
Source FieldSource // category: where the entity data came from (L1/L2/Subgraph/ShadowCached)
110115
DataSource string // subgraph name that owns this entity (empty if not resolvable)
111116
}
112117

@@ -314,16 +319,28 @@ func (c *CacheAnalyticsCollector) RecordWrite(event CacheWriteEvent) {
314319
}
315320

316321
// HashFieldValue computes an xxhash of the given field value bytes and records it
317-
// as an EntityFieldHash with entity key and source information. dataSource is
318-
// the subgraph name that owns the entity (may be empty if not resolvable).
319-
func (c *CacheAnalyticsCollector) HashFieldValue(entityType, fieldName string, valueBytes []byte, keyRaw string, keyHash uint64, source FieldSource, dataSource string) {
322+
// as an EntityFieldHash with entity key and source information.
323+
//
324+
// entityType is the enclosing entity type name (cache identity). fieldPath is
325+
// the chain of schema field names from the enclosing entity down to (but not
326+
// including) the hashed leaf — empty for direct entity scalars. The slice is
327+
// copied so callers may reuse their scratch buffer. dataSource is the
328+
// subgraph name that owns the entity (may be empty if not resolvable).
329+
func (c *CacheAnalyticsCollector) HashFieldValue(entityType, fieldName string, fieldPath []string, valueBytes []byte, keyRaw string, keyHash uint64, source FieldSource, dataSource string) {
320330
c.xxh.Reset()
321331
_, _ = c.xxh.Write(valueBytes)
322332
hash := c.xxh.Sum64()
323333

334+
var pathCopy []string
335+
if len(fieldPath) > 0 {
336+
pathCopy = make([]string, len(fieldPath))
337+
copy(pathCopy, fieldPath)
338+
}
339+
324340
c.fieldHashes = append(c.fieldHashes, EntityFieldHash{
325341
Timestamp: time.Now(),
326342
EntityType: entityType,
343+
FieldPath: pathCopy,
327344
FieldName: fieldName,
328345
FieldHash: hash,
329346
KeyRaw: keyRaw,

v2/pkg/engine/resolve/cache_analytics_test.go

Lines changed: 107 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -136,8 +136,8 @@ func TestCacheAnalyticsCollector_FieldHashing(t *testing.T) {
136136
t.Run("same input produces same hash", func(t *testing.T) {
137137
c := NewCacheAnalyticsCollector()
138138

139-
c.HashFieldValue("User", "name", []byte(`"Alice"`), `{"id":"1"}`, 0, FieldSourceSubgraph, "")
140-
c.HashFieldValue("User", "name", []byte(`"Alice"`), `{"id":"1"}`, 0, FieldSourceSubgraph, "")
139+
c.HashFieldValue("User", "name", nil, []byte(`"Alice"`), `{"id":"1"}`, 0, FieldSourceSubgraph, "")
140+
c.HashFieldValue("User", "name", nil, []byte(`"Alice"`), `{"id":"1"}`, 0, FieldSourceSubgraph, "")
141141

142142
snap := c.Snapshot()
143143
assert.Equal(t, 2, len(snap.FieldHashes))
@@ -151,8 +151,8 @@ func TestCacheAnalyticsCollector_FieldHashing(t *testing.T) {
151151
t.Run("different input produces different hash", func(t *testing.T) {
152152
c := NewCacheAnalyticsCollector()
153153

154-
c.HashFieldValue("User", "name", []byte(`"Alice"`), `{"id":"1"}`, 0, FieldSourceSubgraph, "")
155-
c.HashFieldValue("User", "name", []byte(`"Bob"`), `{"id":"2"}`, 0, FieldSourceSubgraph, "")
154+
c.HashFieldValue("User", "name", nil, []byte(`"Alice"`), `{"id":"1"}`, 0, FieldSourceSubgraph, "")
155+
c.HashFieldValue("User", "name", nil, []byte(`"Bob"`), `{"id":"2"}`, 0, FieldSourceSubgraph, "")
156156

157157
snap := c.Snapshot()
158158
assert.Equal(t, 2, len(snap.FieldHashes))
@@ -162,7 +162,7 @@ func TestCacheAnalyticsCollector_FieldHashing(t *testing.T) {
162162
t.Run("hashed keys mode", func(t *testing.T) {
163163
c := NewCacheAnalyticsCollector()
164164

165-
c.HashFieldValue("User", "name", []byte(`"Alice"`), "", 12345, FieldSourceL1, "")
165+
c.HashFieldValue("User", "name", nil, []byte(`"Alice"`), "", 12345, FieldSourceL1, "")
166166

167167
snap := c.Snapshot()
168168
assert.Equal(t, 1, len(snap.FieldHashes))
@@ -174,9 +174,9 @@ func TestCacheAnalyticsCollector_FieldHashing(t *testing.T) {
174174
t.Run("field source tracking", func(t *testing.T) {
175175
c := NewCacheAnalyticsCollector()
176176

177-
c.HashFieldValue("User", "name", []byte(`"Alice"`), `{"id":"1"}`, 0, FieldSourceSubgraph, "")
178-
c.HashFieldValue("User", "name", []byte(`"Alice"`), `{"id":"1"}`, 0, FieldSourceL1, "")
179-
c.HashFieldValue("User", "name", []byte(`"Alice"`), `{"id":"1"}`, 0, FieldSourceL2, "")
177+
c.HashFieldValue("User", "name", nil, []byte(`"Alice"`), `{"id":"1"}`, 0, FieldSourceSubgraph, "")
178+
c.HashFieldValue("User", "name", nil, []byte(`"Alice"`), `{"id":"1"}`, 0, FieldSourceL1, "")
179+
c.HashFieldValue("User", "name", nil, []byte(`"Alice"`), `{"id":"1"}`, 0, FieldSourceL2, "")
180180

181181
snap := c.Snapshot()
182182
assert.Equal(t, 3, len(snap.FieldHashes))
@@ -1564,7 +1564,7 @@ func TestFieldSourceShadowCached(t *testing.T) {
15641564
t.Run("HashFieldValue with FieldSourceShadowCached", func(t *testing.T) {
15651565
c := NewCacheAnalyticsCollector()
15661566

1567-
c.HashFieldValue("User", "username", []byte(`"Alice"`), `{"id":"1"}`, 0, FieldSourceShadowCached, "")
1567+
c.HashFieldValue("User", "username", nil, []byte(`"Alice"`), `{"id":"1"}`, 0, FieldSourceShadowCached, "")
15681568

15691569
snap := c.Snapshot()
15701570
require.Equal(t, 1, len(snap.FieldHashes))
@@ -1577,8 +1577,8 @@ func TestFieldSourceShadowCached(t *testing.T) {
15771577
t.Run("can distinguish from other sources", func(t *testing.T) {
15781578
c := NewCacheAnalyticsCollector()
15791579

1580-
c.HashFieldValue("User", "name", []byte(`"Alice"`), `{"id":"1"}`, 0, FieldSourceSubgraph, "")
1581-
c.HashFieldValue("User", "name", []byte(`"Alice"`), `{"id":"1"}`, 0, FieldSourceShadowCached, "")
1580+
c.HashFieldValue("User", "name", nil, []byte(`"Alice"`), `{"id":"1"}`, 0, FieldSourceSubgraph, "")
1581+
c.HashFieldValue("User", "name", nil, []byte(`"Alice"`), `{"id":"1"}`, 0, FieldSourceShadowCached, "")
15821582

15831583
snap := c.Snapshot()
15841584
require.Equal(t, 2, len(snap.FieldHashes))
@@ -1589,6 +1589,99 @@ func TestFieldSourceShadowCached(t *testing.T) {
15891589
})
15901590
}
15911591

1592+
// TestCacheAnalyticsCollector_FieldPath verifies the FieldPath dimension on
1593+
// EntityFieldHash, which disambiguates value-type scalars nested at arbitrary
1594+
// depth under an entity (e.g. User.address.street vs User.profile.name).
1595+
// EntityType remains the cache identity in all cases; schema-aware consumers
1596+
// resolve the parent type by walking FieldPath against the schema.
1597+
func TestCacheAnalyticsCollector_FieldPath(t *testing.T) {
1598+
t.Run("direct entity scalar has nil field path", func(t *testing.T) {
1599+
c := NewCacheAnalyticsCollector()
1600+
1601+
// User.email — direct scalar on the entity, no value-type traversal.
1602+
c.HashFieldValue("User", "email", nil, []byte(`"a@b.com"`), `{"id":"1"}`, 0, FieldSourceSubgraph, "")
1603+
1604+
snap := c.Snapshot()
1605+
require.Equal(t, 1, len(snap.FieldHashes))
1606+
assert.Equal(t, "User", snap.FieldHashes[0].EntityType)
1607+
assert.Equal(t, "email", snap.FieldHashes[0].FieldName)
1608+
assert.Nil(t, snap.FieldHashes[0].FieldPath)
1609+
})
1610+
1611+
t.Run("value-type scalars under same entity attribute to that entity", func(t *testing.T) {
1612+
c := NewCacheAnalyticsCollector()
1613+
1614+
// User.address.street and .city — Address is a value type under User.
1615+
// Both attribute to User for cache identity (same KeyRaw).
1616+
c.HashFieldValue("User", "street", []string{"address"}, []byte(`"1 Main"`), `{"id":"1"}`, 0, FieldSourceSubgraph, "")
1617+
c.HashFieldValue("User", "city", []string{"address"}, []byte(`"NYC"`), `{"id":"1"}`, 0, FieldSourceSubgraph, "")
1618+
1619+
snap := c.Snapshot()
1620+
require.Equal(t, 2, len(snap.FieldHashes))
1621+
for i, want := range []string{"street", "city"} {
1622+
assert.Equal(t, "User", snap.FieldHashes[i].EntityType)
1623+
assert.Equal(t, want, snap.FieldHashes[i].FieldName)
1624+
assert.Equal(t, []string{"address"}, snap.FieldHashes[i].FieldPath)
1625+
assert.Equal(t, `{"id":"1"}`, snap.FieldHashes[i].KeyRaw)
1626+
}
1627+
})
1628+
1629+
t.Run("siblings on different value-type parents do not collapse", func(t *testing.T) {
1630+
// Same FieldName "name" reached via different value-type paths
1631+
// (User.profile.name vs User.address.name) — recorded as two distinct
1632+
// events because their FieldPath differs.
1633+
c := NewCacheAnalyticsCollector()
1634+
c.HashFieldValue("User", "name", []string{"profile"}, []byte(`"Profile-Alice"`), `{"id":"1"}`, 0, FieldSourceSubgraph, "")
1635+
c.HashFieldValue("User", "name", []string{"address"}, []byte(`"Home"`), `{"id":"1"}`, 0, FieldSourceSubgraph, "")
1636+
1637+
snap := c.Snapshot()
1638+
require.Equal(t, 2, len(snap.FieldHashes))
1639+
assert.Equal(t, []string{"profile"}, snap.FieldHashes[0].FieldPath)
1640+
assert.Equal(t, []string{"address"}, snap.FieldHashes[1].FieldPath)
1641+
assert.NotEqual(t, snap.FieldHashes[0].FieldHash, snap.FieldHashes[1].FieldHash)
1642+
})
1643+
1644+
t.Run("field path captures arbitrary nesting depth", func(t *testing.T) {
1645+
// User.profile.location.street is two value-type hops deep.
1646+
c := NewCacheAnalyticsCollector()
1647+
c.HashFieldValue("User", "street",
1648+
[]string{"profile", "location"},
1649+
[]byte(`"1 Main"`), `{"id":"1"}`, 0, FieldSourceSubgraph, "")
1650+
1651+
snap := c.Snapshot()
1652+
require.Equal(t, 1, len(snap.FieldHashes))
1653+
assert.Equal(t, "User", snap.FieldHashes[0].EntityType)
1654+
assert.Equal(t, []string{"profile", "location"}, snap.FieldHashes[0].FieldPath)
1655+
assert.Equal(t, "street", snap.FieldHashes[0].FieldName)
1656+
})
1657+
1658+
t.Run("field path is copied so caller scratch buffers can be reused", func(t *testing.T) {
1659+
// Many call sites will pass a reusable scratch slice. The collector
1660+
// must defensively copy so a subsequent push by the caller does not
1661+
// mutate already-recorded events.
1662+
c := NewCacheAnalyticsCollector()
1663+
scratch := []string{"profile"}
1664+
c.HashFieldValue("User", "name", scratch,
1665+
[]byte(`"Alice"`), `{"id":"1"}`, 0, FieldSourceSubgraph, "")
1666+
// Caller mutates the scratch slice after the call.
1667+
scratch[0] = "MUTATED"
1668+
scratch = append(scratch, "more")
1669+
1670+
snap := c.Snapshot()
1671+
require.Equal(t, 1, len(snap.FieldHashes))
1672+
assert.Equal(t, []string{"profile"}, snap.FieldHashes[0].FieldPath, "stored path must be unaffected by caller mutation")
1673+
})
1674+
1675+
t.Run("empty field path is stored as nil for direct entity scalars", func(t *testing.T) {
1676+
c := NewCacheAnalyticsCollector()
1677+
c.HashFieldValue("User", "email", nil,
1678+
[]byte(`"a@b"`), `{"id":"1"}`, 0, FieldSourceSubgraph, "")
1679+
snap := c.Snapshot()
1680+
require.Equal(t, 1, len(snap.FieldHashes))
1681+
assert.Nil(t, snap.FieldHashes[0].FieldPath, "direct entity scalars should record nil FieldPath")
1682+
})
1683+
}
1684+
15921685
// TestShadowComparisonEvent_Recording verifies that shadow comparison events
15931686
// capture all fields (hash, size, age, TTL) needed to detect staleness.
15941687
func TestShadowComparisonEvent_Recording(t *testing.T) {
@@ -1755,7 +1848,7 @@ func BenchmarkFieldHashing(b *testing.B) {
17551848

17561849
b.ResetTimer()
17571850
for b.Loop() {
1758-
c.HashFieldValue("User", "id", value, `{"id":"1"}`, 0, FieldSourceSubgraph, "")
1851+
c.HashFieldValue("User", "id", nil, value, `{"id":"1"}`, 0, FieldSourceSubgraph, "")
17591852
}
17601853
}
17611854

@@ -2056,7 +2149,7 @@ func TestSnapshotSlicesAreIndependent(t *testing.T) {
20562149
c.RecordError(SubgraphErrorEvent{DataSource: "ds-orig"})
20572150
c.RecordMutationEvent(MutationEvent{EntityType: "User-orig"})
20582151
c.RecordCacheOperationError(CacheOperationError{DataSource: "ds-orig"})
2059-
c.HashFieldValue("User-orig", "name", []byte(`"a"`), "k-orig", 1, FieldSourceL1, "")
2152+
c.HashFieldValue("User-orig", "name", nil, []byte(`"a"`), "k-orig", 1, FieldSourceL1, "")
20602153

20612154
snap := c.Snapshot()
20622155

@@ -2078,7 +2171,7 @@ func TestSnapshotSlicesAreIndependent(t *testing.T) {
20782171
c.RecordError(SubgraphErrorEvent{DataSource: "ds-new"})
20792172
c.RecordMutationEvent(MutationEvent{EntityType: "User-new"})
20802173
c.RecordCacheOperationError(CacheOperationError{DataSource: "ds-new"})
2081-
c.HashFieldValue("User-new", "name", []byte(`"z"`), "k-new", 2, FieldSourceL2, "")
2174+
c.HashFieldValue("User-new", "name", nil, []byte(`"z"`), "k-new", 2, FieldSourceL2, "")
20822175
}
20832176

20842177
// Full-slice assertions — snapshot must still show the original events.

v2/pkg/engine/resolve/loader_cache.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2502,7 +2502,7 @@ func (l *Loader) compareShadowValues(res *result, info *FetchInfo) {
25022502
if fieldVal != nil {
25032503
fieldBytes := fieldVal.MarshalTo(nil)
25042504
l.ctx.cacheAnalytics.HashFieldValue(
2505-
entityType, fieldName, fieldBytes,
2505+
entityType, fieldName, nil, fieldBytes,
25062506
keyRaw, 0, FieldSourceShadowCached, dataSource,
25072507
)
25082508
}

0 commit comments

Comments
 (0)