Skip to content

Add explicit Elasticsearch index compatibility upgrades - #307

Open
niemyjski wants to merge 88 commits into
mainfrom
feature/elasticsearch-index-compatibility-upgrades
Open

niemyjski wants to merge 88 commits into
mainfrom
feature/elasticsearch-index-compatibility-upgrades

Conversation

@niemyjski

@niemyjski niemyjski commented Aug 3, 2026 •

Copy link
Copy Markdown
Member

Adds operator-invoked Elasticsearch index-format compatibility inspection and upgrades, independently of schema versioning. Normal configuration performs no compatibility requests. This is offline maintenance: stop writers and alias managers, verify a restorable snapshot, and reconcile uncertain cutovers before resuming writes.

The upgrade validates configured ownership and reservations, copies a marked source into a deterministic compatibility target, checks task/count/shard/settings evidence, transfers aliases, and removes the old physical index. Recovery only finishes positively identified committed cutovers; ambiguous, unmarked, foreign or contradictory evidence requires manual reconciliation. Foundatio-owned index readiness does not certify system indexes or a cluster-wide upgrade.

This review fixes a deletion hole: a wrapped compatibility name now receives the existing sibling-ownership check before deletion. It also disables ping in the multi-node submission fixture so the fixture tests the intended POST request.

Validation at 32ddd603: both ownership regressions failed against the old runtime, then all 3 focused and 254 synthetic compatibility tests passed. Release test-project builds had zero warnings/errors. No Elasticsearch service or persistent 7→8→9 chain was run during this review.

Remaining gates:

  • Source-fence cleanup after pre-dispatch failures and handling of definitive submission rejections need decisions and regression coverage.
  • Final-head ordinary CI, full Elasticsearch 8/9 suites, persistent sequential-major/snapshot rehearsal, formatting and the intended combined release tree still require verification.
  • Resolve fix(reindex): stop a reindex from silently dropping documents #327's normal-schema migration safety first, then deliberately reconcile shared changes; fix: support external-index id opt-out and safe cursor paging (#305) #308 remains independently reviewable. Main had two newer commits at review time; no merge/rebase or retarget was performed.
  • The earlier ES7→ES8 persistent rehearsal did not execute its ES9 leg because an unrelated system index blocked startup. Historical results do not replace final-head release evidence.

Existing audit fixes remain: strict wire task counters and timeout handling, authenticated error-index lineage, positive termination evidence for cleanup, original cancellation preservation, destination reservations and offline-operation guidance.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 76b9eaff66

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@niemyjski niemyjski self-assigned this Aug 4, 2026
@niemyjski
niemyjski force-pushed the feature/elasticsearch-index-compatibility-upgrades branch from 76b9eaf to 4226b37 Compare August 5, 2026 21:30

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4226b374e5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fd97a54e66

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a664a60d98

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 92a9507c4a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ba6c9bf746

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread .github/workflows/build.yml Fixed

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3fcfe71466

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Explain registered destination reservations and callback cancellation behavior. Correct nonexistent force-configuration and positional progress-callback examples, distinguish best-effort schema resume from compatibility recovery, and avoid a timeless preview label for the server API.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

Document registered destination reservations and the actual progress-callback contract. Fix progress-callback call syntax and remove the nonexistent force configuration option. Separate stable alias routing and best-effort schema catch-up from the offline compatibility protocol, including task lifetime, interrupted copies, retained sources, and disk headroom.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

Remove an unrelated constructor statement reorder from the compatibility reservation edits. Keep the review diff focused on destination validation.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

@niemyjski niemyjski left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review follow-up completed

Pushed 11 commits, merged current main without rewriting history, repaired the baseline build, fixed the registered-destination P1, added 20 regression cases, and corrected the API/public/agent documentation. The destination-collision thread now has implementation and CI evidence and is resolved; a fresh thread listing shows no unresolved threads. The PR description separates current evidence from historical validation and records the #308/#327 integration risks.

Verified: Release solution build on 97a335f with 0 warnings/errors; libraries target .NET 8 and .NET 10; .NET 10 test run reports 1,107 passed / 0 failed / 1 skipped in run 35744890739. All 20 new destination cases are included. Main a61d541 is an ancestor of the head, with 0 commits behind.

Final head: 13f069f has passed its build step in run 35748460623; its test step is still running at this review. Do not substitute the preceding green run for a final-head result.

Release validation still required: the skipped test is the deliberately gated persistent Elasticsearch 7→8→9 sequence. This follow-up did not execute that chain, snapshot restoration, packaged external consumers, or combined current #307/#308/#327 tips. Re-run the persistent sequence and restoration on the intended release candidate, and revalidate any later combined-PR conflict resolution. Normal schema migration remains distinct from this offline compatibility protocol; the docs no longer promise guaranteed crash recovery or lossless concurrent catch-up.

This review does not approve, merge, publish, or certify a production deployment. The code fixes and docs are committed; final CI and the explicit release rehearsal remain gates.

Copy link
Copy Markdown
Member Author

Final-audit findings — validation in progress

The previously pending final-head CI on 13f069f60af1b406ee4dafa4874c1793b5caafba is now green. main still resolves to a61d541613528428cc00bc2236602db72948a924, which is already included in this branch; there is currently no additional base commit to merge.

