fix(03): IN-01 cache decrypted client per userId within a drain cycle
This commit is contained in:
@@ -104,11 +104,24 @@ async function loadClientForUser(userId: number): Promise<FastmailClient> {
|
|||||||
* Triggers a targeted single-calendar re-sync after a successful write or 412 conflict.
|
* Triggers a targeted single-calendar re-sync after a successful write or 412 conflict.
|
||||||
* Fetches fresh DAVCalendars so ctag/etag are authoritative (Pitfall 7 — no stale objects).
|
* Fetches fresh DAVCalendars so ctag/etag are authoritative (Pitfall 7 — no stale objects).
|
||||||
* All errors are caught and logged — re-sync failure is non-fatal.
|
* All errors are caught and logged — re-sync failure is non-fatal.
|
||||||
|
*
|
||||||
|
* IN-01: accepts an optional per-drain-cycle client cache. Without it, every settled or
|
||||||
|
* conflicted row independently reloaded + AES-GCM-decrypted the member credential,
|
||||||
|
* widening the window the decrypted app password lives in memory (T-03-13). When a cache
|
||||||
|
* is supplied, the decrypted client is built at most once per userId per drain cycle.
|
||||||
*/
|
*/
|
||||||
async function triggerTargetedResync(calendarUrl: string, userId: number): Promise<void> {
|
async function triggerTargetedResync(
|
||||||
|
calendarUrl: string,
|
||||||
|
userId: number,
|
||||||
|
clientCache?: Map<number, FastmailClient>,
|
||||||
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// loadClientForUser may throw in test environments — caught below
|
// loadClientForUser may throw in test environments — caught below
|
||||||
const client = await loadClientForUser(userId)
|
let client = clientCache?.get(userId)
|
||||||
|
if (!client) {
|
||||||
|
client = await loadClientForUser(userId)
|
||||||
|
clientCache?.set(userId, client)
|
||||||
|
}
|
||||||
const davCalendars = await client.fetchCalendars()
|
const davCalendars = await client.fetchCalendars()
|
||||||
|
|
||||||
// Pitfall 7: find the DAVCalendar by URL match (normalize trailing slash differences)
|
// Pitfall 7: find the DAVCalendar by URL match (normalize trailing slash differences)
|
||||||
@@ -382,6 +395,10 @@ export async function runOutboxDrain(): Promise<void> {
|
|||||||
// Cross-batch ordering is enforced durably by the DB sibling-status check inside the loop.
|
// Cross-batch ordering is enforced durably by the DB sibling-status check inside the loop.
|
||||||
const failedCreateGroups = new Set<string>()
|
const failedCreateGroups = new Set<string>()
|
||||||
|
|
||||||
|
// IN-01: per-drain-cycle client cache so triggerTargetedResync decrypts each member's
|
||||||
|
// credential at most once per cycle. Discarded when the drain returns — never persisted.
|
||||||
|
const clientCache = new Map<number, FastmailClient>()
|
||||||
|
|
||||||
for (const row of sorted) {
|
for (const row of sorted) {
|
||||||
// D-04 fast path: if the create for this group already failed in this batch, skip the delete
|
// D-04 fast path: if the create for this group already failed in this batch, skip the delete
|
||||||
if (row.operation === 'delete' && row.groupId && failedCreateGroups.has(row.groupId)) {
|
if (row.operation === 'delete' && row.groupId && failedCreateGroups.has(row.groupId)) {
|
||||||
@@ -452,7 +469,7 @@ export async function runOutboxDrain(): Promise<void> {
|
|||||||
.update(calendarOutbox)
|
.update(calendarOutbox)
|
||||||
.set({ status: 'failed', lastError: conflictError })
|
.set({ status: 'failed', lastError: conflictError })
|
||||||
.where(eq(calendarOutbox.id, row.id))
|
.where(eq(calendarOutbox.id, row.id))
|
||||||
await triggerTargetedResync(row.calendarUrl, row.userId)
|
await triggerTargetedResync(row.calendarUrl, row.userId, clientCache)
|
||||||
|
|
||||||
if (row.groupId && row.operation === 'create') {
|
if (row.groupId && row.operation === 'create') {
|
||||||
failedCreateGroups.add(row.groupId)
|
failedCreateGroups.add(row.groupId)
|
||||||
@@ -464,7 +481,7 @@ export async function runOutboxDrain(): Promise<void> {
|
|||||||
// raced the re-sync and returned stale cache (deleted event still
|
// raced the re-sync and returned stale cache (deleted event still
|
||||||
// present, edit not yet applied) — forcing a manual refresh. Re-syncing
|
// present, edit not yet applied) — forcing a manual refresh. Re-syncing
|
||||||
// first means 'done' guarantees the cache already reflects the write.
|
// first means 'done' guarantees the cache already reflects the write.
|
||||||
await triggerTargetedResync(row.calendarUrl, row.userId)
|
await triggerTargetedResync(row.calendarUrl, row.userId, clientCache)
|
||||||
await db
|
await db
|
||||||
.update(calendarOutbox)
|
.update(calendarOutbox)
|
||||||
.set({ status: 'done' })
|
.set({ status: 'done' })
|
||||||
|
|||||||
Reference in New Issue
Block a user