Setting Alert Thresholds for Index Lag
Pinning an index-lag alert to a round number like “30 seconds” guarantees one of two outcomes: it pages on every routine bulk load, or it stays silent while search quietly serves week-old graph state. This guide gives you the arithmetic to pick a threshold that reflects your actual write path — it derives the number from your Elasticsearch or OpenSearch refresh_interval plus your largest bulk-ingest window, writes the PromQL, sets a dwell that ignores load blips, and proves the rule fires with a synthetic lag before you trust it. It sits under Alerting Thresholds and Escalation and narrows that reference to one signal: the write-to-visible latency between a durable graph commit and the moment a has() predicate can see it. The failure this prevents is a stale-read incident that no one is paged for because the threshold was guessed instead of computed.
Prerequisites
Confirm each item before writing the rule. Skipping the refresh-interval check is the usual reason a “carefully chosen” threshold still pages on every batch job.
- JanusGraph 0.6.x or 1.0.x with a mixed index on Elasticsearch 7.x/8.x or OpenSearch 1.x/2.x, and a metric exporting write-to-visible lag as
janusgraph_index_write_to_visible_seconds(or derive it — see Step 1). The metric names come from the Prometheus and JMX metrics reference. - Prometheus scraping JanusGraph and the index cluster, with
promtoolon your path for rule validation. - A Pushgateway reachable from your workstation for the synthetic test-fire in Step 5.
- Knowledge of your index
refresh_interval. Read it back withGET /<index>/_settings— do not assume the 1s default; production indexes are frequently set to 30s to cut segment churn. - Your largest routine bulk-ingest window in seconds — the wall-clock time your biggest scheduled load takes to flush through to the index. If you do not know it, measure one run before setting the threshold.
- Write access to the Prometheus rule files and permission to reload (
SIGHUPorPOST /-/reload).
Step 1 — Define the index-lag SLI
The lag you alert on is write-to-visible latency: seconds between a durable storage commit and the point a mixed-index query returns the element. If your build already exports janusgraph_index_write_to_visible_seconds, use it directly. If not, derive it from the index write-queue timestamp versus the last-committed graph mutation timestamp as a recording rule so the alert expression stays cheap:
groups:
- name: janusgraph-index-lag-sli
rules:
- record: janusgraph:index_write_to_visible_seconds:max
expr: |
max by (index_name) (
time() - janusgraph_index_last_visible_commit_timestamp_seconds
)
This is a max, not an average — a single index shard lagging is a stale-read source even if the mean looks healthy. The consistency boundary this SLI measures is exactly the acknowledgment window discussed in Eventual vs Strong Consistency.
Verify: the series exists and is non-negative under normal load.
curl -s http://localhost:9090/api/v1/query \
--data-urlencode 'query=janusgraph:index_write_to_visible_seconds:max' \
| jq '.data.result[] | {index: .metric.index_name, lag: .value[1]}'
Step 2 — Pick a threshold from refresh_interval and the bulk window
The threshold must sit above the highest lag routine operation produces, with headroom, so normal ingestion never pages. Compute it from two measured inputs — the index refresh_interval (steady-state floor) and your largest bulk-flush window (transient ceiling) — and add a headroom multiplier:
Worked example: a 30-second refresh_interval and a 20-second bulk-flush window give a bulk band ceiling of 50 seconds; with a 1.5x headroom factor the threshold is 75 seconds. Round to a clean 75. The headroom multiplier absorbs normal variance in flush timing; drop it toward 1.2 only if your bulk window is very stable, and raise it toward 2.0 if batch sizes vary widely.
# Read the live refresh_interval so the math uses reality, not the default
curl -s 'http://es:9200/janusgraph_vertices/_settings' \
| jq '.[].settings.index.refresh_interval'
Verify: confirm your chosen threshold sits comfortably above observed peak lag during a real bulk load.
# During a scheduled bulk load, capture the peak — it must stay under the threshold
curl -s http://localhost:9090/api/v1/query \
--data-urlencode 'query=max_over_time(janusgraph:index_write_to_visible_seconds:max[15m])' \
| jq '.data.result[].value[1]'
If peak observed lag during normal ingestion is already near your threshold, the threshold is too tight — raise the headroom factor rather than accepting a rule that will page on the next batch job.
Step 3 — Write the PromQL alert expression
Encode the threshold as an alerting rule. Compare the max SLI against the computed number and attach the routing labels and a runbook up front:
groups:
- name: janusgraph-index-lag
rules:
- alert: JanusGraphIndexLagHigh
expr: janusgraph:index_write_to_visible_seconds:max > 75
for: 5m
labels:
severity: critical
service: janusgraph
component: index
annotations:
summary: "Index {{ $labels.index_name }} lag {{ $value | humanizeDuration }}"
description: "Write-to-visible lag exceeded the 75s threshold; search is serving stale graph state."
runbook: "https://runbooks.internal/janusgraph/index-lag"
# Missing metric must page too — a stopped exporter reads as healthy otherwise
- alert: JanusGraphIndexLagMetricAbsent
expr: absent(janusgraph:index_write_to_visible_seconds:max)
for: 10m
labels:
severity: warning
service: janusgraph
component: index
annotations:
summary: "Index-lag SLI metric is absent — exporter or scrape is broken"
runbook: "https://runbooks.internal/janusgraph/metric-absent"
The paired absent() alert is not optional: without it, an exporter that dies makes the lag series vanish and the primary alert reads the missing data as healthy. Which backing index this predicate actually queries — and therefore whose lag you are measuring — depends on the routing described in Mixed-Index Routing.
Verify: the rule parses before you reload Prometheus.
promtool check rules janusgraph-index-lag.yml
Step 4 — Set the for-duration and severity
The for: clause is what separates a stuck writer from a transient bulk spike. A single bulk load can briefly clip the threshold; requiring the breach to persist filters it out. Use 5m for index lag — long enough to ride out one batch job’s flush, short enough that a genuinely stuck writer pages within the window a stale-read incident becomes user-visible.
Set severity: critical only if stale search results are user-facing. If this index backs an internal analytics path where a few minutes of staleness is tolerable, drop it to warning so it routes to a ticket instead of the pager. The severity label is the routing key the Alertmanager tree in the parent Alerting Thresholds and Escalation reference matches on.
Verify: reload and confirm the rule is registered with the expected for and labels.
curl -s -XPOST http://localhost:9090/-/reload
curl -s http://localhost:9090/api/v1/rules \
| jq '.data.groups[].rules[] | select(.name=="JanusGraphIndexLagHigh") | {name, duration, labels}'
Step 5 — Test-fire with a synthetic lag
Never wait for a real incident to learn whether the rule works. Push a synthetic lag value above the threshold through the Pushgateway, watch the alert march from inactive to pending to firing, then clean up.
# 1. Push a synthetic lag of 90s (above the 75s threshold)
echo 'janusgraph_index_last_visible_commit_timestamp_seconds 0' \
| curl --data-binary @- \
http://pushgateway:9091/metrics/job/synthetic_lag/index_name/janusgraph_vertices
# (time() - 0 yields a very large lag, forcing the SLI over threshold)
# 2. Watch the alert state transition over the next few minutes
watch -n 15 "curl -s http://localhost:9090/api/v1/alerts \
| jq '.data.alerts[] | select(.labels.alertname==\"JanusGraphIndexLagHigh\") | {state, activeAt}'"
After the for: 5m dwell elapses the alert must show "state": "firing". Confirm the page actually reached the pager (or a test receiver), then remove the synthetic series so it does not linger and re-fire:
curl -s -X DELETE \
http://pushgateway:9091/metrics/job/synthetic_lag/index_name/janusgraph_vertices
Verify: after deletion, the alert returns to inactive within one evaluation-plus-scrape cycle.
curl -s http://localhost:9090/api/v1/alerts \
| jq '.data.alerts[] | select(.labels.alertname=="JanusGraphIndexLagHigh") | .state'
Fallback and rollback procedures
Validate between actions rather than stacking changes.
If Step 2 gives a threshold below observed peak lag. Your bulk window measurement is stale or batch sizes grew. Re-measure the largest routine load with max_over_time(...[30m]), raise the headroom multiplier to 2.0, and recompute — do not ship a rule you already know pages on normal traffic.
If Step 3 fails promtool. A YAML indentation error or an unescaped template usually causes it. Fix the reported line, re-run promtool check rules, and only reload once it passes; a broken rule file makes Prometheus refuse the entire reload, not just the bad rule.
If Step 5 never reaches firing. Either the synthetic metric is not being scraped (confirm the Pushgateway target is up in Prometheus), the recording rule feeding the SLI is not evaluating, or the for: has not elapsed. Query the raw pushed series directly before blaming the alert.
If the new threshold pages spuriously in production. Roll back by restoring the previous rule file from version control and reloading; then widen the headroom factor or lengthen for: to 10m and re-canary against a staging Prometheus before re-applying. Never silence the alert as a fix — a silenced lag alert is indistinguishable from a working one right up until the stale-read incident.
Related
- Up a level: Alerting Thresholds and Escalation — the parent reference for threshold models, Alertmanager routing, and inhibition.
- Paging Rules for Storage-Backend Saturation — the sibling procedure for saturation signals and page-versus-inhibit decisions.
- Mixed-Index Routing — which backing index answers the query whose lag this threshold measures.
- Eventual vs Strong Consistency — the acknowledgment-window trade-off that defines what “lag” means for your reads.