The deeper audit found additional issues, so the old green build is not final production approval:

  1. The shared task-response reader ignores timed_out, coerces malformed dictionary counters, and lets application serializer naming policy affect wire counters. A candidate now reads validated nonnegative integral counters directly and refuses timed-out copies before compatibility cutover or normal-schema alias promotion.
  2. IsPotentialCompatibilityErrorName strips a natural reindexed-v… prefix before recognizing the configured native name. This can omit the persistent error-index provenance check during recovery. The candidate checks the native name first and adds public inspection regressions for plain, versioned, daily, monthly, and literal reindexed naming.
  3. Cancellation treats task HTTP 404 as proof of termination. Elasticsearch's task lookup can fall back to stored results when the owning node is unreachable, so missing results are not positive proof that copying stopped. The candidate keeps this outcome uncertain and requires a successful completed:true task response before cleanup. Original cancellation remains distinguishable even when inspection/reset also fails; an independent cleanup timeout must not invent caller cancellation.

Candidates and regression probes are isolated on audit/pr-307-final-validation-20260922; they have not yet been promoted to this PR. Audit run 35768612415 includes red/green regression validation, full suites on Elasticsearch 8.19.15 and 9.5.0, external packaged .NET 8/.NET 10 consumers, and a persistent 7.17.29→8.19.15→9.5.0 chain with snapshot restores and alias read/write checks. These jobs are queued, not passed. An earlier audit-only fixture compile error was corrected, and the harness now propagates piped build/test exit codes explicitly.

For release integration, prefer landing #327's normal-schema data-loss fixes first, then reconcile #307 against that updated base. A temporary #327→#307 review stack is useful while both remain open, but merely changing the PR base is not a semantic merge or combined test. #308 should remain independently reviewable unless it gains a real dependency; its shared mapping-discovery conflict still needs an explicit resolution and fresh combined validation. No PR base was retargeted, no PR was merged, and publishing behavior on this PR is unchanged.

Copy link
Copy Markdown
Member Author

Final-audit evidence update

The new candidate now has actual regression proof from Windows run 35770100836:

  • Original source plus new tests: Release build 0 warnings/errors; 37 failed, 24 passed, 0 skipped out of 61 new cases.
  • Candidate source: Release build 0 warnings/errors; 61/61 new cases passed, no skips; 152/152 core tests passed, no skips.
  • Both local NuGet packages were created. External-consumer restore hit an audit-harness Windows source-URL parsing problem; the harness now uses NuGet.Config. The prior formatting command also reported optional sibling-project workspace failures despite exit 0, so that is not accepted as clean formatting evidence; the new gate uses standalone folder-mode whitespace verification.

The persistent rehearsal in run 35770775846 built the same candidate cleanly, preserved a real ES7 data volume through ES8, ran the explicit compatibility chain test on ES8 (1 passed, 0 skipped), and completed snapshot restoration at ES7/ES8 and alias read/write checks at ES8. The ES9 leg did not run. The node's captured fatal error identifies an unrelated ES7-created .geoip_databases system index as incompatible; it does not identify the successfully upgraded Foundatio index.

This is concrete evidence for the documented boundary: a Foundatio-owned index preflight is not cluster-wide upgrade certification. System-index readiness still belongs to Elastic's Upgrade Assistant/tooling. Artifact 10714821867 contains the exact fatal startup log (chain-final-server.log) and per-phase results. The replacement isolated rehearsal disables the optional GeoIP downloader from initial disposable-cluster provisioning, rather than deleting or modifying production/system data to hide this result. No user cluster is involved.

The fixes still need clean-source promotion and completed integration/package/chain gates. Do not merge the temporary audit branch or treat the failed/queued jobs as passed. The original #307 source head remains 13f069f6 until a reviewed clean candidate is promoted.

@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you all sign our Contributor License Agreement before we can accept your contribution.
1 out of 2 committers have signed the CLA.

✅ niemyjski
❌ github-actions[bot]
You have signed the CLA already but the status is still pending? Let us recheck it.

@niemyjski niemyjski left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Final-audit fixes are now on this PR in four clean commits: 54856100, 8915594e, 1d76e627, and 9c681f8c. The promoted diff contains only source, regression tests, and documentation; no audit workflow or publishing changes.

Verified evidence for this exact candidate: Release solution builds for net8.0/net10.0 with zero warnings/errors; 37 of 61 new regression cases fail on the original source, all 61 pass with the fixes; all 152 core tests pass; actual local NuGet consumers build with nullable warnings treated as errors and execute on .NET 8 and .NET 10. See run 35771563588 and its candidate patch/bundle artifact. The red/green/core results were also observed independently on Windows.

The corrected paths cover discarded task timeout evidence and coerced/lost counters, natural-prefix error-index provenance, task-404 cleanup authority, and original cancellation when secondary recovery fails. Existing positive task-termination fixtures now require completed:true instead of 404.

Approval remains withheld pending the recorded final-head CI, full Elasticsearch 8/9 suites, clean standalone formatting, and complete persistent-chain rehearsal. The first chain successfully migrated the real ES7-created Foundatio index on ES8 and restored snapshots, then ES9 refused an unrelated 7-created .geoip_databases system index. This is retained as evidence that cluster-wide readiness still requires Elastic's tooling; the isolated repeat disables the optional downloader from initial disposable provisioning, not on a user cluster.

