From b4b5815392b2ed021ce619171d9b772c2d4c20d8 Mon Sep 17 00:00:00 2001 From: ellisandrews-toast Date: Fri, 12 Jun 2026 12:47:00 -0700 Subject: [PATCH] Add painless script support and wiring for nested sourced_from fields --- config/schema/artifacts/datastore_config.yaml | 211 ++++++++++++++++-- config/schema/artifacts/runtime_metadata.yaml | 38 ++-- .../datastore_config.yaml | 211 ++++++++++++++++-- .../runtime_metadata.yaml | 38 ++-- .../shared_examples.rb | 2 +- .../elastic_graph/indexer/operation/update.rb | 8 +- .../indexer/operation/update_spec.rb | 1 + .../schema_definition/indexing/index.rb | 4 + .../scripts/update/index_data.painless | 170 +++++++++++++- .../index_mappings/miscellaneous_spec.rb | 2 +- .../lib/elastic_graph/constants.rb | 2 +- 11 files changed, 605 insertions(+), 82 deletions(-) diff --git a/config/schema/artifacts/datastore_config.yaml b/config/schema/artifacts/datastore_config.yaml index 61028816d..09ade620d 100644 --- a/config/schema/artifacts/datastore_config.yaml +++ b/config/schema/artifacts/datastore_config.yaml @@ -1236,6 +1236,9 @@ index_templates: type: integer nested_fields2|the_seasons: type: integer + __nested_sourced_data: + type: object + dynamic: 'false' __sources: type: keyword __versions: @@ -1310,6 +1313,9 @@ index_templates: type: integer widget_options|colors: type: integer + __nested_sourced_data: + type: object + dynamic: 'false' __sources: type: keyword __versions: @@ -1476,6 +1482,9 @@ index_templates: type: integer fees|amount_cents: type: integer + __nested_sourced_data: + type: object + dynamic: 'false' __sources: type: keyword __versions: @@ -1527,6 +1536,9 @@ indices: type: integer shapes|coordinates: type: integer + __nested_sourced_data: + type: object + dynamic: 'false' __sources: type: keyword __versions: @@ -1593,6 +1605,9 @@ indices: type: integer owner_ids: type: integer + __nested_sourced_data: + type: object + dynamic: 'false' __sources: type: keyword __versions: @@ -1625,6 +1640,9 @@ indices: type: keyword __typename: type: keyword + __nested_sourced_data: + type: object + dynamic: 'false' __sources: type: keyword __versions: @@ -1654,6 +1672,9 @@ indices: type: integer manufacturer_id: type: keyword + __nested_sourced_data: + type: object + dynamic: 'false' __sources: type: keyword __versions: @@ -1690,6 +1711,9 @@ indices: type: keyword nationality: type: keyword + __nested_sourced_data: + type: object + dynamic: 'false' __sources: type: keyword __versions: @@ -1722,6 +1746,9 @@ indices: type: keyword manufacturer_id: type: keyword + __nested_sourced_data: + type: object + dynamic: 'false' __sources: type: keyword __versions: @@ -1753,6 +1780,9 @@ indices: type: keyword __typename: type: keyword + __nested_sourced_data: + type: object + dynamic: 'false' __sources: type: keyword __versions: @@ -1778,6 +1808,9 @@ indices: format: strict_date active: type: boolean + __nested_sourced_data: + type: object + dynamic: 'false' __sources: type: keyword __versions: @@ -1803,6 +1836,9 @@ indices: type: keyword name: type: keyword + __nested_sourced_data: + type: object + dynamic: 'false' __sources: type: keyword __versions: @@ -1835,6 +1871,9 @@ indices: created_at: type: date format: strict_date_time + __nested_sourced_data: + type: object + dynamic: 'false' __sources: type: keyword __versions: @@ -2156,13 +2195,107 @@ scripts: // No timestamp values matched the params, so return `false`. return false; - update_index_data_b9e2b105d736d8d16ae269ab6ff81e4d: + update_index_data_f8fc933145913e9a98574ede2f3881ac: context: update script: lang: painless source: |- // --- Helper Functions --- // - void setup(Map source, String relationship, Map counts) { + + // Encodes a list of strings into an unambiguous, length-prefixed string ("len:value" parts concatenated). + String encodeKey(List parts) { + StringBuilder sb = new StringBuilder(); + for (String part : parts) { + sb.append(part.length()); + sb.append(':'); + sb.append(part); + } + return sb.toString(); + } + + // Inverse of `encodeKey`. + List decodeKey(String key) { + List parts = new ArrayList(); + int i = 0; + while (i < key.length()) { + int colonPos = key.indexOf(":", i); + int length = Integer.parseInt(key.substring(i, colonPos)); + int valueStart = colonPos + 1; + parts.add(key.substring(valueStart, valueStart + length)); + i = valueStart + length; + } + return parts; + } + + // The encoded path to the nested element this event targets, or "" for a top-level event (no path). + // List segments contribute their matched id (the value at `sourceField`); object segments contribute their + // field name. Every segment contributes one part, so a path with any segments yields a non-empty key. + String buildNestedElementKey(String relationship, Map sourcedFromNestedPaths, Map sourcedFromNestedPathIdentifiers) { + List pathSegments = (List) sourcedFromNestedPaths.get(relationship); + if (pathSegments == null) { + return ""; + } + List parts = new ArrayList(); + for (Map segment : pathSegments) { + if (segment.containsKey("sourceField")) { + parts.add(sourcedFromNestedPathIdentifiers[segment.sourceField]); + } else { + parts.add(segment.get("field")); + } + } + return encodeKey(parts); + } + + // The `__versions` key: the relationship for top-level events, or relationship + element identifiers for nested ones. + String buildVersionsKey(String relationship, String nestedElementKey) { + if (nestedElementKey.isEmpty()) { + return relationship; + } + List parts = decodeKey(nestedElementKey); + parts.add(0, relationship); + return encodeKey(parts); + } + + // Finds the element of `elements` whose `id` equals `matchValue`, or null. + def findInList(List elements, String matchValue) { + for (Map element : elements) { + if (matchValue.equals(element.id)) { + return element; + } + } + return null; + } + + // Navigates `source` through `pathSegments` to the target nested element, or null if any hop is missing. + // `keyParts` has one entry per segment (aligned by index): a list element's matched id, or an object field name. + def navigateToNestedElement(Map source, List pathSegments, List keyParts) { + Map current = source; + + for (int i = 0; i < pathSegments.size(); i++) { + Map segment = (Map) pathSegments.get(i); + String field = (String) segment.get("field"); + + if (!current.containsKey(field)) { + return null; + } + + if (segment.containsKey("sourceField")) { + current = (Map) findInList((List) current.get(field), (String) keyParts.get(i)); + } else { + current = (Map) current.get(field); + } + + if (current == null) { + return null; + } + } + + return current; + } + + // --- Main Functions --- // + + void setup(Map source, String versionsKey, String relationship, String nestedElementKey, Map counts) { if (source.__sources == null) { source.__sources = []; } @@ -2171,8 +2304,17 @@ scripts: source.__versions = [:]; } - if (source.__versions[relationship] == null) { - source.__versions[relationship] = [:]; + if (source.__versions[versionsKey] == null) { + source.__versions[versionsKey] = [:]; + } + + if (!nestedElementKey.isEmpty()) { + if (source.__nested_sourced_data == null) { + source.__nested_sourced_data = [:]; + } + if (source.__nested_sourced_data[relationship] == null) { + source.__nested_sourced_data[relationship] = [:]; + } } if (counts != null && source.__counts == null) { @@ -2180,9 +2322,9 @@ scripts: } } - void validateSource(Map source, String id, String relationship, String sourceId, long eventVersion) { - Map relationshipVersionsMap = source.__versions.get(relationship); - List previousSourceIdsForRelationship = relationshipVersionsMap.keySet().stream().filter(key -> key != sourceId).collect(Collectors.toList()); + void validateSource(Map source, String id, String relationship, String sourceId, long eventVersion, String versionsKey) { + Map versionsMap = source.__versions[versionsKey]; + List previousSourceIdsForRelationship = versionsMap.keySet().stream().filter(key -> key != sourceId).collect(Collectors.toList()); if (previousSourceIdsForRelationship.size() > 0) { throw new IllegalArgumentException( @@ -2194,7 +2336,7 @@ scripts: ); } - Number maybeDocVersion = relationshipVersionsMap.get(sourceId); + Number maybeDocVersion = versionsMap.get(sourceId); // Our JSON schema requires event versions to be non-negative, so we can safely use Long.MIN_VALUE as a stand-in when the value is null. long docVersion = maybeDocVersion == null ? Long.MIN_VALUE : maybeDocVersion.longValue(); @@ -2216,8 +2358,43 @@ scripts: } } - void recordSource(Map source, String relationship, String sourceId, long eventVersion) { - source.__versions[relationship][sourceId] = eventVersion; + // Buffers nested sourced fields keyed by the target element, so they can be re-applied after any later self-event. + void storeNestedSourcedData(Map source, String relationship, Map sourcedFromNestedFields, String nestedElementKey) { + if (sourcedFromNestedFields.isEmpty()) { + return; + } + + ((Map) source.__nested_sourced_data[relationship]).put(nestedElementKey, sourcedFromNestedFields); + } + + // Re-applies all buffered nested sourced data to its target elements. Runs on every event so that a + // self-event's `putAll` (which overwrites nested arrays with fresh data) doesn't drop previously sourced fields. + void applyNestedSourcedData(Map source, Map sourcedFromNestedPaths) { + if (source.__nested_sourced_data == null) { + return; + } + + for (sourcedEntry in source.__nested_sourced_data.entrySet()) { + String relationship = (String) sourcedEntry.getKey(); + Map dataByKey = (Map) sourcedEntry.getValue(); + List pathSegments = (List) sourcedFromNestedPaths.get(relationship); + + if (pathSegments == null) { + continue; + } + + for (elementEntry in dataByKey.entrySet()) { + List keyParts = decodeKey((String) elementEntry.getKey()); + Map target = (Map) navigateToNestedElement(source, pathSegments, keyParts); + if (target != null) { + target.putAll((Map) elementEntry.getValue()); + } + } + } + } + + void recordSource(Map source, String versionsKey, String relationship, String sourceId, long eventVersion) { + source.__versions[versionsKey][sourceId] = eventVersion; // Record the relationship in `__sources` if it's not already there. We maintain it as an append-only set using a sorted list. // This ensures deterministic ordering of its elements regardless of event ingestion order, and lets us check membership in O(log N) time. @@ -2241,8 +2418,16 @@ scripts: String sourceId = params.sourceId; long eventVersion = (long) params.version; // Cast to long since JSON parses numbers as doubles Map counts = params.__counts; + Map sourcedFromNestedFields = params.sourcedFromNestedFields; + Map sourcedFromNestedPathIdentifiers = params.sourcedFromNestedPathIdentifiers; + Map sourcedFromNestedPaths = params.sourcedFromNestedPaths; + + String nestedElementKey = buildNestedElementKey(relationship, sourcedFromNestedPaths, sourcedFromNestedPathIdentifiers); + String versionsKey = buildVersionsKey(relationship, nestedElementKey); - setup(source, relationship, counts); - validateSource(source, id, relationship, sourceId, eventVersion); + setup(source, versionsKey, relationship, nestedElementKey, counts); + validateSource(source, id, relationship, sourceId, eventVersion, versionsKey); applyTopLevelFields(source, id, params.topLevelFields, counts); - recordSource(source, relationship, sourceId, eventVersion); + storeNestedSourcedData(source, relationship, sourcedFromNestedFields, nestedElementKey); + applyNestedSourcedData(source, sourcedFromNestedPaths); + recordSource(source, versionsKey, relationship, sourceId, eventVersion); diff --git a/config/schema/artifacts/runtime_metadata.yaml b/config/schema/artifacts/runtime_metadata.yaml index 19a30bf8f..a76f1b327 100644 --- a/config/schema/artifacts/runtime_metadata.yaml +++ b/config/schema/artifacts/runtime_metadata.yaml @@ -3085,7 +3085,7 @@ object_types_by_name: cardinality: one relationship: __self routing_value_source: id - script_id: update_index_data_b9e2b105d736d8d16ae269ab6ff81e4d + script_id: update_index_data_f8fc933145913e9a98574ede2f3881ac top_level_fields_params: full_address: cardinality: one @@ -3275,7 +3275,7 @@ object_types_by_name: cardinality: one relationship: __self routing_value_source: id - script_id: update_index_data_b9e2b105d736d8d16ae269ab6ff81e4d + script_id: update_index_data_f8fc933145913e9a98574ede2f3881ac top_level_fields_params: __typename: cardinality: one @@ -3314,7 +3314,7 @@ object_types_by_name: cardinality: one relationship: __self routing_value_source: id - script_id: update_index_data_b9e2b105d736d8d16ae269ab6ff81e4d + script_id: update_index_data_f8fc933145913e9a98574ede2f3881ac top_level_fields_params: __typename: cardinality: one @@ -3414,7 +3414,7 @@ object_types_by_name: cardinality: one relationship: __self routing_value_source: id - script_id: update_index_data_b9e2b105d736d8d16ae269ab6ff81e4d + script_id: update_index_data_f8fc933145913e9a98574ede2f3881ac top_level_fields_params: created_at: cardinality: one @@ -3724,7 +3724,7 @@ object_types_by_name: cardinality: one relationship: __self routing_value_source: id - script_id: update_index_data_b9e2b105d736d8d16ae269ab6ff81e4d + script_id: update_index_data_f8fc933145913e9a98574ede2f3881ac top_level_fields_params: __typename: cardinality: one @@ -3897,7 +3897,7 @@ object_types_by_name: cardinality: one relationship: __self routing_value_source: id - script_id: update_index_data_b9e2b105d736d8d16ae269ab6ff81e4d + script_id: update_index_data_f8fc933145913e9a98574ede2f3881ac top_level_fields_params: created_at: cardinality: one @@ -4382,7 +4382,7 @@ object_types_by_name: cardinality: one relationship: __self routing_value_source: id - script_id: update_index_data_b9e2b105d736d8d16ae269ab6ff81e4d + script_id: update_index_data_f8fc933145913e9a98574ede2f3881ac top_level_fields_params: ceo: cardinality: one @@ -4540,7 +4540,7 @@ object_types_by_name: cardinality: one relationship: __self routing_value_source: id - script_id: update_index_data_b9e2b105d736d8d16ae269ab6ff81e4d + script_id: update_index_data_f8fc933145913e9a98574ede2f3881ac top_level_fields_params: created_at: cardinality: one @@ -5501,7 +5501,7 @@ object_types_by_name: cardinality: one relationship: __self routing_value_source: id - script_id: update_index_data_b9e2b105d736d8d16ae269ab6ff81e4d + script_id: update_index_data_f8fc933145913e9a98574ede2f3881ac top_level_fields_params: __typename: cardinality: one @@ -5700,7 +5700,7 @@ object_types_by_name: cardinality: one relationship: __self routing_value_source: id - script_id: update_index_data_b9e2b105d736d8d16ae269ab6ff81e4d + script_id: update_index_data_f8fc933145913e9a98574ede2f3881ac top_level_fields_params: __typename: cardinality: one @@ -5767,7 +5767,7 @@ object_types_by_name: cardinality: one relationship: __self routing_value_source: id - script_id: update_index_data_b9e2b105d736d8d16ae269ab6ff81e4d + script_id: update_index_data_f8fc933145913e9a98574ede2f3881ac top_level_fields_params: active: cardinality: one @@ -6304,7 +6304,7 @@ object_types_by_name: cardinality: one relationship: __self routing_value_source: id - script_id: update_index_data_b9e2b105d736d8d16ae269ab6ff81e4d + script_id: update_index_data_f8fc933145913e9a98574ede2f3881ac top_level_fields_params: name: cardinality: one @@ -6654,7 +6654,7 @@ object_types_by_name: relationship: __self rollover_timestamp_value_source: formed_on routing_value_source: league - script_id: update_index_data_b9e2b105d736d8d16ae269ab6ff81e4d + script_id: update_index_data_f8fc933145913e9a98574ede2f3881ac top_level_fields_params: country_code: cardinality: one @@ -7754,7 +7754,7 @@ object_types_by_name: relationship: __self rollover_timestamp_value_source: created_at routing_value_source: workspace_id2 - script_id: update_index_data_b9e2b105d736d8d16ae269ab6ff81e4d + script_id: update_index_data_f8fc933145913e9a98574ede2f3881ac top_level_fields_params: amount_cents: cardinality: one @@ -7832,7 +7832,7 @@ object_types_by_name: version: cardinality: one relationship: widget - script_id: update_index_data_b9e2b105d736d8d16ae269ab6ff81e4d + script_id: update_index_data_f8fc933145913e9a98574ede2f3881ac top_level_fields_params: widget_cost: cardinality: one @@ -8062,7 +8062,7 @@ object_types_by_name: relationship: __self rollover_timestamp_value_source: introduced_on routing_value_source: primary_continent - script_id: update_index_data_b9e2b105d736d8d16ae269ab6ff81e4d + script_id: update_index_data_f8fc933145913e9a98574ede2f3881ac top_level_fields_params: details: cardinality: one @@ -9107,7 +9107,7 @@ object_types_by_name: cardinality: one relationship: __self routing_value_source: id - script_id: update_index_data_b9e2b105d736d8d16ae269ab6ff81e4d + script_id: update_index_data_f8fc933145913e9a98574ede2f3881ac top_level_fields_params: name: cardinality: one @@ -9129,7 +9129,7 @@ object_types_by_name: relationship: workspace rollover_timestamp_value_source: widget.created_at routing_value_source: id - script_id: update_index_data_b9e2b105d736d8d16ae269ab6ff81e4d + script_id: update_index_data_f8fc933145913e9a98574ede2f3881ac top_level_fields_params: workspace_name: cardinality: one @@ -9364,4 +9364,4 @@ static_script_ids_by_scoped_name: field/as_day_of_week: field_as_day_of_week_f2b5c7d9e8f75bf2457b52412bfb6537 field/as_time_of_day: field_as_time_of_day_ed82aba44fc66bff5635bec4305c1c66 filter/by_time_of_day: filter_by_time_of_day_ea12d0561b24961789ab68ed38435612 - update/index_data: update_index_data_b9e2b105d736d8d16ae269ab6ff81e4d + update/index_data: update_index_data_f8fc933145913e9a98574ede2f3881ac diff --git a/config/schema/artifacts_with_apollo/datastore_config.yaml b/config/schema/artifacts_with_apollo/datastore_config.yaml index 61028816d..09ade620d 100644 --- a/config/schema/artifacts_with_apollo/datastore_config.yaml +++ b/config/schema/artifacts_with_apollo/datastore_config.yaml @@ -1236,6 +1236,9 @@ index_templates: type: integer nested_fields2|the_seasons: type: integer + __nested_sourced_data: + type: object + dynamic: 'false' __sources: type: keyword __versions: @@ -1310,6 +1313,9 @@ index_templates: type: integer widget_options|colors: type: integer + __nested_sourced_data: + type: object + dynamic: 'false' __sources: type: keyword __versions: @@ -1476,6 +1482,9 @@ index_templates: type: integer fees|amount_cents: type: integer + __nested_sourced_data: + type: object + dynamic: 'false' __sources: type: keyword __versions: @@ -1527,6 +1536,9 @@ indices: type: integer shapes|coordinates: type: integer + __nested_sourced_data: + type: object + dynamic: 'false' __sources: type: keyword __versions: @@ -1593,6 +1605,9 @@ indices: type: integer owner_ids: type: integer + __nested_sourced_data: + type: object + dynamic: 'false' __sources: type: keyword __versions: @@ -1625,6 +1640,9 @@ indices: type: keyword __typename: type: keyword + __nested_sourced_data: + type: object + dynamic: 'false' __sources: type: keyword __versions: @@ -1654,6 +1672,9 @@ indices: type: integer manufacturer_id: type: keyword + __nested_sourced_data: + type: object + dynamic: 'false' __sources: type: keyword __versions: @@ -1690,6 +1711,9 @@ indices: type: keyword nationality: type: keyword + __nested_sourced_data: + type: object + dynamic: 'false' __sources: type: keyword __versions: @@ -1722,6 +1746,9 @@ indices: type: keyword manufacturer_id: type: keyword + __nested_sourced_data: + type: object + dynamic: 'false' __sources: type: keyword __versions: @@ -1753,6 +1780,9 @@ indices: type: keyword __typename: type: keyword + __nested_sourced_data: + type: object + dynamic: 'false' __sources: type: keyword __versions: @@ -1778,6 +1808,9 @@ indices: format: strict_date active: type: boolean + __nested_sourced_data: + type: object + dynamic: 'false' __sources: type: keyword __versions: @@ -1803,6 +1836,9 @@ indices: type: keyword name: type: keyword + __nested_sourced_data: + type: object + dynamic: 'false' __sources: type: keyword __versions: @@ -1835,6 +1871,9 @@ indices: created_at: type: date format: strict_date_time + __nested_sourced_data: + type: object + dynamic: 'false' __sources: type: keyword __versions: @@ -2156,13 +2195,107 @@ scripts: // No timestamp values matched the params, so return `false`. return false; - update_index_data_b9e2b105d736d8d16ae269ab6ff81e4d: + update_index_data_f8fc933145913e9a98574ede2f3881ac: context: update script: lang: painless source: |- // --- Helper Functions --- // - void setup(Map source, String relationship, Map counts) { + + // Encodes a list of strings into an unambiguous, length-prefixed string ("len:value" parts concatenated). + String encodeKey(List parts) { + StringBuilder sb = new StringBuilder(); + for (String part : parts) { + sb.append(part.length()); + sb.append(':'); + sb.append(part); + } + return sb.toString(); + } + + // Inverse of `encodeKey`. + List decodeKey(String key) { + List parts = new ArrayList(); + int i = 0; + while (i < key.length()) { + int colonPos = key.indexOf(":", i); + int length = Integer.parseInt(key.substring(i, colonPos)); + int valueStart = colonPos + 1; + parts.add(key.substring(valueStart, valueStart + length)); + i = valueStart + length; + } + return parts; + } + + // The encoded path to the nested element this event targets, or "" for a top-level event (no path). + // List segments contribute their matched id (the value at `sourceField`); object segments contribute their + // field name. Every segment contributes one part, so a path with any segments yields a non-empty key. + String buildNestedElementKey(String relationship, Map sourcedFromNestedPaths, Map sourcedFromNestedPathIdentifiers) { + List pathSegments = (List) sourcedFromNestedPaths.get(relationship); + if (pathSegments == null) { + return ""; + } + List parts = new ArrayList(); + for (Map segment : pathSegments) { + if (segment.containsKey("sourceField")) { + parts.add(sourcedFromNestedPathIdentifiers[segment.sourceField]); + } else { + parts.add(segment.get("field")); + } + } + return encodeKey(parts); + } + + // The `__versions` key: the relationship for top-level events, or relationship + element identifiers for nested ones. + String buildVersionsKey(String relationship, String nestedElementKey) { + if (nestedElementKey.isEmpty()) { + return relationship; + } + List parts = decodeKey(nestedElementKey); + parts.add(0, relationship); + return encodeKey(parts); + } + + // Finds the element of `elements` whose `id` equals `matchValue`, or null. + def findInList(List elements, String matchValue) { + for (Map element : elements) { + if (matchValue.equals(element.id)) { + return element; + } + } + return null; + } + + // Navigates `source` through `pathSegments` to the target nested element, or null if any hop is missing. + // `keyParts` has one entry per segment (aligned by index): a list element's matched id, or an object field name. + def navigateToNestedElement(Map source, List pathSegments, List keyParts) { + Map current = source; + + for (int i = 0; i < pathSegments.size(); i++) { + Map segment = (Map) pathSegments.get(i); + String field = (String) segment.get("field"); + + if (!current.containsKey(field)) { + return null; + } + + if (segment.containsKey("sourceField")) { + current = (Map) findInList((List) current.get(field), (String) keyParts.get(i)); + } else { + current = (Map) current.get(field); + } + + if (current == null) { + return null; + } + } + + return current; + } + + // --- Main Functions --- // + + void setup(Map source, String versionsKey, String relationship, String nestedElementKey, Map counts) { if (source.__sources == null) { source.__sources = []; } @@ -2171,8 +2304,17 @@ scripts: source.__versions = [:]; } - if (source.__versions[relationship] == null) { - source.__versions[relationship] = [:]; + if (source.__versions[versionsKey] == null) { + source.__versions[versionsKey] = [:]; + } + + if (!nestedElementKey.isEmpty()) { + if (source.__nested_sourced_data == null) { + source.__nested_sourced_data = [:]; + } + if (source.__nested_sourced_data[relationship] == null) { + source.__nested_sourced_data[relationship] = [:]; + } } if (counts != null && source.__counts == null) { @@ -2180,9 +2322,9 @@ scripts: } } - void validateSource(Map source, String id, String relationship, String sourceId, long eventVersion) { - Map relationshipVersionsMap = source.__versions.get(relationship); - List previousSourceIdsForRelationship = relationshipVersionsMap.keySet().stream().filter(key -> key != sourceId).collect(Collectors.toList()); + void validateSource(Map source, String id, String relationship, String sourceId, long eventVersion, String versionsKey) { + Map versionsMap = source.__versions[versionsKey]; + List previousSourceIdsForRelationship = versionsMap.keySet().stream().filter(key -> key != sourceId).collect(Collectors.toList()); if (previousSourceIdsForRelationship.size() > 0) { throw new IllegalArgumentException( @@ -2194,7 +2336,7 @@ scripts: ); } - Number maybeDocVersion = relationshipVersionsMap.get(sourceId); + Number maybeDocVersion = versionsMap.get(sourceId); // Our JSON schema requires event versions to be non-negative, so we can safely use Long.MIN_VALUE as a stand-in when the value is null. long docVersion = maybeDocVersion == null ? Long.MIN_VALUE : maybeDocVersion.longValue(); @@ -2216,8 +2358,43 @@ scripts: } } - void recordSource(Map source, String relationship, String sourceId, long eventVersion) { - source.__versions[relationship][sourceId] = eventVersion; + // Buffers nested sourced fields keyed by the target element, so they can be re-applied after any later self-event. + void storeNestedSourcedData(Map source, String relationship, Map sourcedFromNestedFields, String nestedElementKey) { + if (sourcedFromNestedFields.isEmpty()) { + return; + } + + ((Map) source.__nested_sourced_data[relationship]).put(nestedElementKey, sourcedFromNestedFields); + } + + // Re-applies all buffered nested sourced data to its target elements. Runs on every event so that a + // self-event's `putAll` (which overwrites nested arrays with fresh data) doesn't drop previously sourced fields. + void applyNestedSourcedData(Map source, Map sourcedFromNestedPaths) { + if (source.__nested_sourced_data == null) { + return; + } + + for (sourcedEntry in source.__nested_sourced_data.entrySet()) { + String relationship = (String) sourcedEntry.getKey(); + Map dataByKey = (Map) sourcedEntry.getValue(); + List pathSegments = (List) sourcedFromNestedPaths.get(relationship); + + if (pathSegments == null) { + continue; + } + + for (elementEntry in dataByKey.entrySet()) { + List keyParts = decodeKey((String) elementEntry.getKey()); + Map target = (Map) navigateToNestedElement(source, pathSegments, keyParts); + if (target != null) { + target.putAll((Map) elementEntry.getValue()); + } + } + } + } + + void recordSource(Map source, String versionsKey, String relationship, String sourceId, long eventVersion) { + source.__versions[versionsKey][sourceId] = eventVersion; // Record the relationship in `__sources` if it's not already there. We maintain it as an append-only set using a sorted list. // This ensures deterministic ordering of its elements regardless of event ingestion order, and lets us check membership in O(log N) time. @@ -2241,8 +2418,16 @@ scripts: String sourceId = params.sourceId; long eventVersion = (long) params.version; // Cast to long since JSON parses numbers as doubles Map counts = params.__counts; + Map sourcedFromNestedFields = params.sourcedFromNestedFields; + Map sourcedFromNestedPathIdentifiers = params.sourcedFromNestedPathIdentifiers; + Map sourcedFromNestedPaths = params.sourcedFromNestedPaths; + + String nestedElementKey = buildNestedElementKey(relationship, sourcedFromNestedPaths, sourcedFromNestedPathIdentifiers); + String versionsKey = buildVersionsKey(relationship, nestedElementKey); - setup(source, relationship, counts); - validateSource(source, id, relationship, sourceId, eventVersion); + setup(source, versionsKey, relationship, nestedElementKey, counts); + validateSource(source, id, relationship, sourceId, eventVersion, versionsKey); applyTopLevelFields(source, id, params.topLevelFields, counts); - recordSource(source, relationship, sourceId, eventVersion); + storeNestedSourcedData(source, relationship, sourcedFromNestedFields, nestedElementKey); + applyNestedSourcedData(source, sourcedFromNestedPaths); + recordSource(source, versionsKey, relationship, sourceId, eventVersion); diff --git a/config/schema/artifacts_with_apollo/runtime_metadata.yaml b/config/schema/artifacts_with_apollo/runtime_metadata.yaml index 8e7ff906c..d384783c0 100644 --- a/config/schema/artifacts_with_apollo/runtime_metadata.yaml +++ b/config/schema/artifacts_with_apollo/runtime_metadata.yaml @@ -3114,7 +3114,7 @@ object_types_by_name: cardinality: one relationship: __self routing_value_source: id - script_id: update_index_data_b9e2b105d736d8d16ae269ab6ff81e4d + script_id: update_index_data_f8fc933145913e9a98574ede2f3881ac top_level_fields_params: full_address: cardinality: one @@ -3304,7 +3304,7 @@ object_types_by_name: cardinality: one relationship: __self routing_value_source: id - script_id: update_index_data_b9e2b105d736d8d16ae269ab6ff81e4d + script_id: update_index_data_f8fc933145913e9a98574ede2f3881ac top_level_fields_params: __typename: cardinality: one @@ -3343,7 +3343,7 @@ object_types_by_name: cardinality: one relationship: __self routing_value_source: id - script_id: update_index_data_b9e2b105d736d8d16ae269ab6ff81e4d + script_id: update_index_data_f8fc933145913e9a98574ede2f3881ac top_level_fields_params: __typename: cardinality: one @@ -3464,7 +3464,7 @@ object_types_by_name: cardinality: one relationship: __self routing_value_source: id - script_id: update_index_data_b9e2b105d736d8d16ae269ab6ff81e4d + script_id: update_index_data_f8fc933145913e9a98574ede2f3881ac top_level_fields_params: created_at: cardinality: one @@ -3826,7 +3826,7 @@ object_types_by_name: cardinality: one relationship: __self routing_value_source: id - script_id: update_index_data_b9e2b105d736d8d16ae269ab6ff81e4d + script_id: update_index_data_f8fc933145913e9a98574ede2f3881ac top_level_fields_params: __typename: cardinality: one @@ -3999,7 +3999,7 @@ object_types_by_name: cardinality: one relationship: __self routing_value_source: id - script_id: update_index_data_b9e2b105d736d8d16ae269ab6ff81e4d + script_id: update_index_data_f8fc933145913e9a98574ede2f3881ac top_level_fields_params: created_at: cardinality: one @@ -4484,7 +4484,7 @@ object_types_by_name: cardinality: one relationship: __self routing_value_source: id - script_id: update_index_data_b9e2b105d736d8d16ae269ab6ff81e4d + script_id: update_index_data_f8fc933145913e9a98574ede2f3881ac top_level_fields_params: ceo: cardinality: one @@ -4642,7 +4642,7 @@ object_types_by_name: cardinality: one relationship: __self routing_value_source: id - script_id: update_index_data_b9e2b105d736d8d16ae269ab6ff81e4d + script_id: update_index_data_f8fc933145913e9a98574ede2f3881ac top_level_fields_params: created_at: cardinality: one @@ -5624,7 +5624,7 @@ object_types_by_name: cardinality: one relationship: __self routing_value_source: id - script_id: update_index_data_b9e2b105d736d8d16ae269ab6ff81e4d + script_id: update_index_data_f8fc933145913e9a98574ede2f3881ac top_level_fields_params: __typename: cardinality: one @@ -5823,7 +5823,7 @@ object_types_by_name: cardinality: one relationship: __self routing_value_source: id - script_id: update_index_data_b9e2b105d736d8d16ae269ab6ff81e4d + script_id: update_index_data_f8fc933145913e9a98574ede2f3881ac top_level_fields_params: __typename: cardinality: one @@ -5890,7 +5890,7 @@ object_types_by_name: cardinality: one relationship: __self routing_value_source: id - script_id: update_index_data_b9e2b105d736d8d16ae269ab6ff81e4d + script_id: update_index_data_f8fc933145913e9a98574ede2f3881ac top_level_fields_params: active: cardinality: one @@ -6433,7 +6433,7 @@ object_types_by_name: cardinality: one relationship: __self routing_value_source: id - script_id: update_index_data_b9e2b105d736d8d16ae269ab6ff81e4d + script_id: update_index_data_f8fc933145913e9a98574ede2f3881ac top_level_fields_params: name: cardinality: one @@ -6783,7 +6783,7 @@ object_types_by_name: relationship: __self rollover_timestamp_value_source: formed_on routing_value_source: league - script_id: update_index_data_b9e2b105d736d8d16ae269ab6ff81e4d + script_id: update_index_data_f8fc933145913e9a98574ede2f3881ac top_level_fields_params: country_code: cardinality: one @@ -7883,7 +7883,7 @@ object_types_by_name: relationship: __self rollover_timestamp_value_source: created_at routing_value_source: workspace_id2 - script_id: update_index_data_b9e2b105d736d8d16ae269ab6ff81e4d + script_id: update_index_data_f8fc933145913e9a98574ede2f3881ac top_level_fields_params: amount_cents: cardinality: one @@ -7961,7 +7961,7 @@ object_types_by_name: version: cardinality: one relationship: widget - script_id: update_index_data_b9e2b105d736d8d16ae269ab6ff81e4d + script_id: update_index_data_f8fc933145913e9a98574ede2f3881ac top_level_fields_params: widget_cost: cardinality: one @@ -8191,7 +8191,7 @@ object_types_by_name: relationship: __self rollover_timestamp_value_source: introduced_on routing_value_source: primary_continent - script_id: update_index_data_b9e2b105d736d8d16ae269ab6ff81e4d + script_id: update_index_data_f8fc933145913e9a98574ede2f3881ac top_level_fields_params: details: cardinality: one @@ -9236,7 +9236,7 @@ object_types_by_name: cardinality: one relationship: __self routing_value_source: id - script_id: update_index_data_b9e2b105d736d8d16ae269ab6ff81e4d + script_id: update_index_data_f8fc933145913e9a98574ede2f3881ac top_level_fields_params: name: cardinality: one @@ -9258,7 +9258,7 @@ object_types_by_name: relationship: workspace rollover_timestamp_value_source: widget.created_at routing_value_source: id - script_id: update_index_data_b9e2b105d736d8d16ae269ab6ff81e4d + script_id: update_index_data_f8fc933145913e9a98574ede2f3881ac top_level_fields_params: workspace_name: cardinality: one @@ -9536,4 +9536,4 @@ static_script_ids_by_scoped_name: field/as_day_of_week: field_as_day_of_week_f2b5c7d9e8f75bf2457b52412bfb6537 field/as_time_of_day: field_as_time_of_day_ed82aba44fc66bff5635bec4305c1c66 filter/by_time_of_day: filter_by_time_of_day_ea12d0561b24961789ab68ed38435612 - update/index_data: update_index_data_b9e2b105d736d8d16ae269ab6ff81e4d + update/index_data: update_index_data_f8fc933145913e9a98574ede2f3881ac diff --git a/elasticgraph-admin/spec/integration/elastic_graph/admin/index_definition_configurator/shared_examples.rb b/elasticgraph-admin/spec/integration/elastic_graph/admin/index_definition_configurator/shared_examples.rb index 639e39234..a68cc4d7a 100644 --- a/elasticgraph-admin/spec/integration/elastic_graph/admin/index_definition_configurator/shared_examples.rb +++ b/elasticgraph-admin/spec/integration/elastic_graph/admin/index_definition_configurator/shared_examples.rb @@ -42,7 +42,7 @@ def simulate_presence_of_extra_setting(admin, index_definition_name, name, value let(:output_io) { StringIO.new } let(:clock) { class_double(::Time, now: ::Time.utc(2024, 3, 20, 12, 0, 0)) } let(:mapping_removal_note_snippet) { "extra fields listed here will not actually get removed" } - let(:index_meta_fields) { ["__sources", "__typename", "__versions"] } + let(:index_meta_fields) { ["__nested_sourced_data", "__sources", "__typename", "__versions"] } it "idempotently creates an index or index template, avoiding unneeded datastore write calls" do expect { diff --git a/elasticgraph-indexer/lib/elastic_graph/indexer/operation/update.rb b/elasticgraph-indexer/lib/elastic_graph/indexer/operation/update.rb index 32e04c04a..839597070 100644 --- a/elasticgraph-indexer/lib/elastic_graph/indexer/operation/update.rb +++ b/elasticgraph-indexer/lib/elastic_graph/indexer/operation/update.rb @@ -145,11 +145,13 @@ def script_params prepared_record: prepared_record ) - # The normal indexing script uses `__counts`. Other indexing scripts (e.g. the ones generated - # for derived indexing) do not use `__counts` so there's no point in spending effort on computing - # it. Plus, the logic below raises an exception in that case, so it's important we avoid it. + # `__counts` and `sourcedFromNestedPaths` are only used by the normal indexing script. Other + # scripts (e.g. derived indexing) don't use them, and the `__counts` logic below raises if applied + # to them, so we bail out early. return initial_params unless update_target.for_normal_indexing? + initial_params["sourcedFromNestedPaths"] = destination_index_def.sourced_from_nested_paths_as_painless_param + CountAccumulator.merge_list_counts_into( initial_params, mapping: destination_index_mapping, diff --git a/elasticgraph-indexer/spec/unit/elastic_graph/indexer/operation/update_spec.rb b/elasticgraph-indexer/spec/unit/elastic_graph/indexer/operation/update_spec.rb index 7b32bde9f..9e9eeb3d5 100644 --- a/elasticgraph-indexer/spec/unit/elastic_graph/indexer/operation/update_spec.rb +++ b/elasticgraph-indexer/spec/unit/elastic_graph/indexer/operation/update_spec.rb @@ -108,6 +108,7 @@ module Operation "id" => "17", "sourcedFromNestedFields" => {}, "sourcedFromNestedPathIdentifiers" => {}, + "sourcedFromNestedPaths" => {}, "staticValue" => 47, "sourceType" => "Widget", LIST_COUNTS_FIELD => {"sizes" => 0, "widget_names" => 0} diff --git a/elasticgraph-schema_definition/lib/elastic_graph/schema_definition/indexing/index.rb b/elasticgraph-schema_definition/lib/elastic_graph/schema_definition/indexing/index.rb index 061f6e5a1..0fceb226c 100644 --- a/elasticgraph-schema_definition/lib/elastic_graph/schema_definition/indexing/index.rb +++ b/elasticgraph-schema_definition/lib/elastic_graph/schema_definition/indexing/index.rb @@ -320,6 +320,10 @@ def mappings .then { |mapping| ListCountsMapping.merged_into(mapping, for_type: indexed_type) } .then do |fm| internal_fields = { + # The update script buffers nested `sourced_from` data here keyed by relationship and composite + # element key. Like `__versions`, these keys aren't statically known, so we keep it in `_source` + # (the script reads it across events) but unsearchable via `dynamic: false`. + "__nested_sourced_data" => {"type" => "object", "dynamic" => "false"}, "__sources" => {"type" => "keyword"}, "__versions" => { "type" => "object", diff --git a/elasticgraph-schema_definition/lib/elastic_graph/schema_definition/scripting/scripts/update/index_data.painless b/elasticgraph-schema_definition/lib/elastic_graph/schema_definition/scripting/scripts/update/index_data.painless index b618f13fa..b99baa9da 100644 --- a/elasticgraph-schema_definition/lib/elastic_graph/schema_definition/scripting/scripts/update/index_data.painless +++ b/elasticgraph-schema_definition/lib/elastic_graph/schema_definition/scripting/scripts/update/index_data.painless @@ -1,5 +1,99 @@ // --- Helper Functions --- // -void setup(Map source, String relationship, Map counts) { + +// Encodes a list of strings into an unambiguous, length-prefixed string ("len:value" parts concatenated). +String encodeKey(List parts) { + StringBuilder sb = new StringBuilder(); + for (String part : parts) { + sb.append(part.length()); + sb.append(':'); + sb.append(part); + } + return sb.toString(); +} + +// Inverse of `encodeKey`. +List decodeKey(String key) { + List parts = new ArrayList(); + int i = 0; + while (i < key.length()) { + int colonPos = key.indexOf(":", i); + int length = Integer.parseInt(key.substring(i, colonPos)); + int valueStart = colonPos + 1; + parts.add(key.substring(valueStart, valueStart + length)); + i = valueStart + length; + } + return parts; +} + +// The encoded path to the nested element this event targets, or "" for a top-level event (no path). +// List segments contribute their matched id (the value at `sourceField`); object segments contribute their +// field name. Every segment contributes one part, so a path with any segments yields a non-empty key. +String buildNestedElementKey(String relationship, Map sourcedFromNestedPaths, Map sourcedFromNestedPathIdentifiers) { + List pathSegments = (List) sourcedFromNestedPaths.get(relationship); + if (pathSegments == null) { + return ""; + } + List parts = new ArrayList(); + for (Map segment : pathSegments) { + if (segment.containsKey("sourceField")) { + parts.add(sourcedFromNestedPathIdentifiers[segment.sourceField]); + } else { + parts.add(segment.get("field")); + } + } + return encodeKey(parts); +} + +// The `__versions` key: the relationship for top-level events, or relationship + element identifiers for nested ones. +String buildVersionsKey(String relationship, String nestedElementKey) { + if (nestedElementKey.isEmpty()) { + return relationship; + } + List parts = decodeKey(nestedElementKey); + parts.add(0, relationship); + return encodeKey(parts); +} + +// Finds the element of `elements` whose `id` equals `matchValue`, or null. +def findInList(List elements, String matchValue) { + for (Map element : elements) { + if (matchValue.equals(element.id)) { + return element; + } + } + return null; +} + +// Navigates `source` through `pathSegments` to the target nested element, or null if any hop is missing. +// `keyParts` has one entry per segment (aligned by index): a list element's matched id, or an object field name. +def navigateToNestedElement(Map source, List pathSegments, List keyParts) { + Map current = source; + + for (int i = 0; i < pathSegments.size(); i++) { + Map segment = (Map) pathSegments.get(i); + String field = (String) segment.get("field"); + + if (!current.containsKey(field)) { + return null; + } + + if (segment.containsKey("sourceField")) { + current = (Map) findInList((List) current.get(field), (String) keyParts.get(i)); + } else { + current = (Map) current.get(field); + } + + if (current == null) { + return null; + } + } + + return current; +} + +// --- Main Functions --- // + +void setup(Map source, String versionsKey, String relationship, String nestedElementKey, Map counts) { if (source.__sources == null) { source.__sources = []; } @@ -8,8 +102,17 @@ void setup(Map source, String relationship, Map counts) { source.__versions = [:]; } - if (source.__versions[relationship] == null) { - source.__versions[relationship] = [:]; + if (source.__versions[versionsKey] == null) { + source.__versions[versionsKey] = [:]; + } + + if (!nestedElementKey.isEmpty()) { + if (source.__nested_sourced_data == null) { + source.__nested_sourced_data = [:]; + } + if (source.__nested_sourced_data[relationship] == null) { + source.__nested_sourced_data[relationship] = [:]; + } } if (counts != null && source.__counts == null) { @@ -17,9 +120,9 @@ void setup(Map source, String relationship, Map counts) { } } -void validateSource(Map source, String id, String relationship, String sourceId, long eventVersion) { - Map relationshipVersionsMap = source.__versions.get(relationship); - List previousSourceIdsForRelationship = relationshipVersionsMap.keySet().stream().filter(key -> key != sourceId).collect(Collectors.toList()); +void validateSource(Map source, String id, String relationship, String sourceId, long eventVersion, String versionsKey) { + Map versionsMap = source.__versions[versionsKey]; + List previousSourceIdsForRelationship = versionsMap.keySet().stream().filter(key -> key != sourceId).collect(Collectors.toList()); if (previousSourceIdsForRelationship.size() > 0) { throw new IllegalArgumentException( @@ -31,7 +134,7 @@ void validateSource(Map source, String id, String relationship, String sourceId, ); } - Number maybeDocVersion = relationshipVersionsMap.get(sourceId); + Number maybeDocVersion = versionsMap.get(sourceId); // Our JSON schema requires event versions to be non-negative, so we can safely use Long.MIN_VALUE as a stand-in when the value is null. long docVersion = maybeDocVersion == null ? Long.MIN_VALUE : maybeDocVersion.longValue(); @@ -53,8 +156,43 @@ void applyTopLevelFields(Map source, String id, Map topLevelFields, Map counts) } } -void recordSource(Map source, String relationship, String sourceId, long eventVersion) { - source.__versions[relationship][sourceId] = eventVersion; +// Buffers nested sourced fields keyed by the target element, so they can be re-applied after any later self-event. +void storeNestedSourcedData(Map source, String relationship, Map sourcedFromNestedFields, String nestedElementKey) { + if (sourcedFromNestedFields.isEmpty()) { + return; + } + + ((Map) source.__nested_sourced_data[relationship]).put(nestedElementKey, sourcedFromNestedFields); +} + +// Re-applies all buffered nested sourced data to its target elements. Runs on every event so that a +// self-event's `putAll` (which overwrites nested arrays with fresh data) doesn't drop previously sourced fields. +void applyNestedSourcedData(Map source, Map sourcedFromNestedPaths) { + if (source.__nested_sourced_data == null) { + return; + } + + for (sourcedEntry in source.__nested_sourced_data.entrySet()) { + String relationship = (String) sourcedEntry.getKey(); + Map dataByKey = (Map) sourcedEntry.getValue(); + List pathSegments = (List) sourcedFromNestedPaths.get(relationship); + + if (pathSegments == null) { + continue; + } + + for (elementEntry in dataByKey.entrySet()) { + List keyParts = decodeKey((String) elementEntry.getKey()); + Map target = (Map) navigateToNestedElement(source, pathSegments, keyParts); + if (target != null) { + target.putAll((Map) elementEntry.getValue()); + } + } + } +} + +void recordSource(Map source, String versionsKey, String relationship, String sourceId, long eventVersion) { + source.__versions[versionsKey][sourceId] = eventVersion; // Record the relationship in `__sources` if it's not already there. We maintain it as an append-only set using a sorted list. // This ensures deterministic ordering of its elements regardless of event ingestion order, and lets us check membership in O(log N) time. @@ -78,8 +216,16 @@ String relationship = params.relationship; String sourceId = params.sourceId; long eventVersion = (long) params.version; // Cast to long since JSON parses numbers as doubles Map counts = params.__counts; +Map sourcedFromNestedFields = params.sourcedFromNestedFields; +Map sourcedFromNestedPathIdentifiers = params.sourcedFromNestedPathIdentifiers; +Map sourcedFromNestedPaths = params.sourcedFromNestedPaths; + +String nestedElementKey = buildNestedElementKey(relationship, sourcedFromNestedPaths, sourcedFromNestedPathIdentifiers); +String versionsKey = buildVersionsKey(relationship, nestedElementKey); -setup(source, relationship, counts); -validateSource(source, id, relationship, sourceId, eventVersion); +setup(source, versionsKey, relationship, nestedElementKey, counts); +validateSource(source, id, relationship, sourceId, eventVersion, versionsKey); applyTopLevelFields(source, id, params.topLevelFields, counts); -recordSource(source, relationship, sourceId, eventVersion); +storeNestedSourcedData(source, relationship, sourcedFromNestedFields, nestedElementKey); +applyNestedSourcedData(source, sourcedFromNestedPaths); +recordSource(source, versionsKey, relationship, sourceId, eventVersion); diff --git a/elasticgraph-schema_definition/spec/unit/elastic_graph/schema_definition/datastore_config/index_mappings/miscellaneous_spec.rb b/elasticgraph-schema_definition/spec/unit/elastic_graph/schema_definition/datastore_config/index_mappings/miscellaneous_spec.rb index 0e0922eb6..7d9523044 100644 --- a/elasticgraph-schema_definition/spec/unit/elastic_graph/schema_definition/datastore_config/index_mappings/miscellaneous_spec.rb +++ b/elasticgraph-schema_definition/spec/unit/elastic_graph/schema_definition/datastore_config/index_mappings/miscellaneous_spec.rb @@ -347,7 +347,7 @@ module SchemaDefinition mapping = generate_mapping.call(graphql_only: true) # Verify that it does not have a property for `size` or `options.size` - expect(mapping.fetch("properties").keys).to contain_exactly("id", "options", "__sources", "__versions", "__typename") + expect(mapping.fetch("properties").keys).to contain_exactly("id", "options", "__nested_sourced_data", "__sources", "__versions", "__typename") expect(mapping.fetch("properties")).to include({ "id" => {"type" => "keyword"}, "options" => { diff --git a/elasticgraph-support/lib/elastic_graph/constants.rb b/elasticgraph-support/lib/elastic_graph/constants.rb index f0449d88c..e95e9a13d 100644 --- a/elasticgraph-support/lib/elastic_graph/constants.rb +++ b/elasticgraph-support/lib/elastic_graph/constants.rb @@ -140,7 +140,7 @@ module ElasticGraph # # Note: this constant is automatically kept up-to-date by our `schema_artifacts:dump` rake task. # @private - INDEX_DATA_UPDATE_SCRIPT_ID = "update_index_data_b9e2b105d736d8d16ae269ab6ff81e4d" + INDEX_DATA_UPDATE_SCRIPT_ID = "update_index_data_f8fc933145913e9a98574ede2f3881ac" # When an update script has a no-op result we often want to communicate more information about # why it was a no-op back to ElatsicGraph from the script. The only way to do that is to throw