OpenSearch Provider

The Hyperbee.Migrations.Providers.OpenSearch package provides OpenSearch support for Hyperbee Migrations. It manages indices, mappings, settings, aliases, templates, ISM policies, and reindex orchestration through resource-based migrations using a Parlot-parsed statement grammar. AWS Managed OpenSearch Service is supported via the optional Hyperbee.Migrations.Providers.OpenSearch.Aws extension package. For cross-cutting concepts, see Concepts.

Installation

dotnet add package Hyperbee.Migrations.Providers.OpenSearch

For AWS Managed OpenSearch (SigV4 request signing):

dotnet add package Hyperbee.Migrations.Providers.OpenSearch.Aws

Configuration

Register the OpenSearch client and migration services with the DI container. The two registration paths are mutually exclusive: call AddOpenSearchClient for header-based auth (Basic, ApiKey, mTLS, Anonymous) OR AddOpenSearchAwsClient for AWS SigV4. Each guards against the other being called first.

// Local dev, on-prem, or any non-AWS deployment
services.AddOpenSearchClient( new Uri( "http://localhost:9200" ), auth =>
{
    auth.Mode     = OpenSearchAuthenticationMode.Basic;
    auth.UserName = "admin";
    auth.Password = "password";
} );

services.AddOpenSearchMigrations( options =>
{
    options.LedgerIndex = ".migrations";        // default
    options.LockIndex   = ".migrations-lock";   // default
    options.LockingEnabled = true;
} );

For AWS Managed OpenSearch:

services.AddOpenSearchAwsClient( new Uri( "https://my-domain.us-east-1.es.amazonaws.com" ), aws =>
{
    aws.Region  = "us-east-1";
    aws.Service = "es";   // "aoss" for OpenSearch Serverless
} );

services.AddOpenSearchMigrations( /* migration options */ );

Provider options

Option Type Default
LedgerIndex string “.migrations”
LockIndex string “.migrations-lock”
LockName string “migration_lock”
LockingEnabled bool false
ClusterHealthThreshold enum Yellow
WaitMode enum PerStatement
RequireUnsafeJustification bool false
ContextResolutionPolicy enum SkipIfUnset
ActiveContext string null
ImplicitWaitTimeout TimeSpan 30 seconds
LockRenewInterval TimeSpan 30 seconds
LockStaleAfter TimeSpan 60 seconds
LockMaxLifetime TimeSpan 1 hour
AssumeIndicesExist bool false
ForceResume bool false

WithProductionDefaults

WithProductionDefaults() flips four options to production-safe values BEFORE the user’s configuration callback runs, so explicit overrides still win:

Option Library default Production default
ClusterHealthThreshold Yellow Green
WaitMode PerStatement PerMigration
RequireUnsafeJustification false true
ContextResolutionPolicy SkipIfUnset RequireExplicit
services
    .WithProductionDefaults()
    .AddOpenSearchMigrations( options =>
    {
        // Per-option overrides win over the production defaults above.
        options.WaitMode = WaitMode.Off;
    } );

Resource layout

A migration’s resources live in a folder named after the migration class (or version). The folder ships as embedded resources in the migration project’s csproj.

Resources/
  1000-CreateInitialIndex/
    statements.json
  3000-ComponentAndIndexTemplate/
    statements.json
    bodies/
      common-mappings-component.json
  4000-IsmPolicyAndApply/
    statements.json
    hot-warm-cold-policy.json

Mark each file EmbeddedResource in the project file:

<ItemGroup>
  <EmbeddedResource Include="Resources\1000-CreateInitialIndex\statements.json" />
  <EmbeddedResource Include="Resources\4000-IsmPolicyAndApply\statements.json" />
  <EmbeddedResource Include="Resources\4000-IsmPolicyAndApply\hot-warm-cold-policy.json" />
</ItemGroup>

The migration class loads its resources via OpenSearchResourceRunner<T>:

[Migration( 1000 )]
public class CreateInitialIndex( OpenSearchResourceRunner<CreateInitialIndex> runner ) : Migration
{
    public override Task UpAsync( CancellationToken ct = default )
        => runner.StatementsFromAsync( "statements.json", ct );
}

Statement grammar

The grammar is a small SQL-flavored DSL. Each statement is one line; one or more statements live inside a statements.json resource. Statement keywords are case-insensitive. Identifiers may be plain (users, users-v1, users.archive) or backtick-quoted (`users.v2`) for names containing characters the plain-form parser does not accept. The grammar is offline-pure (ADR-0015) – no network I/O at parse time. Anything that needs the live cluster (template resolution, version checks) happens at dispatch time.

Durations use <integer><s|m|h> (e.g., 30s, 5m, 2h). Pure integers are rejected – the suffix is required.

Statement summary

Family Form
Index lifecycle CREATE INDEX <name> [IF NOT EXISTS] [WITH BODY $body] [NO WAIT("<reason>")]
  DROP INDEX <name> [IF EXISTS]
  UPDATE MAPPING ON <idx> [WITH BODY $body]
  UPDATE SETTINGS ON <idx> [CLOSE] [WITH BODY $body] [NO WAIT("<reason>")]
  REFRESH <name>
Alias ALIAS SWAP <alias> FROM <old> TO <new> [NO WAIT("<reason>")]
  ALIAS ADD <alias> ON <idx>
  ALIAS REMOVE <alias> ON <idx>
Reindex REINDEX [UNSAFE("<reason>")] FROM <src> TO <dst> [WITH BODY $body] [NO WAIT("<reason>")]
Composite MIGRATE INDEX <old> TO <new> [WITH TEMPLATE <id> | WITH BODY $body] [VIA ALIAS <alias>] [TIMEOUT <duration>]
Templates CREATE TEMPLATE <name> [WITH BODY $body]
  CREATE COMPONENT <name> [WITH BODY $body]
  DROP TEMPLATE <name> [IF EXISTS]
  DROP COMPONENT <name> [IF EXISTS]
ISM CREATE POLICY <id> [WITH BODY $body]
  APPLY POLICY <id> TO <pattern> [NO WAIT("<reason>")]
Cluster waits WAIT FOR <green|yellow> [ON <idx>] [TIMEOUT <duration>]
  WAIT UNTIL TASK <id> COMPLETE [TIMEOUT <duration>]
Conditional WHEN VERSION <op> '<version>' <statement>

Body references

JSON bodies attach to a statement via WITH BODY <ref>. The provider supports three resolution forms (ADR-0017), all coexistent – pick the one that fits the body’s size and reuse profile.

Form 1: Direct file reference (least ceremony)

{ "statement": "CREATE INDEX users WITH BODY @users-mapping.json" }

The @-prefixed path loads an embedded resource relative to the migration’s own resource folder. Use this for any body that would otherwise dominate the statements.json file – large mappings, ISM policies, reusable templates. Subfolders are optional. Path validation is parse-time:

  • Absolute paths (leading / or \) are rejected – body files must stay inside the migration’s resource folder.
  • Drive-letter prefixes (C:, c:, …) are rejected – same reason. Path.IsPathRooted is platform-dependent (C:/foo reads as rooted on Windows but not on Linux); the validator checks the rooted shape explicitly so an author editing on one host can’t produce a path that’s silently rooted on another.
  • Any other : in the path is rejected – embedded resource names don’t use it.
  • .. segments are rejected – no parent-directory traversal.
  • Allowed characters: letters, digits, _, -, ., /, \.

Form 2: Named body inline

{
  "statement": "CREATE INDEX users WITH BODY $usersIndex",
  "bodies": {
    "usersIndex": {
      "settings": { "number_of_shards": 1, "number_of_replicas": 0 },
      "mappings": { "properties": { "id": { "type": "keyword" } } }
    }
  }
}

$<name> resolves to bodies.<name> on the same statement object. Use this for tiny bodies tightly coupled to a single statement, where atomic versioning and a single-screen view of the migration are more valuable than file separation.

Form 3: Named body referencing a file

{
  "statement": "CREATE INDEX users WITH BODY $usersIndex",
  "bodies": {
    "usersIndex": "@bodies/users-mapping.json"
  }
}

When a bodies.<name> value is a string starting with @, the resolver loads it as a file reference (same rules as form 1). Useful when you want to address bodies by name (e.g., for clarity in PR review) but keep them in their own files.

Back-compat: top-level sibling property

{
  "statement": "CREATE INDEX users WITH BODY $usersIndex",
  "usersIndex": { "settings": { } }
}

When bodies.<name> is missing, the resolver falls back to a top-level sibling property of the same name. Preserves the original ADR-0009 shape so existing migrations do not need rewriting.

Resolution order

  1. BodyFileRef (the @path form): load the embedded resource.
  2. BodyRef with a bodies.<name> entry: structured form wins.
  3. BodyRef with a sibling <name> property: ADR-0009 fallback.
  4. Otherwise: throw OpenSearchProviderException with a remediation message naming both the preferred form and the fallback.

Statement reference

CREATE INDEX

CREATE INDEX <name> [IF NOT EXISTS] [WITH BODY $body] [NO WAIT("<reason>")]

Creates an index. The provider auto-injects mappings.dynamic: "strict" into the body unless the body explicitly sets mappings.dynamic or uses composed_of (component composition). User-explicit settings always win.

{
  "statements": [
    {
      "statement": "CREATE INDEX users IF NOT EXISTS WITH BODY $usersIndex",
      "bodies": {
        "usersIndex": {
          "settings": {
            "number_of_shards":   1,
            "number_of_replicas": 0
          },
          "mappings": {
            "properties": {
              "id":    { "type": "keyword" },
              "email": { "type": "keyword" },
              "name":  { "type": "text" }
            }
          }
        }
      }
    }
  ]
}

DROP INDEX

DROP INDEX <name> [IF EXISTS]

IF EXISTS makes drop idempotent via a HEAD probe before delete.

{ "statement": "DROP INDEX users IF EXISTS" }

UPDATE MAPPING

UPDATE MAPPING ON <idx> [WITH BODY $body]

Sends a PUT /<idx>/_mapping. Mapping updates do NOT propagate to existing documents – for that you need a reindex (or MIGRATE INDEX).

{
  "statement": "UPDATE MAPPING ON users WITH BODY $newFields",
  "bodies": {
    "newFields": {
      "properties": {
        "verified_at": { "type": "date" }
      }
    }
  }
}

UPDATE SETTINGS

UPDATE SETTINGS ON <idx> [CLOSE] [WITH BODY $body] [NO WAIT("<reason>")]

Without CLOSE, applies dynamic settings only. CLOSE opts into the close -> update -> open dance for static settings (write-unavailable for the close window). The reopen runs in a finally so a settings failure still attempts to reopen the index.

Dynamic update (no close):

{
  "statement": "UPDATE SETTINGS ON users WITH BODY $refresh",
  "bodies": { "refresh": { "index": { "refresh_interval": "5s" } } }
}

Static update with explicit CLOSE:

{
  "statement": "UPDATE SETTINGS ON users CLOSE WITH BODY $analyzer",
  "bodies": {
    "analyzer": {
      "index": {
        "analysis": {
          "analyzer": { "default": { "type": "standard" } }
        }
      }
    }
  }
}

REFRESH

REFRESH <name>

Force-refresh; useful before a follow-up read or count.

{ "statement": "REFRESH users" }

ALIAS SWAP (atomic precondition, R-16)

ALIAS SWAP <alias> FROM <old> TO <new> [NO WAIT("<reason>")]

Compiles to a single POST /_aliases with both remove (with must_exist: true) and add actions. Either both succeed or both fail; the alias never resolves to both indices simultaneously. No separate precondition GET – TOCTOU window eliminated by the cluster’s atomic body rejection.

{ "statement": "ALIAS SWAP users-current FROM users-v1 TO users-v2" }

ALIAS ADD / REMOVE

ALIAS ADD <alias> ON <idx>
ALIAS REMOVE <alias> ON <idx>

Single-action _aliases post. Use these for initial alias setup; use ALIAS SWAP for the cutover.

{
  "statements": [
    { "statement": "ALIAS ADD users-current ON users-v1" },
    { "statement": "ALIAS ADD users-archive ON users-v0" }
  ]
}

REINDEX

REINDEX [UNSAFE("<reason>")] FROM <src> TO <dst> [WITH BODY $body] [NO WAIT("<reason>")]

By default the provider injects op_type: create into the body so a retried reindex does not silently overwrite documents that succeeded on the first run. Authors who need overwrite semantics opt out via UNSAFE("<non-empty justification>"). Bare UNSAFE (no parentheses, no string) fails at parse time.

Default-safe:

{ "statement": "REINDEX FROM users-v1 TO users-v2" }

With a query body restricting which docs are reindexed:

{
  "statement": "REINDEX FROM users-v1 TO users-v2 WITH BODY $onlyActive",
  "bodies": {
    "onlyActive": {
      "source": {
        "query": { "term": { "active": true } }
      }
    }
  }
}

Opt out of op_type: create (rare; PR audit trail required):

{
  "statement": "REINDEX UNSAFE(\"intentional overwrite -- dst is empty per script-001\") FROM users-v1 TO users-v2"
}
MIGRATE INDEX <old> TO <new>
  [WITH TEMPLATE <id> | WITH BODY $body]
  [VIA ALIAS <alias>]
  [TIMEOUT <duration>]

The canonical answer to “how do I propagate a template/mapping change to existing data?” Decomposes at parse time into:

  1. CREATE INDEX <new> – body resolved either from WITH TEMPLATE <id> (runtime GET /_index_template/<id>) or WITH BODY $body (sibling reference). Mutually exclusive.
  2. REINDEX FROM <old> TO <new> with op_type: create auto-injected.
  3. ALIAS SWAP <alias> FROM <old> TO <new> (only when VIA ALIAS is present).

Without VIA ALIAS, no swap is performed – the author retains responsibility for cutover. Without WITH TEMPLATE or WITH BODY, CREATE INDEX runs with no body (the cluster’s own template-matching may apply).

MIGRATE INDEX a TO a (same source and destination) is rejected at parse time. Failure of any sub-statement halts the composite and feeds the partial-rollback ledger semantics.

Template-driven, with cutover:

{
  "statement": "MIGRATE INDEX users-v1 TO users-v2 WITH TEMPLATE users-template VIA ALIAS users-current TIMEOUT 5m"
}

Body-driven, no cutover (author does the alias swap separately):

{
  "statement": "MIGRATE INDEX users-v1 TO users-v2 WITH BODY $newShape",
  "bodies": {
    "newShape": { "settings": { "number_of_shards": 3 } }
  }
}

CREATE TEMPLATE / DROP TEMPLATE

CREATE TEMPLATE <name> [WITH BODY $body]
DROP TEMPLATE <name> [IF EXISTS]

Composable index templates (PUT /_index_template/<name>).

{
  "statement": "CREATE TEMPLATE users-template WITH BODY $template",
  "bodies": {
    "template": {
      "index_patterns": ["users-*"],
      "template": {
        "settings": { "number_of_shards": 3, "number_of_replicas": 1 },
        "mappings": {
          "properties": {
            "id":    { "type": "keyword" },
            "email": { "type": "keyword" }
          }
        }
      },
      "composed_of": ["common-mappings"]
    }
  }
}

CREATE COMPONENT / DROP COMPONENT

CREATE COMPONENT <name> [WITH BODY $body]
DROP COMPONENT <name> [IF EXISTS]

Component templates (PUT /_component_template/<name>). The IF EXISTS guard on drops uses a HEAD probe; missing names skip cleanly. Component drops fail loudly when the component is referenced by an index template (drop the referencing template first).

{
  "statement": "CREATE COMPONENT common-mappings WITH BODY @bodies/common-mappings-component.json"
}

CREATE POLICY (ISM)

CREATE POLICY <id> [WITH BODY $body]

Uploads the policy to _plugins/_ism/policies (or _opendistro/_ism/policies on older AWS Managed domains – the provider detects this at bootstrap).

{
  "statement": "CREATE POLICY hot-warm-cold WITH BODY @hot-warm-cold-policy.json"
}

CREATE POLICY is idempotent. ISM versions policies internally, so a plain PUT to an already-existing policy returns HTTP 409 version_conflict_engine_exception. The dispatcher transparently handles this: on 409 it reads the current _seq_no and _primary_term from the existing policy and retries the PUT with if_seq_no / if_primary_term query parameters. The result is upsert semantics – no behavior change when the policy doesn’t exist; safe re-execution when it does. This makes CREATE POLICY usable inside [Migration(N, journal: false)] reconciliation migrations that re-run on every startup. A second 409 on the retry indicates a concurrent writer between the GET and the retry PUT and is surfaced as a hard failure (the migration lock should make this rare).

APPLY POLICY (ISM)

APPLY POLICY <id> TO <pattern> [NO WAIT("<reason>")]

Attaches the policy to existing indices matching the pattern via _plugins/_ism/add. The dispatcher inspects the response body and surfaces logical failures explicitly: HTTP 200 with updated_indices: 0 is mapped to Failed, not silent OK.

{ "statement": "APPLY POLICY hot-warm-cold TO logs-*" }

Three temporal scopes for ISM attachment

ISM attachment to an index series isn’t one problem with three solutions – it’s three different problems, each with its own right tool. Pick by when the indices that need the policy come into existence relative to the migration that owns the policy.

Scope Right tool Sample Notes
Greenfield – attach to indices that will be created in the future ism_template.index_patterns in the policy body, template.aliases in the index template 9000 – ForwardAttachmentLifecycle Cluster handles it lazily at index-creation time. No migration runtime cost. Won’t help with indices that already exist when the migration runs.
One-time backfill – attach a policy to a set of indices that already exist at migration run time Runtime APPLY POLICY <id> TO <pattern> in a normal [Migration(N)] 4000 – IsmPolicyAndApply Single-shot, journaled. Wildcards adapt to current cluster state at run time. Zero-updated -> Failed escalation makes it loud when the pattern matches nothing.
Ongoing reconciliation – keep all matching existing indices on the current policy as the policy evolves Runtime APPLY POLICY <id> TO <pattern> in a [Migration(N, journal: false)] 9001 – OngoingPolicyReconciliation Re-runs on every startup. Idempotent on the wire (ISM’s change_policy is a no-op for already-on-policy indices). The wildcard form is correct because the set of indices to reconcile changes as new ones roll over and old ones are deleted.

The three are stackable. A typical mature pipeline uses greenfield at install time, one-time backfill when an existing series first adopts the policy, and ongoing reconciliation as the policy definition evolves over the project’s lifetime. Many pipelines never need more than one – but you should choose deliberately rather than reach for runtime APPLY POLICY by default.

Caveat: ism_template inside a policy body is the modern endpoint shape. Older AWS-managed clusters served by the legacy _opendistro/_ism endpoint may not honor it; if IsmEndpointDetectStep resolves to the legacy endpoint, the greenfield row falls back to runtime APPLY POLICY (sample 4000’s pattern, run once at install time, plus sample 9001’s reconciliation pattern for ongoing changes). Modern OpenSearch (2.x and the modern AWS endpoint) supports ism_template natively.

WAIT FOR (cluster health)

WAIT FOR <green|yellow> [ON <idx>] [TIMEOUT <duration>]

WAIT FOR YELLOW is the documented “not red” idiom – there is no separate “WAIT FOR not red” verb. The default health threshold is Yellow; WithProductionDefaults() flips it to Green.

{ "statement": "WAIT FOR YELLOW ON users TIMEOUT 30s" }

WAIT UNTIL TASK

WAIT UNTIL TASK <id> COMPLETE [TIMEOUT <duration>]

Polls _tasks/<id> with exponential backoff (500ms -> 30s ceiling). Used by long-running operations that surface a task id (e.g., reindex async dispatch).

{ "statement": "WAIT UNTIL TASK r1A2B3C4D:42 COMPLETE TIMEOUT 10m" }

WHEN VERSION (conditional)

WHEN VERSION <op> '<version>' <statement>

Statement-level prefix that gates the wrapped child on the live cluster’s reported version. Comparators: =, !=, <, <=, >, >=. The cluster version is fetched once per dispatcher (cached) and compared semantically – '2.9' < '2.10' is true (lexical comparison would invert it). Skipped statements log the actual cluster version so ops can distinguish “cluster older than expected” from “predicate is wrong.”

v1 supports MAJOR.MINOR[.PATCH] only. -SNAPSHOT, -rc<N>, and AWS OpenSearch_<x> prefix/suffix forms are rejected at parse time with a remediation message.

{
  "statements": [
    { "statement": "WHEN VERSION >= '2.10' CREATE TEMPLATE users-v2 WITH BODY $modernTemplate" }
  ]
}

Implicit waits and the NO WAIT modifier

OpenSearchMigrationOptions.WaitMode controls when the implicit cluster-health wait fires after each mutating verb:

Mode When it waits Use when
PerStatement (library default) After every mutating statement, scoped to the mutated index Dev iteration, small migrations
PerMigration (production) One consolidated wait at end of resource pass, scoped to all dirty indices Production – avoids the N+1 master-task-queue storm on long migrations
Off Never (only explicit WAIT FOR runs) Author owns all wait timing

The five mutating verbs that participate are CREATE INDEX, REINDEX, ALIAS SWAP, UPDATE SETTINGS, and APPLY POLICY. Each accepts an optional NO WAIT("<reason>") modifier as the very last clause:

CREATE INDEX users WITH BODY @bodies/users.json NO WAIT("massive mapping; manual wait via dashboards")
REINDEX FROM users-v1 TO users-v2 NO WAIT("Tasks API polling out of band")

NO WAIT skips the implicit wait for that one statement under PerStatement. Under PerMigration, per-statement NO WAIT is a DEBUG-level no-op (only the end-of-migration flush runs). Bare NO WAIT (no parentheses, no justification) is rejected at parse time – the justification token is the high-signal grep target for PR review and incident postmortems, mirroring the UNSAFE("...") precedent.

Context filter

A statements.json file may declare an optional top-level context array. The runner uses this to gate the entire file against OpenSearchMigrationOptions.ActiveContext (a comma-separated string, bindable via Migrations:ActiveContext).

{
  "context": ["prod", "staging"],
  "statements": [
    { "statement": "CREATE INDEX users WITH BODY @bodies/users-mapping.json" }
  ]
}

Resolution rules:

File context ActiveContext ContextResolutionPolicy Outcome
(none) (any) (any) run
["prod"] "prod" (any) run
["prod","staging"] "canary,prod" (any) run (any tag matches)
["prod"] "dev" (any) skip (INFO log)
["prod"] null SkipIfUnset (library default) skip (INFO log)
["prod"] null RequireExplicit (production) throw MissingActiveContextException

WithProductionDefaults() flips ContextResolutionPolicy to RequireExplicit so production deployments fail loudly when ActiveContext is missing. Matching is case-sensitive – context tags are identifiers. The check is per-file: skipped files do not dispatch any statements (Up) or run any rollbacks (Down). Combine with WHEN VERSION for finer-grained statement-level gating within a file that has already been admitted by context.

Rollback

Each statement entry may carry an optional rollback field. UpAsync runs statement fields in declaration order; DownAsync (via RollbackStatementsFromAsync) runs rollback fields in reverse declaration order – last operation applied is the first to undo.