Fresh base comparison: main a61d541 is already included, zero commits behind. For the combined release, prefer landing #327 first and then reconciling #307 against main. A temporary #327→#307 stack is useful for parallel review, not mandatory; retargeting alone does not resolve shared reindex semantics. Keep #308 independently reviewable unless a true dependency is introduced, and test its mapping-discovery reconciliation explicitly. No related PR was merged or retargeted.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9c681f8ce1

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +485 to +486
string canonicalAlias = canonicalName.ToString();
if (!aliases.HasCanonicalCompatibilityAlias(canonicalAlias))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reject sibling-owned canonical aliases before deletion

When a registered VersionedIndex named events coexists with a registered index named events-v2, the sibling's compatibility-upgraded physical index can be reindexed-v9-events-v2 with canonical alias events-v2. Calling events.DeleteAsync() resolves that alias here, accepts the canonical marker, and deletes the sibling's physical index because sibling ownership is checked only in the unwrapped-name branch above. Fresh evidence beyond the previously fixed native-name collision is that this wrapped-name branch never calls IsNativelyClaimedByOtherConfiguredIndex(canonicalName); apply the same arbitration before accepting the canonical alias.

Useful? React with 👍 / 👎.

@niemyjski niemyjski left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deeper consistency review: task absence, writer ownership, and destructive-cutover durability

Reviewed #307 at 9c681f8ce11b10509aadd2af9dcbbdcaea33200d, #327 separately at 091b2ba0a6c24806568c42897c1078f0b00dae5c, and Foundatio's actual 13.0.4 dependency tag (d6d686d31be062ba0561eb210e40ad40d79c8fff). These are source-audit findings and failure schedules, not newly executed distributed fault-injection tests. This review does not approve production release.

Confirmed: the destructive cutover does not require restored redundancy

ElasticIndexCompatibilityUpgrader.CreateTargetAsync temporarily sets replicas to zero. RestoreTargetSettingsAsync restores the configured count, but WaitForTargetHealthAsync accepts yellow, with no initializing/relocating shards, before the atomic request deletes the source. Unassigned replicas satisfy that combination. A replacement with only primary copies can therefore become the sole live dataset even when the configured policy requires replicas.

Require target-scoped health and allocated copies consistent with the restored redundancy policy before source deletion. Green is useful only together with the intended replica configuration; green with zero configured replicas is not redundancy. Explicit zero-replica deployments need an explicit risk policy. Make the health/recovery deadline configurable: an unconditional 30-second wait is not a large-index recovery budget. Test a source with replicas, a target whose replicas cannot allocate, and assert no remove_index is dispatched.

Confirmed upstream dependency gap: renewal can return normally after ownership is lost

The repository references Foundatio 13.0.4. At that exact release, CacheLockProvider.RenewAsync returns the task from ReplaceIfEqualAsync(...).AsTask() as non-generic Task, without checking the Boolean outcome. A completed compare-and-replace returning false therefore does not report loss of ownership to callers. The upgrader awaits reindexLock.RenewAsync, so a non-throwing renewal is not evidence the lease remains owned.

Fix and test this in Foundatio, then integrate the dependency deliberately. Test: owner A acquires; its key expires; B acquires; A renews; renewal must report loss and A must not authorize cutover, reset, or block removal. Separately, renewal only on progress callbacks is insufficient protection against long awaits or a stalled callback. Lease renewal must be independent, and lost ownership must revoke mutation authority. A cache lease, or an epoch stored only in a manifest, does not itself fence an already submitted Elasticsearch request.

The 404 change is correct, but proves only one part of the protocol

Pinned Elasticsearch 9.5 task lookup falls back to stored results when the owner is absent or unreachable. Thus task 404 must remain unknown rather than authorize cleanup. Explicit completion means the observed task terminated; successful migration additionally requires the validated response, stable source/target, correct identities, and durable cutover.

The next fault test must cover duplicate/delayed asynchronous submissions. RunCompatibilityReindexAsync sets X-Opaque-Id but does not locally disable inherited transport retries. An opaque ID is correlation, not deduplication. Under a retrying client/proxy, a first submission can be accepted without a received response and a second can return the task ID being tracked. Completion of that one task does not prove all submissions or stale workers are harmless. This is a conditional risk, not a claim that every default client duplicates submissions. Audit client/resilience/proxy launch retries; retain attempt identity and task lineage; do not delete/reuse a target based only on one returned task ID or an empty listing.

Elasticsearch 9.5 adds a reindex-specific cancellation API that follows node-shutdown relocations and can return final state. Consider it through a tested version capability adapter, not as an unconditional replacement on ES8/earlier ES9. Its documented 404 is still ambiguous. Reference: https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-cancel-reindex

Recovery hardening and availability design

Current public Finish recovery uses names, static workflow markers, canonical alias presence, and task absence; it does not recover a durable record of the original index UUIDs and full expected alias manifest. Under the documented exclusion of other writers/managers, this is a narrower protocol than arbitrary concurrent recovery. For stronger unattended recovery, persist source/target UUIDs, attempt identity, expected alias/mapping/settings/transform fingerprints, task lineage, verified-copy evidence, and cutover intent. Use compare-and-set transitions, but do not mistake them for server-enforced fencing of aliases or bulk writes.

