-
Notifications
You must be signed in to change notification settings - Fork 306
Expand file tree
/
Copy pathtouch-buffer.ts
More file actions
197 lines (169 loc) · 5.84 KB
/
touch-buffer.ts
File metadata and controls
197 lines (169 loc) · 5.84 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
import { ApiKeyRepository } from "@domain/api-keys"
import { ApiKeyId } from "@domain/shared"
import { ApiKeyRepositoryLive, SqlClientLive } from "@platform/db-postgres"
import type { PostgresClient } from "@platform/db-postgres"
import { createLogger } from "@repo/observability"
import { Effect } from "effect"
const logger = createLogger("touch-buffer")
/**
* Configuration options for TouchBuffer.
*/
interface TouchBufferConfig {
/** Flush interval in milliseconds (default: 30000ms = 30s) */
intervalMs?: number
/** Maximum buffer size before forced flush (default: 10000) */
maxBufferSize?: number
}
/**
* In-memory buffer for batching API key touch updates.
*
* This class buffers touch updates in memory and flushes them periodically
* to reduce database write load. Instead of writing on every API key request,
* updates are batched and written in a single query.
*
* Performance impact:
* - Reduces writes by 90%+ (30s window / avg 100ms request = 300:1 reduction)
* - lastUsedAt accuracy reduced to flush interval (±30 seconds by default)
*
* Usage:
* ```typescript
* const touchBuffer = createTouchBuffer() // Uses singleton
* touchBuffer.touch(apiKey.id) // Fast, in-memory only
*
* // On shutdown:
* touchBuffer.destroy() // Final flush
* ```
*/
class TouchBuffer {
private buffer = new Map<string, number>() // keyId -> timestamp
private flushInterval: NodeJS.Timeout | null = null
private readonly intervalMs: number
private readonly maxBufferSize: number
private readonly client: PostgresClient
constructor(client: PostgresClient, config: TouchBufferConfig = {}) {
this.client = client
this.intervalMs = config.intervalMs ?? 30000
this.maxBufferSize = config.maxBufferSize ?? 10000
this.startFlushInterval()
logger.info(`TouchBuffer initialized with ${this.intervalMs}ms interval, max buffer size ${this.maxBufferSize}`)
}
/**
* Record a touch for an API key.
*
* This is a fast, in-memory operation. The actual database update
* happens asynchronously during the next flush cycle.
*
* @param keyId - The API key ID to touch
*/
touch(keyId: string): void {
this.buffer.set(keyId, Date.now())
// Force flush if buffer exceeds max size
if (this.buffer.size >= this.maxBufferSize) {
logger.warn(`Buffer size ${this.buffer.size} exceeded max ${this.maxBufferSize}, forcing flush`)
void this.flush()
}
}
/**
* Get current buffer size (for monitoring).
*/
getBufferSize(): number {
return this.buffer.size
}
/**
* Manually trigger a flush (for testing or graceful shutdown).
*/
async flush(): Promise<void> {
if (this.buffer.size === 0) {
return
}
// Copy and clear buffer atomically
const batch = new Map(this.buffer)
this.buffer.clear()
const keyIds = Array.from(batch.keys()).map((id) => ApiKeyId(id))
if (keyIds.length === 0) {
return
}
const startTime = Date.now()
// Use the live layer pattern for cross-org batch updates (bypasses RLS)
const sqlClientLayer = SqlClientLive(this.client)
const apiKeyRepoLayer = ApiKeyRepositoryLive
try {
await Effect.runPromise(
Effect.gen(function* () {
const repo = yield* ApiKeyRepository
return yield* repo.touchBatch(keyIds)
}).pipe(Effect.provide(apiKeyRepoLayer), Effect.provide(sqlClientLayer)),
)
const duration = Date.now() - startTime
logger.info(`Flushed ${keyIds.length} touch updates in ${duration}ms`)
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error"
logger.error(`Failed to flush touch updates: ${errorMessage}`)
// Re-add failed keys to buffer for retry (with limit to prevent unbounded growth)
for (const [keyId, timestamp] of batch) {
if (!this.buffer.has(keyId)) {
this.buffer.set(keyId, timestamp)
}
}
// Trim buffer if it grew too large during retry
if (this.buffer.size > this.maxBufferSize * 1.5) {
const entriesToRemove = this.buffer.size - this.maxBufferSize
const entries = Array.from(this.buffer.entries())
// Remove oldest entries (sorted by timestamp)
entries.sort((a, b) => a[1] - b[1])
for (let i = 0; i < entriesToRemove; i++) {
this.buffer.delete(entries[i][0])
}
logger.warn(`Trimmed ${entriesToRemove} oldest entries from buffer after flush failure`)
}
}
}
/**
* Stop the flush interval and perform a final flush.
*
* Call this during graceful shutdown to ensure all pending
* touch updates are persisted.
*/
async destroy(): Promise<void> {
logger.info(`Destroying TouchBuffer with ${this.buffer.size} pending updates`)
if (this.flushInterval) {
clearInterval(this.flushInterval)
this.flushInterval = null
}
// Final flush
await this.flush()
logger.info("TouchBuffer destroyed")
}
private startFlushInterval(): void {
this.flushInterval = setInterval(() => {
void this.flush()
}, this.intervalMs)
// Ensure interval doesn't prevent process exit in tests/development
if (this.flushInterval.unref) {
this.flushInterval.unref()
}
}
}
/**
* Create a singleton TouchBuffer instance.
*
* This factory ensures only one TouchBuffer exists per application instance.
*/
let touchBufferInstance: TouchBuffer | null = null
export const createTouchBuffer = (client: PostgresClient, config?: TouchBufferConfig): TouchBuffer => {
if (!touchBufferInstance) {
touchBufferInstance = new TouchBuffer(client, config)
}
return touchBufferInstance
}
/**
* Destroy the singleton TouchBuffer instance.
*
* Call this during graceful shutdown.
*/
export const destroyTouchBuffer = async (): Promise<void> => {
if (touchBufferInstance) {
await touchBufferInstance.destroy()
touchBufferInstance = null
}
}