{
  "statements": [
    {
      "statement": "CREATE INDEX audit_v1 IF NOT EXISTS",
      "rollback":  "DROP INDEX audit_v1 IF EXISTS"
    },
    {
      "statement": "ALIAS ADD audit ON audit_v1",
      "rollback":  "ALIAS REMOVE audit ON audit_v1"
    }
  ]
}

If the rollback halts partway (statement N fails after N+1..M succeeded), the ledger entry is overwritten to partially_rolled_back with failedStatementIndex: N, and subsequent runs require ForceResume = true (--force-resume on the runner CLI). See the AWS validation runbook for the recovery protocol.

Bulk loading

Bulk-load helper for code migrations that need to seed documents efficiently. Wraps BulkAllObservable with production-safe defaults (8x parallelism, 1s exponential backoff, 5 retries on 429, refresh-once-at-end). Each retried 429 surfaces as a structured WARN log so operator dashboards can spot self-induced-throttling patterns.

[Migration( 5000 )]
public class SeedDocuments( OpenSearchResourceRunner<SeedDocuments> runner ) : Migration
{
    public override async Task UpAsync( CancellationToken ct = default )
    {
        var docs = LoadFromCsv();   // or any IEnumerable<T>

        await runner.BulkLoadAsync( "users", docs, options =>
        {
            options.BatchSize             = 1000;
            options.MaxDegreeOfParallelism = 8;
            options.BackOffRetries        = 5;
            options.InitialBackOff        = TimeSpan.FromSeconds( 1 );
            options.RefreshOnCompleted    = true;
        }, ct );
    }
}

Locking

The provider uses a single OpenSearch document on LockIndex for distributed locking. Acquisition is op_type=create (atomic claim); on conflict, a realtime GET checks staleness before any takeover. The renewal loop refreshes the heartbeat at LockRenewInterval; CAS conflicts on renewal signal that another runner has taken over and the in-flight migration is canceled cleanly. LockMaxLifetime caps total wall-clock hold so a hung migration cannot lock forever.

The lock index is created with number_of_replicas: 0 so concurrent acquire under N runners does not stall on replica-write coupling.

Ledger forensics

The migration ledger captures forensic fields per R-06 so post-mortems have what they need without log spelunking:

Field Purpose
id Record id (record.<version>.<kebab-name>)
runOn Apply timestamp
direction Up or Down
status succeeded, failed, or partially_rolled_back
appliedBy <machineName>/<processId>
error Failure detail, when applicable
failedStatementIndex Which rollback statement halted the Down sequence

Production deployment

The companion runner project (runners/Hyperbee.MigrationRunner.OpenSearch) is the recommended deployment shape. Same Helm chart / Dockerfile / Octopus deploy template as the other Hyperbee runners. CLI flags: --connection, --auth-mode, --user, --password, --api-key-id, --api-key, --client-cert, --client-cert-password, --ledger, --lock, --lock-name, --profile, --file, --assembly, --force-resume. See Runners.

Multi-topology testing

  • Single-node Testcontainers (every PR) covers the grammar surface.
  • 3-node multi-node Testcontainers Compose (every PR via multi_node_tests.yml in CI) covers the production behaviors single-node cannot exercise: GREEN threshold, replica allocation, shard relocation under load, lock-index replicas:0 invariant.
  • AWS Managed OpenSearch is validated via the AWS validation runbook, pre-release and nightly when AWS credentials are available in CI.

See tests/Hyperbee.Migrations.Integration.Tests/Container/OpenSearch/MULTINODE.md for how to use the multi-node harness in your own tests.

Samples

runners/samples/Hyperbee.Migrations.OpenSearch.Samples ships eight sample migrations covering every v1 verb. Sample 6 (MigrateIndexComposite) is featured – it is the canonical answer to “how do I propagate mapping changes to existing data?” See Resource Migrations.


Table of contents


© Stillpoint Software.

Hyperbee Migration Docs