A potential safe failback is to quarantine the private destination with an acknowledged dedicated write block, retain it, and resume the intact source. This is valid only if no cutover request can still execute and the attempt/stale controllers cannot later promote or unblock that target. It is NOT permission to delete an uncertain target, nor safe after ambiguous cutover dispatch. This could reduce outage on pre-cutover failures without pretending a missing task terminated, but requires its own adversarial tests before implementation.

Scope and architecture

Keep #307's block-before-copy contract as the simple strict offline mode. A live pre-copy followed by a complete blocked rescan still has O(dataset-size) outage. A genuinely short final outage requires a durable, ordered change stream including deletes, or migration of demonstrably immutable partitions. Do not import #327's unconditional source-unblock lifecycle into this protocol; that branch has separate findings.

Primary operational references: https://www.elastic.co/docs/reference/elasticsearch/index-settings/index-block ; https://www.elastic.co/docs/deploy-manage/production-guidance/optimize-performance/indexing-speed ; https://www.elastic.co/docs/manage-data/migrate/migrate-data-between-elasticsearch-clusters-with-minimal-downtime .

Copy link
Copy Markdown
Member Author

Consistency follow-up — tracked blockers and implementation scope

At reviewed head 9c681f8ce11b10509aadd2af9dcbbdcaea33200d, production approval remains withheld. This consolidates the deeper review into a checkable implementation ledger; these items are not fixed merely because they are logged.

Shared ownership and task authority

  • Fix Foundatio 13.0.4 CacheLockProvider.RenewAsync silently discarding a failed ReplaceIfEqualAsync result; prove expired/replaced owners cannot renew successfully. Track the upstream fix and consumption separately.
  • Separate lease renewal from potentially slow observer callbacks; prohibit cutover/reset/unblock by an owner that lost its lease. A heartbeat alone is not server-side fencing.
  • Disable inherited automatic retries for asynchronous reindex submission and reconcile unknown launch outcomes without issuing a second blind launch. X-Opaque-Id is correlation, not idempotency.
  • Preserve the existing rule that task 404 is uncertainty, not positive termination. Cover delayed/duplicate launches and relocated tasks; do not infer all writers are gone from one terminal task or an empty task listing.
  • Bind recovery to physical index generations and attempt/cutover evidence; audit stale worker/index-name reuse. No automatic destructive cleanup or failback after ambiguous cutover.

Compatibility replacement safety

  • Require the destination's configured replica policy before deleting its source, rather than accepting yellow with unassigned replicas; make health waiting appropriate for long migrations.
  • Verify private destination ownership, exact copy/shard evidence, and source/target write fences through cutover. Test ambiguous alias responses and stale direct writers.
  • Keep this protocol explicitly offline. Do not move the block later while silently promising delta catch-up, delete propagation, or low downtime.

Related #327 blockers

Track scripted delete resurrection, _mget errors mistaken for absence, unchecked bulk-item/scroll/shard errors, lost routing, scalar multi-shard checkpoints, truncated changed-ID samples, and unconditional retired-source unblocking on #327. Shared-file integration must retain both protocols' safeguards; an independently green PR does not certify the combined tree.

Large-data operating contract

Document strict offline replacement versus a separately designed snapshot/backfill + durable change-capture protocol, including tombstone retention, ordering/idempotence, capacity/backpressure, rollback boundaries, and immutable-partition options. Generic online CDC, arbitrary script reconciliation, and automatic ambiguous-task quarantine must not be represented as implemented without their own tested protocol.

Implementation is being worked on in this session in isolated candidate branches with logical commits and regression tests. Exact promoted SHAs, actual test results, and remaining blockers will be recorded here. No release or merge to main is authorized by this work.

niemyjski commented Sep 22, 2026 •

Copy link
Copy Markdown
Member Author

Consistency hardening tracker — implementation and validation

This remains an open release gate, not production approval. Product baseline is #307 9c681f8c; clean candidates are validated before branch promotion. Temporary scripts/workflows remain only on audit/pr-307-final-validation-20260922 and must not be merged.

Implemented upstream

  • Reproduced silent failed lease renewal and fixed the discarded compare-and-replace result.
  • Found and fixed a second defect: in-memory compare-and-replace could resurrect an expired/released lease.
  • Opened fix: reject renewal of lost and expired cache leases Foundatio#573, clean commit 2f1c51e015b22c684d24fc78a4b80c27235e9ab2: 425 lock/cache tests passed, zero failures/skips; original ownership tests were 3 red / 1 green; Release .NET8/10 build had zero warnings/errors. Evidence: run 35795463344.
  • Land/release and explicitly consume that dependency. Repositories still references Foundatio 13.0.4; a PR in the upstream repository does not change installed packages.

