Maintenance
When Bliss data looks wrong or a background process seems stuck, this guide tells you what to check first, where the tools live, and how to recover. Most issues resolve via the Settings → Maintenance tab (tenant admins only) without touching the database directly.
Finding the Maintenance tab
- Sign in as a tenant admin (your role is
admin, notmemberorviewer). - Go to Settings in the sidebar.
- Click the Maintenance tab (wrench icon). If you don’t see it, your account isn’t an admin — ask another admin on your tenant to grant the role from Settings → Users.
The tab has several maintenance options (analytics rebuilds, a full rebuild, a securities data refresh, a single-asset rebuild and a subscriptions full history scan). The history of the last 20 manual rebuilds and live progress moved to Settings → Administration → Processing in #100 (the See progress link at the bottom of Maintenance takes you there; see Why are my numbers updating?). All operations are safe to run — they rebuild caches from source-of-truth data, they don’t lose transactions or portfolio items.
Symptom-based playbook
”My expenses for a past month look wrong in the Financial Summary”
Cached analytics rows are out of sync with the underlying transactions. Most often caused by:
- A bulk operation (import, bulk categorization, tag edit) that finished without triggering the normal re-aggregation.
- A scoped rebuild that failed silently for a single month.
Fix: Settings → Maintenance → “Rebuild analytics from a date”.
Pick a date at or before the first visibly-wrong month and click
Rebuild from date. This wipes and recomputes
AnalyticsCacheMonthly and TagAnalyticsCacheMonthly rows from that
date forward, without touching portfolio valuations. Takes seconds to a
couple of minutes depending on how many transactions fall in range.
If several months are affected across the entire year, use “Rebuild all analytics” instead — it’s a full-tenant rebuild of the analytics cache only.
”My portfolio values look stale or don’t match live prices”
Cached PortfolioValueHistory / PortfolioHolding rows are out of
sync. This can happen after:
- A corporate action (stock split, delisting) that the valuation worker hadn’t seen yet.
- A long period of not logging in — price forward-fill may have bugged out on one or two assets.
- A manual value edit on a specific asset.
Fix options:
- One asset only: Settings → Maintenance → “Rebuild a single asset” → pick the asset from the searchable dropdown → click Rebuild asset. Fastest option — rebuilds only that item’s history.
- All assets: Settings → Maintenance → “Full rebuild”. This runs the full pipeline (items → cash → analytics → valuation + loan processors). Expect 5-30 minutes depending on history size. Heaviest option; use when the damage isn’t localized.
”Stock P/E ratios, EPS, or dividend yields look wrong or show — on the Equity Analysis page”
SecurityMaster (the global stock fundamentals table) is refreshed
nightly at 3 AM UTC from Twelve Data’s /profile, /earnings,
/dividends, and /quote endpoints. When the response is inconsistent
for a given symbol — sparse history, off-by-one timezone on the latest
quarter, missing fields — the row is flagged untrusted and consumers
hide the affected fields rather than show wrong numbers. A — on the
Equity Analysis page means “data is missing or untrustworthy,” not a
bug.
Fix (your holdings): Settings → Maintenance → “Refresh my securities data” → click Refresh securities. This refreshes every stock and ETF you hold (profile, dividends and quote; earnings for stocks only) and is the right first step. It shares the 1-hour single-run lock and shows up in Recent rebuilds (Settings → Processing). New holdings are fetched automatically the same day, and Full rebuild also refreshes any holdings with missing or stale data (a “Refresh securities data” step in the history).
Fix (everyone, global): Settings → Maintenance → “Refresh fundamentals for all symbols (global)” → click Refresh fundamentals. This enqueues an immediate run of the same job the nightly cron triggers, iterating every active stock and ETF symbol across all tenants and re-fetching from Twelve Data. Each symbol takes about 2 seconds (rate-limited at 30 calls/min for the fundamentals slot), so a portfolio with 50 stocks finishes in ~2 minutes. The button only disables briefly while the request enqueues — the actual refresh runs in the background.
Verifying it worked: reload the Equity Analysis page after a couple
of minutes. Symbols whose underlying Twelve Data data was salvageable
will show numbers; symbols where Twelve Data is genuinely
unreliable will continue to show —. That’s the trust gate working as
intended — wrong data is worse than missing data for portfolio
intelligence and insights.
If you click the button rapidly, BullMQ will queue duplicate runs serially (concurrency 1 on the security-master worker). It won’t double-call Twelve Data per symbol within a single run, but a queued duplicate will re-run everything once the first finishes — usually harmless, just unnecessary credit usage on Twelve Data.
”A transaction I imported doesn’t show up in analytics”
Usually means the scoped-update-analytics job for that month didn’t
run or failed. First:
- Confirm the transaction is visible in Transactions. If not, the issue isn’t analytics — it’s the import itself.
- If the transaction is there but its month’s analytics look incomplete, use “Rebuild analytics from a date” with a date at or before the transaction’s month.
”The system ran a rebuild that’s still showing as Running for way too long”
Rebuilds are bounded by per-job BullMQ lock durations (5-30 minutes depending on the job type) and self-heal when they stall. But if something is truly stuck:
- Check Settings → Processing: the Live section shows each step with its
progress, and Recent rebuilds shows the rebuild’s jobs. If the
job’s state is still
active, wait — a full-portfolio rebuild on a 15k-transaction tenant can legitimately run 15-30 minutes. - Look at the backend Railway logs for your tenant’s rebuild. Search
for your
tenantId— you should see batch progress like[AnalyticsWorker] Processing batch of 1000. Total processed: 5000/29438. If progress is advancing, it’s working. If you seecould not renew lock for job <n>errors, the worker lost its Redis lock — BullMQ’s stalled-job checker will re-queue it. - The 1-hour
rebuild-lock:*TTL is a safety ceiling. If a worker crashes and the lock never releases, it auto-clears after an hour and you can retry.
”I triggered a rebuild but the Maintenance tab shows 409 ‘Already running’ and I can’t re-trigger”
A per-(tenant, scope) single-flight lock is held by a previous trigger. Usually because:
- The terminal job of the chain is still running. Watch the Current
section for a job in
active/waitingstate — once it completes, the lock releases within seconds and the button becomes clickable again. - The previous rebuild crashed and its lock hasn’t TTL-expired yet. The
Next available in X mincountdown shows the remaining TTL. Wait it out or reach out to ops to manually clear the Redis keyrebuild-lock:<tenantId>:<scope>.
You can still run a different rebuild scope in the meantime —
locks are per-scope, so full-analytics and single-asset don’t block
each other.
”I’m a member (not admin) and I need a rebuild”
Ask the admin on your tenant to run it for you. The Maintenance tab and
its API endpoints return 403 Admin access required for
non-admin roles by design — rebuilds are expensive operations that
admins should own.
”Annual or long-dormant subscriptions are missing from the Subscriptions page”
The nightly detection looks back 6 months, so a yearly subscription may not have been charged inside that window.
- Go to Settings → Maintenance.
- Run Subscriptions — full history scan. It widens the lookback to 48 months and runs in the background.
- Open the Subscriptions page after a minute or so. Annual and older subscriptions should now appear.
Run it once after your first import. See Tracking Subscriptions for how detection works.
Before reaching for a rebuild
Some issues look like stale caches but are actually the underlying data being wrong. Check these first:
- Missing transactions: go to Transactions. If you don’t see the transaction there, no rebuild will fix it. Re-import or add it manually.
- Wrong categorization: a rebuild doesn’t re-categorize transactions. Edit the category on the transaction itself (or use the transaction review page for bulk fixes).
- Wrong portfolio holdings: holdings are derived from transactions via FIFO. If the transactions are wrong (missing buys, incorrect quantities), fix them first, then rebuild.
- Live price discrepancies: the pricing providers (Twelve Data, manual values) are the source of truth. Check that the symbol / ISIN / exchange on your portfolio item is correct — a wrong ticker is usually the reason a price looks stale.
Advanced: reading the rebuild history
The rebuild history lives in Settings → Processing → Recent rebuilds (it moved out of the Maintenance tab in #100). Each completed or failed rebuild in it includes:
- Scope — which kind of rebuild.
- State badge — Completed, Failed, Running, or Queued.
- Requester email — who clicked the button.
- Elapsed time — how long ago it finished.
- Failure reason (failed only) — the error message from the final failed attempt.
- Attempts (when > 1) — how many times BullMQ retried. Multiple attempts usually indicate transient Redis or Prisma Accelerate issues that eventually recovered.
History is retained for 30 days in Redis. If Redis restarts, history is wiped but in-flight jobs are preserved via BullMQ’s own durable state.
What a rebuild does NOT touch
- Transaction records (
Transaction). - Portfolio item records (
PortfolioItem) — except when runningfull-portfolio, which rewrites theircostBasis/quantity/realizedPnLfields from transactions. - Categories, tags, accounts, bank connections, Plaid items.
- User settings, tenant settings, currency configurations.
- Currency rates.
If anything on the above list looks wrong, you have a different problem — a rebuild won’t help.
Rotating a secret / key
Rotating ENCRYPTION_SECRET, JWT_SECRET_CURRENT, NEXTAUTH_SECRET,
INTERNAL_API_KEY, POSTGRES_PASSWORD, or REDIS_PASSWORD is a
maintenance operation, but it’s involved enough to have its own guide: see
Key Rotation for the full inventory, step-by-step
procedures, verification steps, and rollback paths — especially before
touching ENCRYPTION_SECRET, which is the one secret whose failure mode is
irreversible.
All four application secrets have a 32-character minimum, enforced at
startup. In production the API and backend refuse to boot when one is shorter;
in development it is a warning. scripts/setup.sh generates 48/48/48/32, so a
generated .env always passes. Check before rotating by hand:
openssl rand -base64 48.
Verifying what is actually deployed
A green build does not prove what shipped. A stale layer cache, a
platform-pinned runtime, or a pnpm.overrides entry that silently failed to
apply all produce a successful deploy running the wrong versions — and nothing
logs any of it at boot.
curl -H "x-admin-key: $ADMIN_API_KEY" https://<your-api-host>/api/runtimeOne call covers all four services. The API layer is the only one reachable from outside, so it aggregates:
| Service | How it is reported |
|---|---|
api | directly — this is the process answering |
backendWeb | fetched over the private network with INTERNAL_API_KEY |
backendWorker | read from Redis. The worker runs START_MODE=worker and never starts an HTTP server, so it cannot be polled — it publishes a heartbeat instead |
web | version.json, stamped into the static bundle at build time |
What to look at:
commit— the single most useful field. Answers “did my deploy actually land, and is every service on the same code?” A mismatch between services means a half-finished rollout. Populated fromRAILWAY_GIT_COMMIT_SHA, orGIT_COMMIT_SHAif you pass it yourself;unknownjust means neither was set.nodeandlibc— confirms the runtime and the base image.muslmeans Alpine, which is what the Dockerfiles build.dependencies— resolved versions of the packages pinned bypnpm.overrides. Anullhere, or a version below the pinned floor, means an override did not apply.backendWorker.status—alivemeans the worker wrote its heartbeat within the last 3 minutes.absentmeans the worker is down or wedged, and no amount of green in the other three services tells you that. Worth checking whenever queued work has stopped moving.
The endpoint is authenticated because exact runtime and dependency versions are
information disclosure: they turn an untargeted scan into a targeted CVE lookup
against a host already known to be vulnerable. It is deliberately not on
/health, which stays unauthenticated for the platform’s deploy health check.
If ADMIN_API_KEY is unset, the endpoint rejects every request — the same
fail-closed behaviour as the other admin routes. Set it to any high-entropy
string; it does not need to match anything else.
Normalizing stored email addresses
One-off migration, and a no-op on any instance created after the change that introduced it. Bliss lowercases every email on write and lookup; this script brings pre-existing rows into the same canonical form.
Run it from the API service, which already has DATABASE_URL and
ENCRYPTION_SECRET in its environment. The paths below are relative to the
repo root, which is also the container’s working directory (/app), so the
same command works in a checkout and over docker exec / railway ssh.
# 1. Dry run — this is the DEFAULT. Nothing is written.
node apps/api/scripts/normalize-user-emails.mjs
# 2. Only if step 1 reports rows needing change, and only after a pg_dump:
node apps/api/scripts/normalize-user-emails.mjs --applyOn Railway: railway ssh --service api --environment <env> -- node apps/api/scripts/normalize-user-emails.mjs
Why there is a script at all, rather than a SQL UPDATE. User.email is
stored with searchable (deterministic) encryption: its salt and IV are derived
from a SHA-256 of the plaintext, so the ciphertext is a pure function of the
exact plaintext bytes. A SQL LOWER() would lowercase the base64 ciphertext
and corrupt every row beyond recovery. The script decrypts, lowercases and
re-encrypts each row individually.
What it refuses to do:
| Situation | Behaviour |
|---|---|
Two rows differ only in case (e.g. A@x.com and a@x.com) | Reports both and exits non-zero, having written nothing. User.email is UNIQUE, so lowercasing them would violate the index — and a half-applied migration is worse than none. Resolve the duplicate accounts, then re-run. |
A row cannot be decrypted with the current ENCRYPTION_SECRET | Aborts before any write and names the row ids. |
ENCRYPTION_SECRET_PREVIOUS is set | Refuses to run. That means a key rotation is still in flight; finish it first (see Key Rotation), because this script re-encrypts under ENCRYPTION_SECRET only. |
Idempotent — a second run reports zero rows needing change. Take a verified
pg_dump before using --apply, as with any migration that rewrites
ciphertext.