id: mlflow-model-registry-sync
namespace: company.team
description: |
Compare the MLflow model registry with the production serving registry table once a day and
alert Slack when deployed models lag behind the registered version, have no serving row at all,
or have been archived in MLflow. The verdict, gap count, and stale list are published as outputs.
triggers:
- id: daily_registry_reconcile
type: io.kestra.plugin.core.trigger.Schedule
description: Reconcile MLflow registry state against the serving registry every
day at 05:00 UTC (shipped disabled, enable it once the secrets exist).
cron: "0 5 * * *"
disabled: true
inputs:
- id: env
type: STRING
displayName: Target environment
description: Environment key used to filter serving registry rows (for example
production or staging).
defaults: "production"
- id: registry_gap_tolerance
type: INT
displayName: Registry gap tolerance
description: Number of lagging or missing models tolerated before the alert
branch fires.
defaults: 0
- id: reconciliation_repo
type: STRING
displayName: Reconciliation repository (optional)
description: GitHub owner and repository that receives a follow up issue when
drift is found. Leave empty to skip that call.
defaults: ""
tasks:
# --- 1. collect MLflow registry state ---
- id: fetch_registered_models
type: io.kestra.plugin.core.http.Request
description: List every registered model in MLflow through the 2.0 REST search endpoint.
uri: "{{ secret('MLFLOW_TRACKING_URI')
}}/api/2.0/mlflow/registered-models/search?max_results=1000"
method: GET
headers:
Accept: "application/json"
Authorization: "Bearer {{ secret('MLFLOW_TOKEN') }}"
retry:
type: exponential
interval: PT2S
maxInterval: PT10S
maxAttempts: 3
- id: fetch_model_versions
type: io.kestra.plugin.core.http.Request
description: Pull every model version with its current stage so the comparison
sees promotions and archivals.
uri: "{{ secret('MLFLOW_TRACKING_URI')
}}/api/2.0/mlflow/model-versions/search?max_results=1000"
method: GET
headers:
Accept: "application/json"
Authorization: "Bearer {{ secret('MLFLOW_TOKEN') }}"
retry:
type: exponential
interval: PT2S
maxInterval: PT10S
maxAttempts: 3
# --- 2. read what production actually serves ---
- id: query_deployed_models
type: io.kestra.plugin.jdbc.postgresql.Query
description: Read name, version, and deployed_at for the chosen environment from
the production serving registry table.
url: "{{ secret('POSTGRES_JDBC_URL') }}"
username: "{{ secret('POSTGRES_USERNAME') }}"
password: "{{ secret('POSTGRES_PASSWORD') }}"
fetchType: FETCH
sql: |
SELECT
model_name AS name,
model_version AS version,
deployed_at::text AS deployed_at
FROM public.deployed_models
WHERE environment = '{{ inputs.env }}'
ORDER BY model_name;
# --- 3. reconcile registry against serving rows ---
- id: reconcile
type: io.kestra.plugin.scripts.python.Script
description: Classify every model as gap, missing, or stale and emit the verdict
through the JSON outputs protocol.
inputFiles:
mlflow_models.json: "{{ outputs.fetch_registered_models.body }}"
mlflow_versions.json: "{{ outputs.fetch_model_versions.body }}"
deployed_rows.json: "{{ outputs.query_deployed_models.rows | json }}"
config.json: |
{"env": "{{ inputs.env }}", "tolerance": {{ inputs.registry_gap_tolerance }}}
script: |
import datetime
import json
def load_json(path):
try:
with open(path, "r", encoding="utf-8") as handle:
raw = handle.read().strip()
if not raw:
return None
return json.loads(raw)
except Exception:
return None
def as_int(value):
try:
return int(str(value).strip())
except (TypeError, ValueError):
return None
def clean(value):
return str(value).replace("\\", "/").replace('"', "'")
config = load_json("config.json") or {}
tolerance = as_int(config.get("tolerance"))
if tolerance is None or tolerance < 0:
tolerance = 0
env = clean(config.get("env") or "production")
models_doc = load_json("mlflow_models.json")
versions_doc = load_json("mlflow_versions.json")
deployed_rows = load_json("deployed_rows.json")
checked_at = (
datetime.datetime.now(datetime.timezone.utc)
.replace(microsecond=0)
.isoformat()
.replace("+00:00", "Z")
)
registered_names = set()
deployed_map = {}
without_target_stage = []
gaps = []
missing = []
stale = []
gaps_text = "none"
missing_text = "none"
stale_text = "none"
reason = "ok"
if not isinstance(models_doc, dict) or not isinstance(versions_doc, dict) or not isinstance(deployed_rows, list):
reason = "unreadable_input"
verdict = "UNKNOWN"
gap_count = 0
stale_models = []
stale_count = 0
needs_attention = True
else:
registered_entries = models_doc.get("registered_models")
if not isinstance(registered_entries, list):
registered_entries = []
for entry in registered_entries:
if isinstance(entry, dict) and entry.get("name"):
registered_names.add(str(entry["name"]))
version_entries = versions_doc.get("model_versions")
if not isinstance(version_entries, list):
version_entries = []
by_model = {}
for entry in version_entries:
if not isinstance(entry, dict):
continue
name = entry.get("name")
version = as_int(entry.get("version"))
if not name or version is None:
continue
stage = str(entry.get("current_stage") or "None").strip()
by_model.setdefault(str(name), []).append((version, stage))
target = {}
for name, items in by_model.items():
production = [v for v, s in items if s.lower() == "production"]
staging = [v for v, s in items if s.lower() == "staging"]
if production:
target[name] = max(production)
elif staging:
target[name] = max(staging)
for name in sorted(registered_names):
if name in by_model and name not in target:
without_target_stage.append(name)
for row in deployed_rows:
if not isinstance(row, dict):
continue
name = row.get("name") or row.get("model_name")
version = as_int(row.get("version"))
if version is None:
version = as_int(row.get("model_version"))
if not name or version is None:
continue
deployed_map[str(name)] = version
for name in sorted(target):
registered_version = target[name]
if name not in deployed_map:
missing.append({"name": name, "registered_version": registered_version})
elif deployed_map[name] < registered_version:
gaps.append({
"name": name,
"deployed_version": deployed_map[name],
"registered_version": registered_version,
})
for name in sorted(deployed_map):
deployed_version = deployed_map[name]
items = by_model.get(name)
if not items:
stale.append({"name": name, "version": deployed_version, "reason": "absent_from_mlflow"})
continue
stage = None
for version, current_stage in items:
if version == deployed_version:
stage = current_stage
break
if stage is None:
stale.append({"name": name, "version": deployed_version, "reason": "version_not_registered"})
elif stage.lower() == "archived":
stale.append({"name": name, "version": deployed_version, "reason": "archived"})
gap_count = len(gaps) + len(missing)
stale_models = [
"%s v%s (%s)" % (clean(s["name"]), s["version"], s["reason"]) for s in stale
]
stale_count = len(stale)
needs_attention = gap_count > tolerance or stale_count > 0
verdict = "DRIFT" if needs_attention else "IN_SYNC"
gaps_text = "; ".join(
"%s (deployed v%s, registered v%s)"
% (clean(g["name"]), g["deployed_version"], g["registered_version"])
for g in gaps
) or "none"
missing_text = "; ".join(
"%s (registered v%s, no serving row)" % (clean(m["name"]), m["registered_version"])
for m in missing
) or "none"
stale_text = "; ".join(stale_models) or "none"
payload = {
"outputs": {
"verdict": verdict,
"gap_count": gap_count,
"stale_models": stale_models,
"stale_count": stale_count,
"missing_count": len(missing),
"gaps": gaps,
"missing": missing,
"stale": stale,
"gaps_text": gaps_text,
"missing_text": missing_text,
"stale_text": stale_text,
"needs_attention": bool(needs_attention),
"tolerance": tolerance,
"env": env,
"reason": reason,
"checked_at": checked_at,
"registry_model_count": len(registered_names),
"deployed_count": len(deployed_map),
"without_target_stage": sorted(without_target_stage),
}
}
print("::" + json.dumps(payload) + "::")
print(
"verdict=%s gap_count=%d missing=%d stale=%d reason=%s"
% (verdict, gap_count, len(missing), len(stale), reason)
)
# --- 4. alert branch ---
- id: drift_gate
type: io.kestra.plugin.core.flow.If
description: Alert when the gap count is above the tolerance or any serving row
is stale, otherwise record a green run.
condition: "{{ outputs.reconcile.vars.needs_attention }}"
then:
- id: slack_drift_alert
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
description: Post the gap, missing, and stale lists to the alert channel with
the verdict and the execution link.
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
payload: |
{
"text": ":rotating_light: MLflow registry drift for {{ outputs.reconcile.vars.env }} at {{ outputs.reconcile.vars.checked_at }}. gap_count {{ outputs.reconcile.vars.gap_count }}, missing {{ outputs.reconcile.vars.missing_count }}, stale {{ outputs.reconcile.vars.stale_count }}, verdict {{ outputs.reconcile.vars.verdict }}. Execution {{ execution.id }}.",
"blocks": [
{"type": "section", "text": {"type": "mrkdwn", "text": "*MLflow registry reconciliation* for `{{ outputs.reconcile.vars.env }}`\n*Verdict* `{{ outputs.reconcile.vars.verdict }}` at {{ outputs.reconcile.vars.checked_at }}\n*Tolerance* {{ outputs.reconcile.vars.tolerance }} gap(s) allowed\n*MLflow registered models* {{ outputs.reconcile.vars.registry_model_count }}, *serving rows* {{ outputs.reconcile.vars.deployed_count }}"}},
{"type": "section", "text": {"type": "mrkdwn", "text": "*Gaps* (serving version behind the registered version)\n{{ outputs.reconcile.vars.gaps_text }}"}},
{"type": "section", "text": {"type": "mrkdwn", "text": "*Missing* (registered in MLflow, no serving row)\n{{ outputs.reconcile.vars.missing_text }}"}},
{"type": "section", "text": {"type": "mrkdwn", "text": "*Stale* (serving row archived or absent in MLflow)\n{{ outputs.reconcile.vars.stale_text }}"}}
]
}
- id: open_reconciliation_issue
type: io.kestra.plugin.core.http.Request
description: Optional follow up that opens an issue in the reconciliation
repository so a human owns the repair.
runIf: "{{ inputs.reconciliation_repo != '' }}"
allowFailure: true
uri: "https://api.github.com/repos/{{ inputs.reconciliation_repo }}/issues"
method: POST
contentType: "application/json"
headers:
Authorization: "Bearer {{ secret('GITHUB_TOKEN') }}"
Accept: "application/vnd.github.v3+json"
X-GitHub-Api-Version: "2022-11-28"
body: |
{
"title": "MLflow registry reconciliation {{ outputs.reconcile.vars.env }} {{ outputs.reconcile.vars.checked_at }}",
"labels": ["mlops", "registry-sync"],
"body": "Verdict {{ outputs.reconcile.vars.verdict }} at {{ outputs.reconcile.vars.checked_at }}\n\nGaps: {{ outputs.reconcile.vars.gaps_text }}\nMissing: {{ outputs.reconcile.vars.missing_text }}\nStale: {{ outputs.reconcile.vars.stale_text }}\n\nExecution {{ execution.id }}"
}
else:
- id: log_in_sync
type: io.kestra.plugin.core.log.Log
description: Green path, record the in sync numbers so the execution history
shows every check that ran.
message: "MLflow registry and the {{ inputs.env }} serving registry are in sync.
gap_count {{ outputs.reconcile.vars.gap_count }}, stale {{
outputs.reconcile.vars.stale_count }}, checked_at {{
outputs.reconcile.vars.checked_at }}."
errors:
- id: slack_alert
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
description: Alert the channel when the reconciliation itself fails, so a broken
check never looks like a clean one.
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
payload: |
{"text": "FAILED {{ flow.id }} run {{ execution.id }}"}
outputs:
- id: gap_count
type: INT
value: "{{ outputs.reconcile.vars.gap_count }}"
- id: stale_models
type: JSON
value: "{{ outputs.reconcile.vars.stale_models | toJson }}"
- id: verdict
type: STRING
value: "{{ outputs.reconcile.vars.verdict }}"
- id: checked_at
type: STRING
value: "{{ outputs.reconcile.vars.checked_at }}"