Implemented in the #307 candidate; full validation/promotion pending

  • Destination-specific green health after replica policy restoration, no unassigned/initializing/relocating shards, positive configurable CompatibilityUpgradeHealthTimeout (30-minute default), bounded health polling and correct treatment of poll HTTP408 as not-ready.
  • Independent 30-second heartbeat, bounded renewal attempts, linked work cancellation on lease loss; guarded renewal around callbacks and destructive boundaries. Detected lease loss forbids automatic reset/recovery/unblocking.
  • Effective single-attempt asynchronous submission and alias cutover. The new two-node retry regression exposed that the supported Elastic transport ignores request-local MaxRetries(0); selecting and pinning one normal pool node sets the effective retry count to zero. Correlation headers are not idempotency keys; proxy retries remain an operator constraint.
  • Replica readiness, no-retry, cancellation/lease loss and independent-heartbeat tests. Earlier focused run passed 270 compatibility cases and 152 core cases; the latest heartbeat candidate is being validated separately.

Latest #307 validation: run 35797864520, pinned harness dbfbe4362215b2bac3d0de616ffedbbd91760d82. Its full suites use real two-node ES8.19.15 and ES9.5.0 clusters. The earlier single-node full-suite run correctly failed replica-dependent tests; it is not counted as passing evidence.

Cross-PR implementation

#327 candidate now implements strict routed item/scroll/bulk evidence, exact reconciliation counts, source UUID + complete primary-shard checkpoint vectors, changed-ID pagination past 1,000 results, scripted-quiescence preflight rejection, retained retired-source fences, no completion fabrication from a QuiesceSource flag, single-attempt launch/cutover and independent lease renewal. Tracker: #327 (comment) . Latest full validation is 35797917483; no combined-release guarantee is inferred.

Still open — do not check off on a green ordinary suite

  • Durable generation/attempt-bound migration journal: source/target UUIDs, transform/configuration fingerprints, task lineage, verified state, cutover intent and exact expected alias topology.
  • Stale-controller exclusion and recovery that account for already-dispatched/duplicate/delayed requests across restarts. Heartbeats and task-list snapshots are not storage-enforced fencing.
  • Safe recovery of unknown launches across application-level retries; a transport single attempt alone does not persist the outcome across processes.
  • Version-aware ES9.5 relocated-task cancellation/read/list handling and fault injection. Preserve the current task404-is-unknown behavior for all versions.
  • Final-head CI, refreshed persistent-major/snapshot rehearsal, packaged consumers, and semantic reconciliation of the intended Add explicit Elasticsearch index compatibility upgrades #307/fix(reindex): stop a reindex from silently dropping documents #327/fix: support external-index id opt-out and safe cursor paging (#305) #308 release tree.
  • Low-downtime change-capture/replay is a separate protocol, not supplied by a full blocked rescan. Operating guidance must retain queue draining, routing/tombstone ordering, destination isolation, stale-client retirement and snapshot/rollback constraints.

No production data, main-branch merge, release/publishing policy, force cleanup, history rewrite or PR base retargeting has been performed.

niemyjski commented Sep 23, 2026 •

Copy link
Copy Markdown
Member Author

Consistency remediation tracker — sequential execution

This is the master tracker for #307, #327, and the upstream lock repair. Not a production approval. Each entry is closed only with implementation and exact revision/test evidence. Existing newer work is retained rather than overwritten.

Observed progress

  • Provider repair already exists in Foundatio #573 at c4fbcd4c173969b5560f88954c54ae62307f7a95. Artifact 10725336070 from run 35799107410 confirms 427 passing lock/cache tests and two red baseline CAS regressions. Repositories still references 13.0.4: release/consumption is an open dependency, not silently substituted.
  • Add explicit Elasticsearch index compatibility upgrades #307 candidate 3af1fccb513a4bfaa2c4c6fcaf468c2de7529c69 reuses the reviewed lease-supervision/replica-health candidate and adds tested node-predicate preservation. Run https://github.com/FoundatioFx/Foundatio.Repositories/actions/runs/35804332376: Release builds 0 warnings/errors; 2 red node-selection cases; 21 focused cases green; 1,187 passed, 0 failed, 1 deliberate persistent-chain skip on each two-node ES8.19.15 and ES9.5.0 cluster. Artifact 10726804580 preserves exact clean commits and logs. Not yet promoted while the ordinary-CI two-node profile is validated.
  • Duplicate dispatch is now an executed defect, not just a hypothetical: 502/503/504 produced three POST /_reindex requests even after request-local MaxRetries(0). The pinned transport ignores that local value. Single-node dispatch is required; it preserves configured node predicates/forced-node settings and leaves ordinary client retries unchanged.
  • fix(reindex): stop a reindex from silently dropping documents #327 current starting point b458dd81d75095428c4d438ed93a32a707739cd9 includes newer checkpoint/routing/script/durable-state repairs. Additional task404/launch and retired-source tests are applied against that current tree, not the obsolete 091b2ba0 candidate. Run https://github.com/FoundatioFx/Foundatio.Repositories/actions/runs/35805762856 has demonstrated five task/launch regressions red then green; the live retirement stage is being validated separately. No new fix(reindex): stop a reindex from silently dropping documents #327 product head has been promoted yet.

