ADR-0067: AppFolio session concurrency — serialize vs. session-refresh-lock for parallel renewal prep
- Status: Accepted / Implemented — option A (serialize per account) shipped; option B deferred until volume justifies it; option C rejected (Fede: no second AppFolio login).
- Date: 2026-06-24 (implemented 2026-07-06)
- Deciders: Fede (owner), eng
- Relates: 0025 (Temporal renewals), 0047 (saga read-model), 0066 (unified renewal pipeline). Builds on the
mfa-locksDDB-lock pattern (PR #181). Downstream of the 2026-06-23 Camellia go-live. - Implemented by:
agentJobGroupIdinsrc/lib/integrations/sqs/agent-jobs.ts— AppFolio agent jobs (appfolio.*) now publish withMessageGroupId = appfolio:<accountId>(per-account FIFO serialization); non-AppFolio agents keep per-entity grouping;MessageDeduplicationIdstays per-entity. Fix landed after the 2026-07 Camellia renewal retries surfacedMFA_LOCK_HELDcollisions live.
Context
On 2026-06-23, the first real autonomous renewal batch for Camellia fired three prep jobs within ~6 seconds (Mia, Wanda, Verity). The first (Mia) succeeded; the second and third collided on the shared AppFolio session and failed their first attempt with:
session bootstrap failed: REFRESH_DID_NOT_TAKE: ...still returned HTTP 401 after a full login refresh — session could not be re-established.
They only succeeded on later SQS retries once spaced out in time. One side effect (Verity) was an orphaned AppFolio offer and a spurious PM escalation.
Crucial reframing — the collision is no longer a correctness problem. Two fixes shipped 2026-06-24 already neutralize the harm:
- #2592 routes infra/session failures to Sentry (engineering), never the PM — no more raw-error PM spam.
- #2595 makes the renewal workflow wait for the SQS retry on a transient prep failure instead of going terminal — no more orphaned offers.
So a collision now simply costs latency (a batch member waits for a 150s SQS redelivery and succeeds on attempt 2–3). It no longer corrupts state or pages a human. This ADR is therefore a performance/throughput decision, not a fire — which materially lowers the appetite for a high-risk change.
Current architecture
The AppFolio browser-automation path is shared by all agent jobs — renewals, work orders, work-order enrichment, cancellations:
- PropFlow publishes a job to
propflow-agent-jobs.fifo(src/lib/integrations/sqs/agent-jobs.ts). Renewal prep usesMessageGroupId = appfolio.renewal.send:<leaseId>— per-lease, so different leases land in different FIFO groups and are delivered concurrently to separate Lambda invocations (propflow-agent-runtime-prod, reserved concurrency 25). - Each Lambda POSTs to the single appfolio-browser-agent Vercel deployment.
- That deployment uses one Browserbase persistent context for all
jpcowork (singleBROWSERBASE_CONTEXT_ID;AppfolioAccountConfig.browserbase.contextIdis a scalar, not an array). The context holds AppFolio's Rails_sessioncookie and device-trust cookie. - AppFolio login = email/password plus SMS-based MFA (code delivered to one Twilio number,
+18442853526). The existing MFA lock (mfa-locks.ts, PKMFA_LOCK#sha256(orgId+email), PR #181) serializes only the MFA-code-submission step — not the full session bootstrap. Device trust does not persist in the Browserbase context (verified 2026-05-26), so every session lapse forces a full MFA re-login.
The collision, precisely
When the shared _session cookie is stale, N concurrent jobs all detect 401 and all enter the login/refresh path at once. One wins and writes fresh cookies into the shared context; the others' in-flight logins are invalidated by AppFolio (a new login for the same device/credential kills the competing sessions) → their post-login re-probe still 401s → REFRESH_DID_NOT_TAKE. The L4 retry then fires /api/cron/refresh-if-needed?force=1 for the losers, which races again on the same shared context. They recover only on later SQS redeliveries, once the winner has finished and released the session. The MFA lock does not prevent this — it guards code submission, not the bootstrap/refresh sequence.
This is not renewal-specific: any two concurrent AppFolio browser operations (e.g. a renewal prep + a work-order write) on a stale session can collide the same way. The renewal batch merely surfaced a latent system-wide property.
Options
Option A — Serialize AppFolio browser jobs per account (recommended)
Change the FIFO MessageGroupId for AppFolio browser jobs from per-lease to per-account (e.g. appfolio:<accountId>). SQS FIFO guarantees in-order, one-at-a-time delivery within a group, so only one AppFolio browser op runs per account at a time — the collision cannot occur.
- Blast radius: tiny. One change in
agent-jobs.ts(the group-id derivation). No browser-agent change, no new infra, no session-path surgery. - Risk: very low. FIFO serialization is a well-understood primitive already in use.
- Cost: throughput. All
jpcoAppFolio browser work serializes behind one another (~75–110s/job). A 3-renewal batch takes ~4–5 min instead of ~75s; a work order queued behind a renewal batch waits. For current volume (one property, a handful of renewals/day) this is immaterial. - Reversibility: trivial (revert the group-id).
Option B — Session-refresh lock (the "Phase 1" from the concurrency investigation)
Add a DDB SESSION_REFRESH_LOCK#<account> that serializes the entire session-bootstrap-or-refresh (not just MFA submission), mirroring mfa-locks.ts. On a warm session the lock is never taken → N jobs run in parallel. On a stale session exactly one job re-logs-in while the others receive 503 SESSION_REFRESH_LOCK_HELD and park via SQS ChangeMessageVisibility, then retry onto the now-warm session (no second MFA).
- Upside: real parallelism on the common warm-session path, system-wide (renewals, work orders, enrichment all benefit). Eliminates the
refresh-if-neededstampede. - Blast radius: large. It modifies the shared AppFolio session/refresh path that every agent operation depends on — a bug there breaks renewals and work orders and enrichment. It spans the separate appfolio-browser-agent service (new lock client + 503 handling in all four L4 routes) plus PropFlow.
- Open question (unresolved): does AppFolio enforce single-active-session-per-credential? If it does, two concurrent writes on one warm session may still invalidate each other even with the refresh serialized — meaning B fully parallelizes reads but not necessarily concurrent writes. This must be tested before committing.
- Effort: ~1–2 weeks across two services + a real test of the single-session question.
Option C — Multi-credential session pool (rejected)
N independent Browserbase contexts, one per AppFolio login (e.g. fede@ + gera@), leased per job — true N-way parallelism even for writes. Rejected by Fede: no second AppFolio login. Documented for completeness; revisit only if a second service identity ever becomes acceptable.
Decision (recommended)
Adopt Option A (serialize per account) now. Rationale:
- The collision is no longer a correctness issue (#2592 + #2595). What remains is latency, and at current volume serialization adds none that matters.
- A is one low-risk line vs. B's broad, cross-service surgery on the session path every AppFolio op shares.
- It directly satisfies the "if too complex, stagger them" guidance.
Defer Option B until AppFolio browser-op volume (renewals plus work orders, across more than one property) actually makes per-account serialization a throughput bottleneck — and, before building it, first answer the single-active-session-per-credential question (a cheap experiment: two concurrent warm-session writes; observe whether one is invalidated). If AppFolio enforces single-session, B's value is capped at parallel reads and C (rejected) would be the only true write-parallel path — which further argues for staying on A.
Reject Option C per the no-second-login constraint.
Consequences
- Throughput ceiling: all
jpcoAppFolio browser jobs run one-at-a-time. Acceptable now; the trigger to revisit is a measured backlog (e.g. work orders waiting minutes behind renewal batches, or renewal cohorts large enough that a serial batch misses the daily send window). Add a simple metric: agent-jobs queue depth / oldest-message-age per account. - Scope of serialization: the per-account group id serializes every AppFolio browser op for that account, not just renewals — which is exactly what makes it collision-proof, but means a long renewal batch can delay a work order. If that becomes painful before B is justified, a middle option is a coarser-but-not-global group id (e.g. one shared
appfolio:<accountId>group only for the session-contending operations) — but that is just A with extra bookkeeping; prefer measuring first. - No new failure modes: unlike B, A introduces no lock to get wrong, no 503 path, no replay/parking logic.
- Revisit triggers for B: (a) measured throughput pain at current single-session model; (b) a green result on the single-active-session-per-credential experiment; (c) appetite for a change touching the shared session path with a full work-order + renewal regression pass.
- Unaffected: the MFA model itself is not modified by A. The SMS-MFA + single-shared-session design stays; A just stops issuing concurrent requests into it.