Webhook icon
Schedule icon
Queries icon
Query icon
If icon
Fail icon
Request icon
Switch icon
Log icon
Loop icon
Sequential icon
SlackIncomingWebhook icon
Pause icon
OutputValues icon
LoopUntil icon
Exit icon

Find and Repair Drift Between a Postgres Table and Its Meilisearch Index

Detect missing, stale and orphaned Meilisearch documents against Postgres, repair them in batches, and gate large deletes behind approval.

Categories
DataInfrastructure

Most apps index into Meilisearch from their own code: a hook after each write, or a queue worker. The index then drifts from the database without anyone noticing:

  • a bulk import, a restore or a backfill writes to the table directly and never reaches the indexer;
  • a worker crashes or a Meilisearch task fails, and nobody reads the task list;
  • someone deletes rows in SQL, or edits a document by hand in the Meilisearch dashboard.

Users then find products that no longer exist, miss new ones, or see old prices. This blueprint treats the Postgres table (or a view over it) as the source of truth. It compares every document with its row, repairs the differences, and tells you exactly what was wrong. It needs only Postgres and Meilisearch, both of which run locally in Docker.

How it works

  1. after_bulk_change (Webhook, called after a bulk change) or nightly (Schedule, disabled by default) starts a run. concurrency queues overlapping runs; while a run waits (up to 24 hours) for an orphan-delete approval, the queued runs wait behind it.
  2. ensure_tables creates two working tables. check_source resolves the table or view and its columns; source_guard and id_guard (If) stop on an unknown source or on duplicated or null ids.
  3. check_meilisearch (retries) and index_info read the index. check_index (Switch) stops on an API error or a primary key that differs from the source, and notes a missing index (the first batch creates it).
  4. read_index (Loop, 4 pages at a time) pages through the index, fetching only the source's columns, and stages each page in Postgres.
  5. compare joins rows and documents in one statement and classifies each difference:
    • missing: a row without a document;
    • stale: any field differs, compared as JSON values (so 19.90 equals 19.9); the report names the fields;
    • orphan: a document without a row;
    • not indexable: a row whose id Meilisearch would reject. It is set aside so it cannot fail a whole batch.
  6. reindex (Loop of Sequential with allowFailure) reads the current rows in batches of batch_size and sends each batch as one Meilisearch task.
  7. handle_orphans: up to max_deletes_without_approval orphans are deleted. Above that, ask_approval posts to Slack and wait_for_approval (Pause, 24 h) asks on-call. confirm_orphans then keeps only ids that still have no row.
  8. wait_for_tasks (LoopUntil) polls the Meilisearch tasks until none is enqueued or processing. summarize lists failed tasks with Meilisearch's error.
  9. notify posts the report (silent when there is no drift); any_failed ends the run WARNING when a task failed.

Error handling

  • Stops before writing on a bad setup: unknown table, duplicated or null ids, Meilisearch down (4 attempts), a rejected API key (HTTP 403 with Meilisearch's message), or an index whose primary key is not the source's id column. The errors alert names the task and the cause.
  • Large deletes need a person. 150 orphans out of 2,496 documents usually means a truncated table or a wrong source, not drift. The approval message says so, and nothing is deleted if nobody answers within 24 hours.
  • Deletes are checked again. A row restored while the approval waited is kept, and the report says how many came back.
  • One bad row does not block the rest. Ids Meilisearch would reject are reported and skipped. A document Meilisearch refuses while indexing (for example, a store with no latitude in a _geo index) fails its task. The report quotes Meilisearch's error, which names the document; the other tasks still apply, and the run ends WARNING. Meilisearch merges consecutive document tasks into one internal batch, so the failure can take a neighbouring task with it: in testing, one store failed two tasks of 50 documents. Fix the row and the next run re-sends everything still missing.
  • Nothing to undo. Re-indexing writes the current rows, so it is safe to repeat. Working rows are deleted at the end of every run, in the errors block too, and leftovers of a crashed run expire after a day.

What this blueprint teaches about Kestra

  • Paginating an API with a Loop over offsets computed in Pebble (range) and a concurrencyLimit.
  • Waiting for asynchronous work with LoopUntil and failOnMaxReached, and reading the last iteration's output afterwards.
  • Isolating batches in a Loop with Sequential + allowFailure, and collecting per-batch results through Loop outputs.
  • Using Postgres as the comparison engine for API data (staged pages, one FULL JOIN), instead of moving the comparison into a script.
  • A Pause that only appears above a threshold, with a default that does nothing.

Prerequisites

  • PostgreSQL 13 or later, with a role that can read the source and create two tables (meili_sync_snapshot, meili_sync_diff).
  • Meilisearch 1.x and an API key that can read and write documents and read tasks (the admin key, or a key with documents.*, indexes.get, stats.get, tasks.get).
  • A Slack incoming webhook.

Secrets

  • POSTGRES_URL, POSTGRES_USERNAME, POSTGRES_PASSWORD: the database that holds the source table.
  • MEILISEARCH_API_KEY: Meilisearch API key.
  • SLACK_WEBHOOK_URL: Slack incoming webhook.
  • MEILI_DRIFT_WEBHOOK_KEY: a long random string, the key in the webhook URL ($KEY in the trigger example).

Inputs

  • source_table (STRING, default public.products): table or view; each column becomes a document field.
  • primary_key (STRING, default id): the id column, equal to the index's primary key.
  • index_uid (STRING, default products), meilisearch_url (STRING, default http://localhost:7700).
  • page_size (INT, default 1000), batch_size (INT, default 500).
  • max_deletes_without_approval (INT, default 100).
  • dry_run (BOOL, default false): report only.
  • kestra_url (STRING): used for links in Slack.

Outputs

  • drift (JSON): source_rows, index_documents, missing, stale, orphans, invalid_ids, dry_run. Empty when the run ends WARNING, and zeros when it fails before comparing; the Slack report has the numbers in both cases.
  • failed_tasks (STRING): one line per failed Meilisearch task.

Quick start

  1. Start Postgres and Meilisearch: docker run -d -p 5432:5432 -e POSTGRES_PASSWORD=... postgres:16 and docker run -d -p 7700:7700 -e MEILI_MASTER_KEY=... getmeili/meilisearch.
  2. Create a public.products table (any columns, with an id primary key) and add the secrets.
  3. Run the flow. With 2,500 rows and no index it reports "Missing: 2500 ... re-sent from the table" and builds the index in 5 batches.
  4. Delete 3 rows in SQL, edit 3 prices, and delete or edit a few documents in Meilisearch, then run it again: it lists each kind with the ids and the fields that differ ("5 (price+updated_at), 7 (name)") and repairs them.
  5. For an index your app already writes, first run with dry_run: true and check that no document is reported stale for format reasons. If it is, shape the view (for example extract(epoch FROM updated_at) for a numeric timestamp) until both sides match.

How to extend

  • Point source_table at a view to join tables, rename fields, build _geo from latitude and longitude, or filter out unpublished rows.
  • Run one copy per index with its own inputs, or call this flow as a subflow from your import pipeline.
  • Swap Slack for another notifier, or also send the drift output to your metrics.

Links

See How

New to Kestra?

Use blueprints to kickstart your first workflows.