Runbook for operating the service in production. Covers how to tell when something is wrong, what the service does automatically, and when a human needs to step in.
The service exposes a small HTTP server on HTTP_PORT (default 8080) with three endpoints:
| Path | Purpose |
|---|---|
/healthz |
Liveness probe. Always returns 200 while the process is alive. Deliberately cheap — a stuck event loop can still return 200, which is the point of separating it from readiness. |
/readyz |
Readiness probe. Returns 200 only when RabbitMQ is connected and the process is not shutting down. Flips to 503 as soon as SIGTERM is received so load balancers and PodDisruptionBudget logic stop routing to the pod before the drain begins. |
/metrics |
Prometheus text exposition. Scrape interval of 15–30 s is plenty; the metrics below are counters and gauges, not high-cardinality. |
In Kubernetes, point livenessProbe at /healthz and readinessProbe at /readyz. The default Helm chart values do this for you.
| Metric | Type | What it tells you |
|---|---|---|
nextcloud_migration_active |
gauge | How many migrations are in-flight right now. Sustained at the concurrency cap means you're queue-bound. |
nextcloud_migration_started_total |
counter | Migration arrival rate. Alert on a drop to zero while the RabbitMQ queue has items — indicates the consumer has stopped picking work up. |
nextcloud_migration_finished_total{outcome} |
counter | outcome="completed", outcome="failed", or outcome="canceled". Alert on a sustained failure ratio above your tolerance (e.g. > 10%). User-driven cancels are expected and should not page. |
nextcloud_migration_cancels_received_total{outcome} |
counter | Cancel messages received by handler outcome: recorded, already_requested, ignored_terminal, not_found, pre_start. A spike in not_found or ignored_terminal points at a publisher sending stale cancels. |
nextcloud_migration_files_total{outcome} |
counter | Per-file outcomes (transferred, skipped, failed). skipped is normal during resumes; failed is not. |
nextcloud_migration_file_transfer_duration_seconds |
histogram | Per-file transfer latency. A shift in the upper buckets is an early signal of Stack or Nextcloud slowdown. |
nextcloud_migration_cloudery_token_total{outcome} |
counter | outcome="success" vs "failed". A spike in failed tokens points at Cloudery, not at this service. |
nextcloud_migration_rabbitmq_connected |
gauge | 1 when connected, 0 otherwise. Complements the /readyz probe for dashboards. |
Default Node.js runtime metrics (event loop lag, heap, GC) are included automatically by prom-client.
Even with metrics, the event log remains the source of truth for per-migration diagnosis:
- Process uptime. Log lines with
event: service.starting,service.shutting_down,service.stoppeddelimit lifetimes. - RabbitMQ connection. An
event: rabbitmq.connectedon startup means the broker is reachable. - Consumption rate. Count
event: consumer.message_receivedover time.
If the process is alive, /readyz is green, but the queue keeps growing, look at the concurrency cap next.
Every log line carries a stable event field. Route these to your alert pipeline:
| Event | Severity | When |
|---|---|---|
service.stopped with drained: false |
info | Shutdown hit the 60 s drain deadline. Heartbeat recovery will pick up the stragglers, but investigate if this keeps happening. |
cloudery.token_failed with attempts: 3 |
error | Cloudery retry budget exhausted — all three attempts failed. A short blip is fine; sustained means the Cloudery is down or your CLOUDERY_TOKEN is wrong. Alert on this specific variant with attempts, not on every cloudery.token_failed: the warn-level ones without attempts fire on each individual attempt and are expected noise during a retry. |
consumer.migration_unhandled_error |
error | A migration blew past the internal catch. Code bug; capture the stack trace. |
migration.tracking_update_failed |
error | Couldn't persist the terminal state of a migration. The doc is now a stale-running zombie; heartbeat logic will eventually reclaim it. |
runner.drain_timeout |
warn | In-flight migrations did not finish within the shutdown deadline. Expected during rolling deploys; persistent occurrences mean you're running very long migrations. |
The cloudery.token_retry, stack.token_refresh, and consumer.resuming_stale events are normal — informational only.
Symptom: user reports their migration has been running forever in the UI.
Check the tracking document's last_heartbeat_at:
- Younger than 30 minutes: the migration is still active somewhere. If no consumer log lines confirm it, the consumer probably crashed between heartbeats — wait a bit longer or look for a new consumer picking it up.
- Older than 30 minutes: the doc is a zombie. The next message for this migration ID (e.g. a user retry) will trigger automatic resume — the service logs
consumer.resuming_staleand takes over, skipping files already in Cozy. No manual intervention needed.
If no new message is coming (the original was ACKed and the queue is empty), publish a replacement to the exchange with the same migrationId. The stale-running recovery handles the rest.
A tracking doc in failed status carries two pieces of information:
failure_reason— the migration-level fatal error (quota exceeded, source path missing, etc.).errors[]— per-file failures that happened before the fatal error, if any.
Common causes:
- Insufficient quota:
failure_reasonbegins withInsufficient quota. The user needs more Cozy storage; re-triggering the migration won't help. - Source path not found:
failure_reasonisSource path not found in Nextcloud. The user gave a path that doesn't exist in their Nextcloud; they need to fix the request, not retry as-is. - Per-file errors only:
failure_reasonis null, some files are inerrors[]but the migration iscompleted. The migration finished; those specific files need individual attention (permissions, corrupted source, etc.).
Queue: migration.nextcloud.commands.dlq.
The DLQ accumulates messages that:
- Hit a transient failure 3 times in a row (Stack 5xx, CouchDB conflict exhaustion, network partition).
- Failed JSON decoding at the library level.
Malformed payloads, missing tracking docs, and invalid source paths do not end up here — those are handled explicitly by the service and either marked failed or ACKed silently.
Inspect the DLQ via the RabbitMQ management UI. To replay: move a message back to migration.nextcloud.commands using the management UI's "Move messages" action. If something ends up in the DLQ repeatedly, the cause is not transient — investigate before replaying blindly.
MAX_CONCURRENT_MIGRATIONS: raise when the instance has headroom and migrations are queued; lower on cramped hosts to stop the consumer from starving neighbours.FLUSH_INTERVAL: raise under CouchDB write pressure; lower for more frequent progress bar updates (at the cost of more writes).LOG_LEVEL: bump todebugtemporarily when diagnosing a specific instance; never leave it there in production — the per-file events become very noisy.
See Configuration for the full list.
On SIGTERM or SIGINT:
/readyzflips to 503 immediately so load balancers stop routing to the pod.- RabbitMQ subscription closes — no new messages are accepted.
- The process waits up to 60 seconds for in-flight migrations to finish naturally.
- The ops HTTP server closes.
- If the drain deadline passed, the process exits anyway. Anything still running becomes a stale-running zombie and is reclaimed by the next consumer.
This matters for rolling deployments: if you expect migrations to routinely run longer than 60 seconds (most do), you'll see service.stopped with drained: false on every rollout. That's not a regression — it's the heartbeat recovery doing its job.