Ordered work and remaining gates

  1. Lease ownership: integrate a released version containing #573, verify all migration call paths stop destructive mutations after lost ownership, and validate heartbeat lifetime. Add explicit Elasticsearch index compatibility upgrades #307's candidate supervises renewal independently of callbacks and forbids reset/unblock after observed loss. A checked renewal is not a server-side generation fence.
  2. Asynchronous launch and recovery authority: one effective dispatch, no task404/partial-evidence cleanup permission, persistent task lineage, no blind outer/proxy retry. Add explicit Elasticsearch index compatibility upgrades #307 single-dispatch is validated; fix(reindex): stop a reindex from silently dropping documents #327 durable task404/launch changes are in current regression validation. 9.5 relocation-specific cancellation is not represented as implemented for every supported server.
  3. Destination durability (Add explicit Elasticsearch index compatibility upgrades #307): target-only configured replica readiness before source deletion; configurable bounded wait. Candidate enforces green/no unassigned shards with renewable polling; normal CI must provision replicas rather than use its old one-node setup.
  4. Script/deletion reconciliation (fix(reindex): stop a reindex from silently dropping documents #327): verify the newer clean-and-rebuild output protocol, hard deletes/no-op remnants, and unsupported ranges/transformations. Do not infer correctness from destination >= source.
  5. Complete/routed transport evidence (fix(reindex): stop a reindex from silently dropping documents #327): retain and test all-item/shard/scroll/bulk/mget validation and routing+ID identity. An item error is not absence.
  6. Complete source-change evidence (fix(reindex): stop a reindex from silently dropping documents #327): retain and test UUID-bound complete primary checkpoint vectors, all changed-ID pages, and initially empty sources. A checkpoint snapshot is not a write barrier.
  7. Cutover / retirement / failback: retain the old physical source block after durable cutover intent, including lost responses and failing observers; never automatically reopen a cutover record. Proven pre-cutover cleanup and operator blocks remain distinct. New live regressions target this on fix(reindex): stop a reindex from silently dropping documents #327.
  8. Generation-bound recovery and operator contract: full persisted generation/attempt/configuration/alias manifest and enforceable takeover fence remain a separate protocol gate. Maintenance admission control, private destinations, stable metadata, snapshot validation, retry ownership, and concurrency-token invalidation remain mandatory. No automatic quarantine/failback after ambiguous cutover is being claimed.
  9. Integration/release verification: promote only clean reviewed candidates, reconcile actual fix(reindex): stop a reindex from silently dropping documents #327/Add explicit Elasticsearch index compatibility upgrades #307 tips, rerun the intended release tree and persistent chain/package checks, and replace historical/pending PR notes with observed results. Both PRs still target main; no force push or merge to main.

Architecture retained: strict offline compatibility replacement stays write-block-first. Low-downtime mutable migration requires a separate durable ordered change stream covering updates/deletes/version fencing. Slicing, throttling, and historical-partition migration do not silently supply that protocol. No production cluster or package-feed configuration is changed as a shortcut; the planned CI-only replica profile leaves the development compose file and publishing configuration intact.

Copy link
Copy Markdown
Member Author

Sequential step 2: new regression proves request-local retry setting is insufficient

Run https://github.com/FoundatioFx/Foundatio.Repositories/actions/runs/35802285678 built the original and proposed code cleanly. All three new transport regressions (502/503/504, three-node pool, global retry limit 2) observed three POST /_reindex submissions, both before and after adding request-local .MaxRetries(0). The five existing task-404 cases still passed. No candidate was promoted.

The transport binding implementation computes MaxRetries from the global configuration and pool, ignoring the request-local MaxRetries value; a non-null ForceNode sets the effective limit to zero: https://github.com/elastic/elastic-transport-net/blob/78dc23fe779254d1a06ef52bef44e3941c6fb049/src/Elastic.Transport/Components/Pipeline/BoundConfiguration.cs . This turns the earlier conditional concern into an executed regression and explains why merely setting MaxRetries(0) is not adequate with this dependency.

I am correcting the launch to use a single selected node for this one ambiguous side-effecting dispatch, without disabling retries for ordinary reads or changing the shared client globally. The regression gate remains unchanged and must pass before commit promotion. Persisted task intent/404 handling does not account for a duplicate launch whose task ID was never returned.

Copy link
Copy Markdown
Member Author

Sequential consistency remediation — release blocking

I am treating the following as ordered production gates and will close them one at a time with regression evidence before moving to the next:

  1. Lease ownership / fencing — a stale controller must stop mutating after lock ownership is lost. Repositories currently consumes Foundatio 13.0.4, whose cache-lock renewal path does not reliably surface a failed compare-and-replace.
  2. Async reindex single-dispatch / complete task lineage — an ambiguous submission must not be blindly retried into multiple untracked writers. A task 404 is not termination evidence.
  3. Destination durability before destructive compatibility cutover — restoring the replica setting is not enough; do not delete the source until the target has the intended assigned shard redundancy.
  4. Recovery authority / stale-worker protection — unknown or delayed requests must not be able to authorize cleanup, unblock, promote, or reuse a target after ownership changed.
  5. Cross-PR integration — after fix(reindex): stop a reindex from silently dropping documents #327 lands, reconcile its normal-schema migration protocol with Add explicit Elasticsearch index compatibility upgrades #307's compatibility protocol and rerun the actual combined tree.

Each item stays open until the implementation, red/green regression, documentation, and current-head validation agree. I will not mark #307 production-ready merely because the ordinary suite is green.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b6a7ec7996

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +108 to +110
catch (OperationCanceledException)
{
throw;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Roll back source-only cancellation before target creation

When the progress callback throws OperationCanceledException or returns a canceled task for the 5% notification, this branch propagates after the source marker and write block have been acknowledged but before CreateTargetAsync is called. Because canResetCurrentAttempt is still false and the source-only topology is classified as ManualIntervention, the source remains write-blocked and the public recovery API cannot clear it. Fresh evidence after the callback-failure fix is that the explicit cancellation branch still bypasses the exception swallowing; mark this locally proven pre-dispatch state as resettable and remove the marker/block before propagating cancellation.

Useful? React with 👍 / 👎.

@sunnyrp sunnyrp left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR #307 — Elasticsearch index compatibility upgrades

Verdict: REQUEST CHANGES

Blockers: (1) CI build is red at head b6a7ec79. (2) Two unanswered P1/P2 threads are confirmed at head: sibling-alias deletion → data loss, and a stranded source write block. (3) Deterministic _reindex start rejections leave the source write-blocked with no public recovery path.
Warnings: stale seq_no/primary_term optimistic-concurrency tokens after cutover; final_pipeline can mutate copied docs undetected; no-progress timeout ignores the injected TimeProvider; PR size/scope; CLA pending.

Security | Build | Dependencies | Requirements | Code Review | Data-loss matrix | Pre-merge plan

Security: PASS

No malicious scripts, credential access, or obfuscation found. CodeQL passes. The workflow-permission alert was addressed in 5451d7f7.

Build: FAIL | CI: build / build failed on both runs (35809708683, 35809712324) with 1,170 tests, 1 failed

IndexCompatibilityTests.RunCompatibilityReindexAsync_TransientSubmissionIsNotRetriedAcrossNodes fails at IndexCompatibilityTests.Task.cs:109. Expected ["POST /_reindex"], got HEAD / first.

  • Root cause: StaticNodePool pings a node (HEAD /) before first use, and ForceNode does not disable that ping. The stub queued for POST /_reindex is consumed by the ping instead (see inline).
  • The PR description's "final-head CI" gate is therefore not met. The listed evidence runs target 9c681f8c, which is two commits behind the current head.

Dependencies: N/A for audit

One System.IO.Hashing reference was added. It is used only for the X-Opaque-Id correlation hash.

Requirements: GAPS

  • Stated scope is met in design: offline, operator-invoked; zero compatibility requests in ConfigureIndexesAsync; fail-closed recovery.
  • Remaining gates in the PR body are not yet closed at the current head:
    • full-solution ES 8.19/9.5 matrix
    • persistent 7→8→9 chain (the ES9 leg has not executed yet)
    • Windows packaged consumer
    • combined #327/#307/#308 validation

Code Review

# Sev Finding Impact
1 BLOCKER Wrapped-name branch of ValidateCompatibilityDeleteTarget (Index.cs:485-492) never calls IsNativelyClaimedByOtherConfiguredIndex(canonicalName) events.DeleteAsync() can delete events-v2's reindexed-v9-events-v2 → data loss (confirms open P1 thread)
2 BLOCKER Source is marked and write-blocked (Upgrader.cs:130-131). Any failure before CreateTargetAsync leaves canResetCurrentAttempt == false: refresh, lock renew, or callback cancellation at 5%. Recovery then classifies source-only as ManualIntervention Production source stays write-blocked with no public reset API (confirms/extends open P2 thread)
3 BLOCKER _reindex start: every non-success response (e.g. deterministic 400/403/404) and every pre-dispatch exception (ping, DNS, cancellation) becomes ElasticReindexTaskUncertainException (Runner.cs:90-101) Source write-blocked plus a marked, empty target, classified ManualIntervention; the operator must hand-edit cluster state
4 WARNING _reindex resets _seq_no/_primary_term. Foundatio versioned documents carry these as their concurrency token Clients holding pre-upgrade versions can hit spurious conflicts, or in rare collisions a lost update (inconsistency). Document it, and advise invalidating cached versions
5 WARNING Pipeline("_none") bypasses only default_pipeline. A copied index.final_pipeline still runs during _reindex Document bodies can be transformed while counts and mappings still match. Silent data inconsistency; reject or strip final_pipeline for the copy, then restore it
6 NIT No-progress timeout uses Stopwatch.StartNew() while delays use the injected TimeProvider (Runner.cs:174) The timeout branch cannot be tested with FakeTimeProvider; clocks are inconsistent
7 WARNING PR size/shape: +9.8k lines, 90+ commits, many fix-on-fix commits, 98 review threads Hard to review for regressions in the shared ElasticReindexer path (+297/−248) used by every consumer. Squash-merge, and consider splitting the reindexer refactor

Data-loss / inconsistency matrix

  • Source deletion is irreversible. It happens atomically inside the _aliases cutover (remove_index, Upgrader.cs:203-206). Count and mapping checks precede it, but there is no snapshot precondition. Require a verified snapshot in the runbook.
  • Sibling families (#1): physical index of another registered index deleted.
  • Stranded write blocks (#2, #3): writers fail until manual intervention. Not data loss, but an outage.
  • Concurrency tokens (#4) and final pipeline (#5): silent content or version divergence.
  • Cluster-wide readiness: as the PR notes, system indexes such as .geoip_databases can still block ES9 startup. Foundatio preflight ≠ Upgrade Assistant.

Existing comments

  • Outstanding (confirmed at head): 4075643315 (P1 sibling canonical alias), 4078470116 (P2 5% cancellation). Replied to both with verified details.
  • Earlier threads appear addressed by author replies. Not re-verified individually.

Recommended pre-merge plan

  1. Fix the CI test by adding .DisablePing(), or stub HEAD /. Decide whether product code should classify pre-dispatch failures as not started.
  2. Fix #1 by applying sibling arbitration in the wrapped branch, with a regression test for events + events-v2 + reindexed-v9-events-v2.
  3. Fix #2 and #3 by tracking "no create/reindex dispatched" locally, and resetting the source marker and block on those paths. Treat deterministic 4xx _reindex rejections as not started, so the reset path can clean up the empty target.
  4. Address or document #4 and #5 in docs/guide/index-management.md: pre-flight checks for final_pipeline, and a version-token caveat.
  5. Re-run and link, at the final head:
    • ordinary CI
    • ES 8.19.15 + 9.5.0 full matrix
    • standalone format check
    • Windows and macOS packaged consumer
    • persistent 7.17→8.19→9.5 chain with the ES9 leg actually executing
  6. Validate the combined #327/#307/#308 stack, or merge the others first and rebase.
  7. CLA: license/cla is pending.
  8. Squash-merge with a curated message; release as preview first; keep the audit branch unmerged.
  9. Operator runbook: snapshot → stop writers/index managers → Inspect → Upgrade → verify → Recover if needed, including rollback via snapshot restore.

new Uri("http://node-2.invalid:9200"),
new Uri("http://node-3.invalid:9200")
]), invoker)
.MaximumRetries(2);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BLOCKER (CI red at head), 95% confidence. StaticNodePool enables pinging, so the transport sends HEAD / to node-1 before the forced POST /_reindex. The ping consumes the only stub, and the Request: "POST /_reindex" assertion in SequenceRequestInvoker fails (CI: Expected "POST /_reindex", Actual "HEAD /").

Minimal fix: add .DisablePing() here, or queue a HEAD / 200 stub first. Keep MaximumRetries(2) so the test still proves ForceNode prevents failover.

}
catch (Exception ex)
{
throw CreateUncertainStartException(sourceIndex, targetIndex, ex);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BLOCKER (fail-closed, but unrecoverable), 85% confidence. Every exception here becomes ElasticReindexTaskUncertainException. That includes failures that provably happen before dispatch: the node ping above, DNS, OperationCanceledException before send, and SelectSingleDispatchNode failure. Lines 95-101 do the same for deterministic 4xx rejections such as 400 action_request_validation_exception or 403.

At this point the source is marked and write-blocked, and the target exists with its marker. onTaskTerminated never fires, so canResetCurrentAttempt stays false. GetRecoveryAction then returns ManualIntervention. Result: the production source stays write-blocked, and no public API can clear it.

Suggest classifying "no request bytes sent" and 4xx (excluding 408/429) as not started, and letting the caller reset. Keep 5xx/timeout/lost-response as uncertain.

int statusFailures = 0;
long lastProgress = 0;
var noProgressTimeout = ElasticReindexer.GetNoProgressTimeout(workItem);
var noProgressStopwatch = Stopwatch.StartNew();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NIT, 90% confidence. The no-progress window uses the wall-clock Stopwatch, while every delay uses the injected _timeProvider. With a FakeTimeProvider the timeout branch can never fire, so it is untested. Use _timeProvider.GetTimestamp() / GetElapsedTime() for consistency.

if (batchSize.HasValue)
source.Size(batchSize.Value);
});
d.Dest(destination => destination.Index(targetIndex).OpType(OpType.Create).Pipeline("_none"));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING (silent data inconsistency), 70% confidence; please confirm. Pipeline("_none") suppresses only the destination's index.default_pipeline. _create_from copies index.final_pipeline, which always runs and cannot be bypassed per request. A non-idempotent final pipeline (timestamps, enrich, set) would rewrite every copied _source. Counts and mappings would still match, so verification passes and the original is then deleted.

Suggest failing validation when the source has index.final_pipeline, or stripping it on the target for the copy and restoring it before cutover.


workflowAttempted = true;
await AddWorkflowMarkerAsync(sourceIndex, false, cancellationToken).AnyContext();
await AddWriteBlockAsync(sourceIndex, cancellationToken).AnyContext();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BLOCKER (stranded write block), 90% confidence. This extends open thread r4078470116. After this block is acknowledged, canResetCurrentAttempt is false until line 138. Every failure in that window leaves the source write-blocked with only the source existing:

  • RefreshAsync failure or cancellation
  • reindexLock.RenewAsync() failure inside ReportProgressAsync(5)
  • callback OperationCanceledException
  • CreateTargetAsync rejected with a deterministic 4xx

GetRecoveryAction maps that state to ManualIntervention (Recovery.cs:298-304), and CanResetCurrentAttempt requires TargetExists, so nothing can clear the block.

Suggest a local targetCreateDispatched flag. When it is still false, the process has positive evidence that no create is in flight, so it can safely remove the block and marker before rethrowing.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants