perf: cache-only strategy for list views, cache-then-cw for single fetch
- Add data-source hierarchy to opportunity manager (cache-only, cache-then-cw, cw-first) - fetchPages/search/fetchByCompany use cache-only: Redis → DB (no CW calls) - fetchItem uses cache-then-cw by default, cw-first when fresh=true - Add idleTimeout: 255 to Bun.serve to prevent request timeouts - Map CW status 57 (04. Confirmed Quote) to Active equivalency - Add computeCacheTTL algorithm and opportunityCache module
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* @module computeCacheTTL
|
||||
*
|
||||
* Adaptive Cache TTL Algorithm
|
||||
* ============================
|
||||
*
|
||||
* Determines how long a cached record should live before it must be
|
||||
* re-fetched from the upstream source (e.g. ConnectWise API).
|
||||
*
|
||||
* The algorithm prioritises freshness for records that are actively
|
||||
* being worked on, while avoiding unnecessary API calls for stale or
|
||||
* inactive data.
|
||||
*
|
||||
* ## Spec
|
||||
*
|
||||
* | # | Condition | TTL (ms) | TTL (human) | Rationale |
|
||||
* |---|------------------------------------------------------------------|----------|-------------|--------------------------------------------------------------------|
|
||||
* | 1 | `closedFlag` is `true` | `null` | Do not cache| Closed records are rarely accessed; caching wastes memory. |
|
||||
* | 2 | `expectedCloseDate` OR `lastUpdated` is within the last **5 days**| 30 000 | 30 seconds | High-activity window — data changes frequently and must stay fresh.|
|
||||
* | 3 | `expectedCloseDate` OR `lastUpdated` is within the last **14 days**| 60 000 | 60 seconds | Moderate activity — still relevant, but changes less often. |
|
||||
* | 4 | Everything else (older than 14 days) | 900 000 | 15 minutes | Low activity — safe to serve from cache for longer. |
|
||||
*
|
||||
* ## Evaluation order
|
||||
*
|
||||
* Rules are evaluated **top-to-bottom**; the first matching rule wins.
|
||||
* Rule 2 (5-day window) is a subset of Rule 3 (14-day window), so it
|
||||
* must be checked first.
|
||||
*
|
||||
* ## Inputs
|
||||
*
|
||||
* | Field | Type | Description |
|
||||
* |--------------------|------------------|--------------------------------------------------------------------|
|
||||
* | `closedFlag` | `boolean` | Whether the record is closed / inactive. |
|
||||
* | `expectedCloseDate`| `Date \| null` | The projected close date (future-looking relevance signal). |
|
||||
* | `lastUpdated` | `Date \| null` | The last time the upstream record was modified (backward-looking). |
|
||||
* | `now` | `Date` (optional)| Override for the current timestamp; defaults to `new Date()`. |
|
||||
*
|
||||
* ## Output
|
||||
*
|
||||
* Returns `number | null`:
|
||||
* - A positive integer representing the TTL in **milliseconds**, or
|
||||
* - `null` when the record should **not** be cached at all.
|
||||
*
|
||||
* ## Usage
|
||||
*
|
||||
* ```ts
|
||||
* import { computeCacheTTL } from "../modules/algorithms/computeCacheTTL";
|
||||
*
|
||||
* const ttl = computeCacheTTL({
|
||||
* closedFlag: opportunity.closedFlag,
|
||||
* expectedCloseDate: opportunity.expectedCloseDate,
|
||||
* lastUpdated: opportunity.cwLastUpdated,
|
||||
* });
|
||||
*
|
||||
* if (ttl !== null) {
|
||||
* await redis.set(key, serialised, "PX", ttl);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 30 seconds – TTL for high-activity records (within 5 days). */
|
||||
export const TTL_HIGH_ACTIVITY = 30_000;
|
||||
|
||||
/** 60 seconds – TTL for moderate-activity records (within 14 days). */
|
||||
export const TTL_MODERATE_ACTIVITY = 60_000;
|
||||
|
||||
/** 15 minutes – TTL for low-activity / stale records. */
|
||||
export const TTL_LOW_ACTIVITY = 900_000;
|
||||
|
||||
/** 30 days in milliseconds. */
|
||||
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
/** 5 days in milliseconds. */
|
||||
const FIVE_DAYS_MS = 5 * 24 * 60 * 60 * 1000;
|
||||
|
||||
/** 14 days in milliseconds. */
|
||||
const FOURTEEN_DAYS_MS = 14 * 24 * 60 * 60 * 1000;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Input type
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface CacheTTLInput {
|
||||
/** Whether the record is closed / inactive. */
|
||||
closedFlag: boolean;
|
||||
/** When the record was closed — used for recently-closed caching (within 30 days). */
|
||||
closedDate: Date | null;
|
||||
/** The projected close date — serves as a forward-looking relevance signal. */
|
||||
expectedCloseDate: Date | null;
|
||||
/** The date the upstream record was last modified — backward-looking signal. */
|
||||
lastUpdated: Date | null;
|
||||
/**
|
||||
* Override for the current timestamp.
|
||||
* Useful for deterministic testing. Defaults to `new Date()`.
|
||||
*/
|
||||
now?: Date;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Algorithm
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Compute the cache TTL for a record based on its activity signals.
|
||||
*
|
||||
* @param input - The record's activity signals. See {@link CacheTTLInput}.
|
||||
* @returns The TTL in milliseconds, or `null` if the record should not be cached.
|
||||
*
|
||||
* @see Module-level JSDoc for the full spec table and evaluation rules.
|
||||
*/
|
||||
export function computeCacheTTL(input: CacheTTLInput): number | null {
|
||||
const {
|
||||
closedFlag,
|
||||
closedDate,
|
||||
expectedCloseDate,
|
||||
lastUpdated,
|
||||
now = new Date(),
|
||||
} = input;
|
||||
|
||||
const nowMs = now.getTime();
|
||||
|
||||
/**
|
||||
* Check whether a date falls within a window around `now`.
|
||||
*
|
||||
* "Within" means the date is between `now - windowMs` and `now + windowMs`,
|
||||
* allowing both past updates and future-scheduled dates to qualify.
|
||||
*/
|
||||
const isWithinWindow = (date: Date | null, windowMs: number): boolean => {
|
||||
if (!date) return false;
|
||||
const diff = Math.abs(nowMs - date.getTime());
|
||||
return diff <= windowMs;
|
||||
};
|
||||
|
||||
// Rule 1 — Closed records
|
||||
if (closedFlag) {
|
||||
// Rule 1b — Recently closed (within 30 days) → cache at low-activity TTL
|
||||
if (isWithinWindow(closedDate, THIRTY_DAYS_MS)) {
|
||||
return TTL_LOW_ACTIVITY;
|
||||
}
|
||||
// Rule 1a — Closed longer than 30 days → do not cache
|
||||
return null;
|
||||
}
|
||||
|
||||
// Rule 2 — High activity (5 days)
|
||||
if (
|
||||
isWithinWindow(expectedCloseDate, FIVE_DAYS_MS) ||
|
||||
isWithinWindow(lastUpdated, FIVE_DAYS_MS)
|
||||
) {
|
||||
return TTL_HIGH_ACTIVITY;
|
||||
}
|
||||
|
||||
// Rule 3 — Moderate activity (14 days)
|
||||
if (
|
||||
isWithinWindow(expectedCloseDate, FOURTEEN_DAYS_MS) ||
|
||||
isWithinWindow(lastUpdated, FOURTEEN_DAYS_MS)
|
||||
) {
|
||||
return TTL_MODERATE_ACTIVITY;
|
||||
}
|
||||
|
||||
// Rule 4 — Low activity / stale
|
||||
return TTL_LOW_ACTIVITY;
|
||||
}
|
||||
Reference in New Issue
Block a user