id: qdrant-collection-alias-zero-downtime-cutover-gate
namespace: company.ai
description: |
Checks that a freshly built Qdrant collection is Green, indexed and answering searches, then moves a
production alias onto it with a single atomic alias update. Waits for an operator approval first
by default, and verifies the alias target afterwards.
concurrency:
limit: 1
behavior: QUEUE
inputs:
- id: qdrant_host
type: STRING
defaults: "localhost"
displayName: "Qdrant Host"
description: "Hostname or IP address of the Qdrant service."
- id: qdrant_http_port
type: INT
defaults: 6333
displayName: "Qdrant HTTP Port"
description: "REST API port, used to read and change aliases."
- id: qdrant_grpc_port
type: INT
defaults: 6334
displayName: "Qdrant gRPC Port"
description: "gRPC port, used to read collection info and run the canary search."
- id: candidate_collection
type: STRING
required: true
validator: "^[A-Za-z0-9_.-]+$"
displayName: "Candidate Collection"
description: "Name of the newly built collection to check and promote."
- id: alias_name
type: STRING
required: true
validator: "^[A-Za-z0-9_.-]+$"
displayName: "Production Alias Name"
description: "Alias that applications query. It is moved onto candidate_collection."
- id: canary_point_id
type: STRING
required: true
displayName: "Canary Point ID"
description: "ID of a point that exists in the candidate collection. It is used
for a 1-NN similarity search before the cutover."
- id: max_indexing_lag
type: INT
defaults: 0
min: 0
displayName: "Max Allowable Indexing Lag"
description: "Maximum allowed number of unindexed vectors (points minus indexed
vectors). Qdrant only builds HNSW segments above its indexing threshold,
so raise this for small collections."
- id: require_manual_approval
type: BOOL
defaults: true
displayName: "Require Operator Approval"
description: "When true, pauses for approval in the Kestra UI before the alias
is changed. Skipped in dry runs."
- id: dry_run
type: BOOL
defaults: true
displayName: "Dry Run Mode"
description: "When true, runs every check and the canary search but does not
change the alias."
tasks:
- id: probe_candidate_collection
type: io.kestra.plugin.qdrant.CollectionInfo
description: "Reads status, point count and indexed vector count of the
candidate collection over gRPC."
host: "{{ inputs.qdrant_host }}"
port: "{{ inputs.qdrant_grpc_port }}"
apiKey: "{{ secret('QDRANT_API_KEY') }}"
collectionName: "{{ inputs.candidate_collection }}"
tlsEnabled: false
- id: validate_candidate_readiness
type: io.kestra.plugin.core.flow.If
description: "Stops the cutover when the collection is not Green, is empty, or
has more unindexed vectors than allowed."
condition: "{{ outputs.probe_candidate_collection.status != 'Green' or
(outputs.probe_candidate_collection.pointsCount | default(0)) == 0 or
((outputs.probe_candidate_collection.pointsCount | default(0)) -
(outputs.probe_candidate_collection.indexedVectorsCount | default(0))) >
inputs.max_indexing_lag }}"
then:
- id: alert_candidate_unready
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
description: "Tells the channel that the candidate collection is not ready."
url: "{{ secret('SLACK_WEBHOOK') }}"
payload: |
{"text": ":warning: *Qdrant Cutover Blocked: Candidate Collection Not Ready*\n*Collection:* `{{ inputs.candidate_collection }}`\n*Status:* `{{ outputs.probe_candidate_collection.status }}`\n*Points:* {{ outputs.probe_candidate_collection.pointsCount | default(0) }}\n*Indexed Vectors:* {{ outputs.probe_candidate_collection.indexedVectorsCount | default(0) }}\n*Limit on unindexed vectors:* {{ inputs.max_indexing_lag }}\n*Execution:* `{{ execution.id }}`"}
- id: abort_unready_candidate
type: io.kestra.plugin.core.execution.Fail
description: "Fails the execution so that the alias is not moved."
errorMessage: "Candidate collection {{ inputs.candidate_collection }} is not
ready: status={{ outputs.probe_candidate_collection.status }},
points={{ outputs.probe_candidate_collection.pointsCount | default(0)
}}, indexedVectors={{
outputs.probe_candidate_collection.indexedVectorsCount | default(0)
}}, allowed unindexed={{ inputs.max_indexing_lag }}."
- id: canary_vector_search
type: io.kestra.plugin.qdrant.Query
description: "Runs a 1-NN similarity search seeded by the canary point to prove
the candidate can answer queries."
host: "{{ inputs.qdrant_host }}"
port: "{{ inputs.qdrant_grpc_port }}"
apiKey: "{{ secret('QDRANT_API_KEY') }}"
collectionName: "{{ inputs.candidate_collection }}"
vectorId: "{{ inputs.canary_point_id }}"
topK: 1
fetchType: FETCH
withPayload: false
- id: validate_canary_recall
type: io.kestra.plugin.core.flow.If
description: "Stops the cutover when the canary search returns nothing."
condition: "{{ (outputs.canary_vector_search.size | default(0)) == 0 }}"
then:
- id: alert_canary_failure
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
description: "Tells the channel that the canary search returned no results."
url: "{{ secret('SLACK_WEBHOOK') }}"
payload: |
{"text": ":x: *Qdrant Cutover Blocked: Canary Query Failed*\n*Collection:* `{{ inputs.candidate_collection }}`\n*Canary Point ID:* `{{ inputs.canary_point_id }}`\n*Results:* 0 rows returned\n*Execution:* `{{ execution.id }}`"}
- id: abort_canary_failure
type: io.kestra.plugin.core.execution.Fail
description: "Fails the execution so that the alias is not moved."
errorMessage: "The canary search returned no neighbours for point {{
inputs.canary_point_id }} in collection {{ inputs.candidate_collection
}}."
- id: inspect_current_aliases
type: io.kestra.plugin.core.http.Request
description: "Reads all aliases from the Qdrant REST API."
uri: "http://{{ inputs.qdrant_host }}:{{ inputs.qdrant_http_port }}/aliases"
method: GET
contentType: application/json
headers:
api-key: "{{ secret('QDRANT_API_KEY') }}"
- id: resolve_alias_state
type: io.kestra.plugin.core.output.OutputValues
description: "Finds where the alias points now and whether it already points at
the candidate."
values:
existing_target: >-
{{ (outputs.inspect_current_aliases.body | jq('[(.result.aliases //
[])[] | select(.alias_name == "' ~ inputs.alias_name ~ '") |
.collection_name] | first // "none"')) | first }}
alias_exists: >-
{{ (outputs.inspect_current_aliases.body | jq('[(.result.aliases //
[])[] | select(.alias_name == "' ~ inputs.alias_name ~ '")] | if length
> 0 then "yes" else "no" end')) | first }}
cutover_state: >-
{{ (outputs.inspect_current_aliases.body | jq('[(.result.aliases //
[])[] | select(.alias_name == "' ~ inputs.alias_name ~ '" and
.collection_name == "' ~ inputs.candidate_collection ~ '")] | if length
> 0 then "ALREADY_ACTIVE" else "CUTOVER_REQUIRED" end')) | first }}
- id: route_cutover_decision
type: io.kestra.plugin.core.flow.Switch
description: "Skips the change when the alias already points at the candidate,
otherwise runs the cutover."
value: "{{ outputs.resolve_alias_state.values.cutover_state }}"
cases:
"ALREADY_ACTIVE":
- id: log_already_active
type: io.kestra.plugin.core.log.Log
description: "Records that nothing needs to change."
message: "Qdrant alias '{{ inputs.alias_name }}' already points at '{{
inputs.candidate_collection }}'. No change needed."
"CUTOVER_REQUIRED":
- id: check_approval_gate
type: io.kestra.plugin.core.flow.If
description: "Asks an operator to approve when require_manual_approval is on and
this is not a dry run."
condition: "{{ inputs.require_manual_approval and not inputs.dry_run }}"
then:
- id: notify_approval_requested
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
description: "Tells the channel that the checks passed and approval is needed."
url: "{{ secret('SLACK_WEBHOOK') }}"
payload: |
{"text": ":mag: *Qdrant Cutover Gate: Approval Required*\nChecks passed for `{{ inputs.candidate_collection }}` ({{ outputs.probe_candidate_collection.pointsCount | default(0) }} points).\n*Alias:* `{{ inputs.alias_name }}`\n*Current Target:* `{{ outputs.resolve_alias_state.values.existing_target }}`\n*New Target:* `{{ inputs.candidate_collection }}`\nResume execution `{{ execution.id }}` in Kestra to approve."}
- id: await_operator_approval
type: io.kestra.plugin.core.flow.Pause
description: "Waits for an engineer to resume with approval, and fails if nobody
does within the timeout."
timeout: PT4H
onResume:
- id: approved
description: "Tick to confirm the cutover to the candidate collection"
type: BOOL
defaults: false
- id: operator_notes
description: "Reason or ticket reference"
type: STRING
defaults: "none"
- id: validate_approval_decision
type: io.kestra.plugin.core.flow.If
description: "Cancels the cutover when the operator resumed without ticking
approval."
condition: "{{ outputs.await_operator_approval.onResume.approved != true }}"
then:
- id: notify_approval_rejected
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
description: "Tells the channel that the cutover was cancelled."
url: "{{ secret('SLACK_WEBHOOK') }}"
payload: |
{"text": ":no_entry_sign: *Qdrant Cutover Cancelled*\nThe operator did not approve the cutover of alias `{{ inputs.alias_name }}`.\n*Execution:* `{{ execution.id }}`"}
- id: abort_cutover_rejected
type: io.kestra.plugin.core.execution.Fail
description: "Fails the execution."
errorMessage: "The alias cutover was not approved by the operator."
- id: execute_cutover_gate
type: io.kestra.plugin.core.flow.If
description: "Changes the alias when dry_run is off, otherwise only logs the
plan."
condition: "{{ not inputs.dry_run }}"
then:
- id: apply_atomic_alias_mutation
type: io.kestra.plugin.core.http.Request
description: "Sends one atomic alias update that removes the old alias (when it
exists) and creates it on the candidate collection."
uri: "http://{{ inputs.qdrant_host }}:{{ inputs.qdrant_http_port
}}/collections/aliases"
method: POST
contentType: application/json
headers:
api-key: "{{ secret('QDRANT_API_KEY') }}"
body: |
{
"actions": [
{% if outputs.resolve_alias_state.values.alias_exists == 'yes' %}
{
"delete_alias": {
"alias_name": "{{ inputs.alias_name }}"
}
},
{% endif %}
{
"create_alias": {
"collection_name": "{{ inputs.candidate_collection }}",
"alias_name": "{{ inputs.alias_name }}"
}
}
]
}
- id: settle_consensus
type: io.kestra.plugin.core.flow.Sleep
description: "Gives the cluster a moment to apply the change before it is read
back."
duration: PT3S
- id: reverify_active_alias
type: io.kestra.plugin.core.http.Request
description: "Reads the aliases again."
uri: "http://{{ inputs.qdrant_host }}:{{ inputs.qdrant_http_port }}/aliases"
method: GET
contentType: application/json
headers:
api-key: "{{ secret('QDRANT_API_KEY') }}"
- id: verify_post_cutover_target
type: io.kestra.plugin.core.output.OutputValues
description: "Extracts the alias target after the change."
values:
verified_target: >-
{{ (outputs.reverify_active_alias.body | jq('[(.result.aliases
// [])[] | select(.alias_name == "' ~ inputs.alias_name ~ '")
| .collection_name] | first // "none"')) | first }}
cutover_verified: >-
{{ (outputs.reverify_active_alias.body | jq('[(.result.aliases
// [])[] | select(.alias_name == "' ~ inputs.alias_name ~ '"
and .collection_name == "' ~ inputs.candidate_collection ~
'")] | if length > 0 then "yes" else "no" end')) | first }}
- id: enforce_verification_verdict
type: io.kestra.plugin.core.flow.If
description: "Reports success, or fails when the alias does not point at the
candidate."
condition: "{{ outputs.verify_post_cutover_target.values.cutover_verified ==
'yes' }}"
then:
- id: notify_cutover_success
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
description: "Tells the channel that the cutover worked."
url: "{{ secret('SLACK_WEBHOOK') }}"
payload: |
{"text": ":white_check_mark: *Qdrant Alias Cutover Succeeded*\n*Alias:* `{{ inputs.alias_name }}`\n*New Target:* `{{ inputs.candidate_collection }}`\n*Previous Target:* `{{ outputs.resolve_alias_state.values.existing_target }}`\n*Points:* {{ outputs.probe_candidate_collection.pointsCount | default(0) }}\n*Execution:* `{{ execution.id }}`"}
- id: log_cutover_complete
type: io.kestra.plugin.core.log.Log
description: "Records the cutover."
message: "Cutover complete: alias '{{ inputs.alias_name }}' now points at '{{
inputs.candidate_collection }}' (was '{{
outputs.resolve_alias_state.values.existing_target }}')."
else:
- id: alert_verification_failed
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
description: "Tells the channel that the alias does not point at the candidate."
url: "{{ secret('SLACK_WEBHOOK') }}"
payload: |
{"text": ":rotating_light: *CRITICAL: Qdrant Alias Cutover Verification Failed*\n*Alias:* `{{ inputs.alias_name }}`\n*Expected Target:* `{{ inputs.candidate_collection }}`\n*Actual Target:* `{{ outputs.verify_post_cutover_target.values.verified_target }}`\n*Execution:* `{{ execution.id }}`"}
- id: fail_post_cutover_verification
type: io.kestra.plugin.core.execution.Fail
description: "Fails the execution."
errorMessage: "Post-cutover verification failed: alias {{ inputs.alias_name }}
points at '{{
outputs.verify_post_cutover_target.values.verified_target
}}', expected '{{ inputs.candidate_collection }}'."
else:
- id: log_dry_run_summary
type: io.kestra.plugin.core.log.Log
description: "Logs the plan without changing anything."
message: "DRY RUN: checks and canary search passed for '{{
inputs.candidate_collection }}'. Would move alias '{{
inputs.alias_name }}' from '{{
outputs.resolve_alias_state.values.existing_target }}' to '{{
inputs.candidate_collection }}' ({{
outputs.probe_candidate_collection.pointsCount | default(0) }}
points)."
errors:
- id: alert_flow_error
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
description: "Tells the channel when the flow stops with an error, whether a
gate rejected the cutover or a task failed."
url: "{{ secret('SLACK_WEBHOOK') }}"
payload: |
{"text": ":fire: *Qdrant Cutover Flow Stopped*\nFlow `{{ flow.id }}` stopped with an error in execution `{{ execution.id }}`."}