Skip to content

Yodlee Soft-Refresh Optimization

Yodlee is the only integration that blocks php-fpm worker threads during data refresh. All refresh operations run synchronously in HTTP request threads, occupying workers for up to 20 minutes (Redis lock timeout). Under high concurrency, this can exhaust the php-fpm worker pool and degrade other API endpoints.

  • DEV-3438: User cannot see manual refresh button because Yodlee soft-refresh frequently times out
  • Parent Epic DEV-3901: Investigate and optimise nightly sync
  1. Saloon migration - Migrate Yodlee’s custom Guzzle-based Connector to standard Saloon architecture
  2. Async conversion - Move synchronous refresh operations to Laravel Queue
  3. Notification integration - Add push notifications (reuse Wealthbox/Redtail patterns), including error feedback
  • Significantly reduce php-fpm resource occupation
  • Improve data sync user experience
  • Unified, maintainable technical architecture

Yodlee is a completely separate legacy system from the standard Saloon-based integration architecture:

DimensionStandard IntegrationYodlee
FrameworkSaloon PHPGuzzle HTTP direct
ConnectorExtends Saloon\Http\Connector with middlewareStatic call(), no base class
AuthenticationOAuth 2.0 (thread-safe token refresh)JWT RS512 (25-min cache)
InitiatorAdvisor configures connectionClient links bank via FastLink
Data ModelIntegration + IntegrationMappingyodlee_providers + yodlee_provider_accounts + account_yodlees
IntegrationTypeIn enum (47 types)Not in enum (uses AccountSource::YODLEE)
Nightly SyncSupported (scheduled)Not participating (requires user session context)
Refresh ModelActive scheduling (background jobs)Synchronous blocking (HTTP thread)
Rate LimitingSaloon middleware (Redis + HasRateLimits)Manual Redis counter (9/120s, silent abort)
User triggers POST /refresh
-> Acquire Redis distributed lock (20 min timeout)
-> Call Yodlee API: PUT providerAccounts?providerAccountIds={id}
-> Return 202 (in progress) or 200 (complete)
[php-fpm worker BLOCKED]
User polls GET /refresh
-> Call Yodlee API: GET providerAccounts/{id}
-> Check status, update local DB
-> Return action_required to frontend
[php-fpm worker BLOCKED on each poll]
ProblemImpact
Synchronous blockingphp-fpm workers occupied for duration of Yodlee API calls
20 min lock timeoutToo long, masks stuck processes
Silent rate limitingUsers get no feedback when hitting 9-refreshes/120s limit
No error transparencyaction_required: null means both “done” and “rate limit abort”
No middleware reuseCannot leverage Saloon’s logging, retry, tracing, rate-limit middleware

Understanding the layered architecture is essential for planning the migration scope.

Responsibility: Pure communication with external Vendor API.

ConcernConnector DoesConnector Does NOT
AuthenticationManage tokens, JWT signing, API keysUnderstand business meaning of responses
HTTP transportSend requests, receive responses, timeoutsTransform data to internal models
Error mappingHTTP status -> domain exceptionsWrite to database
Rate limitingTrack and enforce API limitsDecide business retry logic
ObservabilityRequest/response logging, OTel tracingManage sync lifecycle state

Standard Saloon Connector hierarchy:

Saloon\Http\Connector
-> Support\ApiBased\Connectors\Connector (rate limit + timeout + error middleware)
-> Support\ApiBased\Connectors\OauthConnector (thread-safe token refresh)

Yodlee Connector (Integrations/Yodlee/Connector.php):

  • Does NOT extend any base connector class
  • Static-only API: call(url, method, headers, params, queries, context)
  • Custom JWT RS512 authentication inline
  • Custom exception types (not mapped through standard AffectedLevel)

Responsibility: Orchestrate the entire data sync process.

ConcernIntegrator DoesIntegrator Does NOT
Data fetchingCall Connector to retrieve vendor dataManage HTTP transport details
Data transformationVendor Model -> RC internal ModelHandle authentication
PersistenceCreate/Update/Delete database recordsCare about HTTP status codes
Sync lifecycleManage mapping states, detect changesHandle connection retries
Entity discoveryList available entities from vendor (via EntityProvider interface)Configure request headers

Standard Integrator base class (integrations-core):

abstract class Integrator {
abstract public function syncAll(): void; // Batch sync
abstract public static function sync(IntegrationMapping $mapping): ?array; // Single sync
abstract protected static function getVendor(): IntegrationType;
}

Yodlee Integrator (Integrations/Yodlee/Integrator.php, ~592 lines):

  • Does NOT extend the base Integrator class
  • Entry point: saveProviderAccount() (not sync())
  • Overloaded responsibilities: status code mapping (30+ codes -> 5 actions), data sync, rate limit checking all in one class
Controller (HTTP entry point)
|
v
Integrator::sync($mapping)
|
|-- $connector->call('/endpoint') <- Connector: HTTP + auth + error mapping
|-- VendorModel::fromResponse($data) <- Integrator: transform vendor data
|-- Account::updateOrCreate(...) <- Integrator: persist to database
|-- $mapping->update(['last_completed_at' => now()])

Option 1: Saloon Connector Only (Transport Layer Migration)

Section titled “Option 1: Saloon Connector Only (Transport Layer Migration)”

Migrate Yodlee’s Connector.php from raw Guzzle to Saloon Connector. Data model and Integrator logic unchanged.

Pros:

  • Smallest blast radius, Integrator untouched
  • Gains: Saloon middleware (logging, retry, rate-limit, OTel tracing)
  • Can be done independently of async work

Cons:

  • Does not solve php-fpm blocking
  • Does not address Integrator.php overload

Move refresh operations into Laravel Queue. Frontend continues polling for results.

Pros:

  • Directly solves php-fpm blocking (core goal)
  • Leverages existing queue infrastructure
  • Minimal frontend change (already polls GET /refresh)

Cons:

  • Queue worker still synchronously polls Yodlee API (acceptable - isolated process)
  • Needs intermediate state storage (Redis/DB) for job status

Use Yodlee’s webhook notification to avoid polling entirely.

Pros:

  • Truly async, no polling needed
  • Yodlee proactively notifies on completion

Cons:

  • Requires webhook endpoint configuration and verification
  • Frontend needs WebSocket/SSE for real-time updates (new infrastructure)
  • Yodlee webhook API capability needs verification

Option 4: Hybrid (Short-term Polling + Long-term Webhook)

Section titled “Option 4: Hybrid (Short-term Polling + Long-term Webhook)”

Queue job polls for first 30 seconds, then falls back to webhook notification.

Pros: Covers both fast and slow refresh scenarios

Cons: Highest complexity, over-engineering for current needs

Phased approach: Phase 1 -> Phase 2 -> Phase 3 (optional)

Phase 1: Async Queue + Error Transparency (Highest Priority)

Section titled “Phase 1: Async Queue + Error Transparency (Highest Priority)”

Move refresh operations to Laravel Queue. This directly addresses the php-fpm blocking problem.

Key design decisions:

AspectDecisionRationale
Job dispatchPOST /refresh dispatches job, returns 202 immediatelyFrees php-fpm worker instantly
Status storagePersist job state in Redis (keyed by provider account ID)Frontend polls same GET /refresh endpoint
Lock redesignBind lock to Job lifecycle, not HTTP request. Shorten to 5 min with active renewalPrevent stuck processes
Queue isolationDedicated yodlee-refresh queuePrevent interference with other integration sync jobs
Polling in JobJob polls Yodlee API with reasonable interval (5s) and max retries (60 = 5 min)Bounded resource usage
Data syncExecute saveAccountsWhenSuccessful() within Job on SUCCESS/PARTIAL_SUCCESSEnsure transaction integrity and idempotency
Error transparencyReplace ambiguous null with explicit states: done / rate_limited / error / in_progressUsers get actionable feedback

Notification scenarios for Yodlee (vs standard integrations):

ScenarioStandard IntegrationYodlee-Specific
Sync successSupportedData refreshed notification
Sync failureSupportedTechnical error notification
Credentials expiredSupportedput action - prompt reconnect via FastLink
MFA requiredNot applicablemanual_refresh - prompt FastLink Refresh mode
Partial successNot applicablePARTIAL_SUCCESS - some accounts have issues
Rate limitedNot applicableCurrently silent, should notify

Notification channel: Frontend polling + toast notification (minimal change). WebSocket/SSE is not justified given low event frequency.

Migrate Yodlee’s Connector from raw Guzzle to Saloon architecture.

Scope: Transport layer only. Data model (yodlee_* tables) and IntegrationType enum unchanged.

ComponentBeforeAfter
Base classNone (standalone)Extends Support\ApiBased\Connectors\Connector
AuthenticationInline JWT RS512Saloon Authenticator (custom JWT authenticator)
Rate limitingManual Redis counterSaloon HasRateLimits middleware
Error handlingCustom exception typesMap to standard ExternalServiceException hierarchy
LoggingManual file writeSaloon middleware (WriteFatalRequestToFileLog)
TracingManual OTel spansSaloon APM middleware

Why NOT migrate to IntegrationType enum / Integration model:

  • Yodlee’s passive refresh model (requires user session context) is fundamentally incompatible with nightly sync
  • Data model migration (yodlee_* tables -> integrations + integration_mappings) would be high cost, low benefit
  • AccountSource::YODLEE works correctly for its use case

Phase 3: Notification Enhancement (Optional)

Section titled “Phase 3: Notification Enhancement (Optional)”

Reuse Wealthbox/Redtail notification patterns for richer Yodlee feedback:

  • App-level notifications for long-running refreshes
  • Persistent error messages for credential issues
  • Consider integration with existing failed_since / failed_biz_days tracking pattern if Yodlee is ever brought closer to the standard model
  1. Yodlee Webhook API - Does Yodlee support webhook notifications for refresh completion? Needs verification against latest Yodlee documentation under STG ownership.
  2. Frontend polling adjustment - When POST /refresh returns 202 immediately, does the frontend polling interval need tuning?
  3. Monitoring metrics - What new metrics are needed post-async? (job duration, success rate, Yodlee API latency, queue depth)
  4. Integrator refactoring - Should Integrator.php’s status code mapping be extracted into a dedicated StatusResolver class during Phase 2?