1+ import { AsyncLocalStorage } from 'node:async_hooks'
12import { createLogger } from '@sim/logger'
23import { type AuditLogParams , ambientSurface , insertDeferredAuditEntry } from './log'
34
@@ -12,6 +13,9 @@ const ROLLUP_MAX_ENTRIES = 100
1213/** Pending rollups held by the process; reaching it writes every one of them. */
1314const ROLLUP_MAX_PENDING = 1_000
1415
16+ /** Rows inserted at once by one write, so a large flush never floods the pool. */
17+ const ROLLUP_WRITE_CONCURRENCY = 10
18+
1519export interface AuditRollupSummary {
1620 entries : number
1721 counters : Record < string , number >
@@ -41,7 +45,17 @@ export interface AuditRollupEntry {
4145 describe ( summary : AuditRollupSummary ) : string
4246}
4347
48+ /**
49+ * A unit of work that owns the rollups recorded inside it (a workflow run and
50+ * the child runs it executes inline), so its end writes only its own rows.
51+ */
52+ interface RollupScope {
53+ id : number
54+ writes : Set < Promise < void > >
55+ }
56+
4457interface PendingRollup {
58+ scope : RollupScope | undefined
4559 first : AuditRollupEntry
4660 surface : string | undefined
4761 firstAt : Date
@@ -53,20 +67,22 @@ interface PendingRollup {
5367}
5468
5569const pending = new Map < string , PendingRollup > ( )
56-
57- /** Writes started but not finished, so a flush also waits for rows already leaving. */
58- const inFlight = new Set < Promise < void > > ( )
70+ const scopeStorage = new AsyncLocalStorage < RollupScope > ( )
71+ let nextScopeId = 0
5972
6073/**
6174 * Records a repetitive audit event, merging it with its repeats. Fire-and-forget
6275 * like `recordAudit`. Each pending row is written when its window closes, when
63- * it reaches {@link ROLLUP_MAX_ENTRIES}, or on {@link flushAuditRollups}. The
64- * window timer is not unref'd, so a process that ends naturally still writes it.
76+ * it reaches {@link ROLLUP_MAX_ENTRIES}, or when the {@link withAuditRollupScope}
77+ * it was recorded in ends. The window timer is not unref'd, so a process that
78+ * ends naturally still writes it.
6579 */
6680export function recordAuditRollup ( entry : AuditRollupEntry ) : void {
6781 const surface = ambientSurface ( )
82+ const scope = scopeStorage . getStore ( )
6883 const { params } = entry
6984 const key = JSON . stringify ( [
85+ scope ?. id ?? null ,
7086 surface ?? null ,
7187 params . workspaceId ?? null ,
7288 params . actorId ,
@@ -90,6 +106,7 @@ export function recordAuditRollup(entry: AuditRollupEntry): void {
90106
91107 const now = new Date ( )
92108 const rollup : PendingRollup = {
109+ scope,
93110 first : entry ,
94111 surface,
95112 firstAt : now ,
@@ -104,19 +121,33 @@ export function recordAuditRollup(entry: AuditRollupEntry): void {
104121}
105122
106123/**
107- * Writes every pending row now and waits for writes already started. Work that
108- * may be frozen or ended once it returns (a workflow run) awaits this at its
109- * end. Never throws.
124+ * Runs `work` as the owner of the rollups recorded inside it, then writes them
125+ * and waits for those writes before returning, so work whose process may be
126+ * frozen or ended once it returns (a background workflow run) leaves nothing
127+ * pending. Other scopes' rollups keep their own window. Each write is one
128+ * insert, bounded by the database's statement and connect timeouts; a failed
129+ * one is logged, never thrown.
110130 */
111- export async function flushAuditRollups ( ) : Promise < void > {
112- if ( pending . size > 0 ) startWrite ( [ ...pending . keys ( ) ] . map ( take ) )
113- await Promise . all ( inFlight )
131+ export async function withAuditRollupScope < T > ( work : ( ) => Promise < T > ) : Promise < T > {
132+ const scope : RollupScope = { id : nextScopeId ++ , writes : new Set ( ) }
133+ try {
134+ return await scopeStorage . run ( scope , work )
135+ } finally {
136+ const keys = [ ...pending . entries ( ) ]
137+ . filter ( ( [ , rollup ] ) => rollup . scope === scope )
138+ . map ( ( [ key ] ) => key )
139+ if ( keys . length > 0 ) startWrite ( keys . map ( take ) )
140+ await Promise . all ( scope . writes )
141+ }
114142}
115143
116144function startWrite ( rollups : PendingRollup [ ] ) : void {
117145 const done = writePending ( rollups )
118- inFlight . add ( done )
119- void done . finally ( ( ) => inFlight . delete ( done ) )
146+ const scopes = new Set ( rollups . map ( ( rollup ) => rollup . scope ) )
147+ for ( const scope of scopes ) scope ?. writes . add ( done )
148+ void done . finally ( ( ) => {
149+ for ( const scope of scopes ) scope ?. writes . delete ( done )
150+ } )
120151}
121152
122153function mergeInto ( rollup : PendingRollup , entry : AuditRollupEntry ) : void {
@@ -163,19 +194,24 @@ function toAuditParams(rollup: PendingRollup): AuditLogParams {
163194 }
164195}
165196
166- /** One insert per row, so a row that cannot be written never takes others with it. */
197+ /**
198+ * One insert per row, so a row that cannot be written never takes others with
199+ * it, at most {@link ROLLUP_WRITE_CONCURRENCY} at a time.
200+ */
167201async function writePending ( rollups : PendingRollup [ ] ) : Promise < void > {
168- await Promise . all (
169- rollups . map ( async ( rollup ) => {
170- try {
171- await insertDeferredAuditEntry ( toAuditParams ( rollup ) , rollup . surface )
172- } catch ( error ) {
173- logger . error ( 'Failed to record audit log rollup' , {
174- error,
175- action : rollup . first . params . action ,
176- entries : rollup . entries ,
177- } )
178- }
202+ for ( let index = 0 ; index < rollups . length ; index += ROLLUP_WRITE_CONCURRENCY ) {
203+ await Promise . all ( rollups . slice ( index , index + ROLLUP_WRITE_CONCURRENCY ) . map ( writeRollup ) )
204+ }
205+ }
206+
207+ async function writeRollup ( rollup : PendingRollup ) : Promise < void > {
208+ try {
209+ await insertDeferredAuditEntry ( toAuditParams ( rollup ) , rollup . surface )
210+ } catch ( error ) {
211+ logger . error ( 'Failed to record audit log rollup' , {
212+ error,
213+ action : rollup . first . params . action ,
214+ entries : rollup . entries ,
179215 } )
180- )
216+ }
181217}
0 commit comments