fix(21.8 Part F.1): an ALTER must not rewrite ingest processors it was not asked to touch - #316
Merged
Conversation
…sked to touch The issue record named one cause. Building the gate it asked for -- "no test asserts the pipeline diff is empty for an unchanged processor" -- found three, each reachable from a different processor kind, so no single statement could exhibit more than one. 1. An anonymous script processor has no column. Processor `description` is an ES 7.9+ field that 6.8 drops (and PipelineApi strips before sending on 6.x), and it is what re-types a script processor on read-back, so keyed by column it could never match the processor that DECLARED it: every ALTER reported it removed and re-added. The identity was in the script all along -- ScriptProcessor writes `ctx.<column> = ...` as the last statement of every source it generates -- so ScriptTarget.of reads back what ScriptTarget.assign wrote, in IngestPipeline.diff's KEY, on both sides. 2. A stored non-textual value was read back as a STRING, so `reputation DOUBLE DEFAULT 0.0` reported ProcessorPropertyChanged(value, 0.0, 0.0) forever -- a diff that prints as no diff. It never depended on `description`, so it was never confined to 6.8. readValue is the typed read IndexField already uses for null_value; Value.unwrap is its inverse on the write side, because Values.value holds WRAPPED elements that serialise as Jackson beans. 3. loadTablePipelineDiff compared what it had read back under the FINAL name against table.defaultPipeline, producing default-pipeline changes drawn from the wrong pipeline. Table.declaredPipeline is exhaustive. Container-valued SET processors are fixed with it: a list or object was lost to the empty string on read-back and is now round-tripped. Proof, not inference: GatewayApiIntegrationSpec requires DdlResult(false) on a repeated no-op ALTER, with DdlResult(true) on the first as a positive control. Reverting the sql fixes reds it on real ES 6.8.23 with the record's own churn line. assertDdl could never have caught this -- a churning ALTER succeeds. Nine mutations, nine predicted REDs. Green on all five clients; extensions verified against a local publish, materialized views green on ES 6/7/8/9. Story: _bmad-output/implementation-artifacts/21-8-temporal-and-boolean-conversion-defects.md Record: docs/issues/local-21.8-alter-churns-unchanged-pipeline-processor.md Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #317
Story 21.8 Part F.1, the last open part of the story. Base
origin/main011bc912.What was wrong
Every ALTER re-created ingest processors nobody had touched. The local record named one cause; building the gate it asked for — "no test asserts the pipeline diff is empty for an unchanged processor" — found three. Each is reachable from a different processor kind, so no single statement could ever exhibit more than one, which is why one example had only ever shown one.
DEFAULTreports changed forever(2) should change how this class is hunted. It never depended on
description, so it was never confined to 6.8 — and the warning it produced wasProcessorPropertyChanged(value, 0.0, 0.0), a diff that prints as no diff. It survived every version run because it was unreadable, not because it was rare.The fix
ScriptTarget— one object owning the ingest assignment format.assignwritesctx.<column> = …,ofreads it back,referenceis shared withColumn.update, which spelledctx.<name>a third way.IngestPipeline.diff's KEY resolves a script processor's target through it, on both sides. The identity was in the script all along;descriptionwas never its only carrier.IngestProcessor.readValue— the typed readIndexFieldalready uses fornull_value, withValue.unwrapas its inverse on the write side.Table.declaredPipeline(pipelineType)— exhaustive;GatewayApi.loadTablePipelineDiffcalls it instead of always readingdefaultPipeline.Container-valued
SETprocessors are fixed with it: a list or object was lost to the empty string on read-back and now round-trips.Why the recovery lives in the diff key and not in
columnThe first attempt put it in
GenericProcessor.column, which is read byTable.defaultPipeline'sfilterNot,IngestPipeline.merge,ProcessorRemoved.stmtanddescribe. Measured: a hand-addedADD PROCESSOR SCRIPT(ctx.age = 1)then collided with a declaredage SCRIPT AS (…)column, was filtered out of the desired pipeline, and the next ALTER emitted aDROPfor it — data loss, to fix a cosmetic diff.Confined to the key it reaches the only consumer that needs it, and because both sides derive the identity the same way from the same string, a source
ofmisreads yields the same key on both sides and so cannot manufacture a difference.Why
Value.unwrapis needed alongside the typed readValues.valueandObjectValue.valuehold wrapped elements, andpropertiesfeeds them tomapToJsonNode. Measured: a list read back and written out again became"value":[{"value":"a","regex":{…},"expr":{…}}]— andALTER PIPELINErewrites every processor of the merged pipeline, so an untouched processor would be written back to the cluster in that shape.unwraprecurses, because one level left a nested array still serialising as beans.A list whose head is
Nullhas no arm in the sealedValueshierarchy and madeValue.applythrow inside an uncaught path, so it keeps the old text read rather than killing the ALTER.Proof
GatewayApiIntegrationSpecgained "report NO changes when an ALTER leaves every processor untouched": re-issue an ALTER that changes nothing and requireDdlResult(false), which the engine emits only for an empty diff, withDdlResult(true)on the first call as a positive control. Reverting thesqlfixes reds it on real Elasticsearch 6.8.23 with the record's own line, two causes at once:assertDdlcould never have caught this: a churning ALTER succeeds.Verification
sql1029 ·core944 · bridge 197 · es6bridge 197 ·macrosTests21. Rootsbt test: es6 rest 400, es6 jest 385, es7 403, es8 419, es9 413 — 0 failed, the new gate on all five clients, and zeroProcessorRemoved(SCRIPT(and zeroProcessorChanged(across the whole run. Scala 2.12 leg compiles; scalafmt and headerCheck clean.Nine mutations, nine predicted REDs, control green, sources byte-identical after restore.
Extensions verified against a local publish: materialized views green on ES 6/7/8/9 (
ExtensionsIntegrationSpecandMaterializedViewBasicLicenseSpecper major, plus generator/metadata/quota/watcher/if-exists). Full extensions suite exits 0.Unrecognized VM option 'UseAVX=2'). Single-variable control,es6jest/teston each tree: pristineorigin/main384 passed / 3 aborted, this branch 385 passed / 3 aborted. Same three, same cause, both trees.Release notes
DEFAULT(every version) had its default pipeline re-created on every ALTER, with the standing "Default Pipeline may be used by multiple indexes" warning each time.SETprocessor's stored value keeps its JSON type when read back, containers included, soSHOW TABLEandDESCRIBEreport a list, object, number or boolean as itself rather than as a string or"".Recorded, not fixed
IngestPipeline(name, json, …)stamps the pipeline's type onto its processors, the case-class constructor does not, so a CUSTOM pipeline's two sides disagree. Unreached by shipped code (PipelineApimerges custom pipelines). Pinned on the cause, not on the size of the diff.Index -> Tableload attaches a final pipeline'sScriptProcessorto its column. Needs the load path, not the diff.toMapdrops one). Not new on ES 7+, whereScriptDescRegexhas always keyed them that way; this aligns 6.8 with the rest.ScriptProcessor.fromScriptsplits on;with no string-literal awareness, and its multi-statement branch never strips a trailingreturn. Both pre-existing.alterPipeline, auditalterTable) not done — reclassified by the spec as a separate concern; bothstart.? ~ … ~ end.?sites remain.Review
Three independent fresh-context layers. They found that the first attempt would have dropped a hand-added script processor, and that it had silently disarmed story 21.5's gate —
AlterPipelineRenderSpec'sctx.a = 1fixture resolved toa, so the content-addressed fallback that story shipped was never exercised and its falsification would now score green. Both fixed; the fixtures now use a form the recovery declines, and the file asserts it still reaches the fallback.🤖 Generated with Claude Code