-
Notifications
You must be signed in to change notification settings - Fork 12
Add per-write stage timing (realm:write-timing) #6164
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -262,6 +262,8 @@ import { | |
| sanitizeLoggingCorrelationId, | ||
| X_BOXEL_LOGGING_CORRELATION_ID_HEADER, | ||
| } from './prerender-headers.ts'; | ||
| import { RequestTimings } from './request-timings.ts'; | ||
| import { emitWriteTiming } from './write-timings.ts'; | ||
| import { mergeRelationships } from './merge-relationships.ts'; | ||
| import { getCardDirectoryName } from './helpers/card-directory-name.ts'; | ||
| import { | ||
|
|
@@ -7604,6 +7606,30 @@ export class Realm { | |
| // (the pre-write drain, and reading the card back out of the index vs. | ||
| // echoing it) and nothing prerender-specific beyond them. | ||
| let answerFromEcho = duringPrerender || isSkipIndexWaitRequest(request); | ||
| // Per-write stage timing, emitted as one `realm:write-timing` line when the | ||
| // request carries a correlation id (see write-timings.ts). `mark(stage)` | ||
| // stamps the wall-clock elapsed since the previous mark; `emit(outcome)` | ||
| // renders the line. Both are no-ops for an uninstrumented write. | ||
| let handlerStart = Date.now(); | ||
| let correlationId = sanitizeLoggingCorrelationId( | ||
| request.headers.get(X_BOXEL_LOGGING_CORRELATION_ID_HEADER), | ||
| ); | ||
| let timings = correlationId ? new RequestTimings() : undefined; | ||
| let lastMark = handlerStart; | ||
| let mark = (stage: string) => { | ||
| let now = Date.now(); | ||
| timings?.add(stage, now - lastMark); | ||
| lastMark = now; | ||
| }; | ||
| let emit = (outcome: string) => { | ||
| if (timings && correlationId) { | ||
| emitWriteTiming( | ||
| `corr=${correlationId} op=POST outcome=${outcome} total=${ | ||
| Date.now() - handlerStart | ||
| }ms ${timings.toLogFragment()}`, | ||
| ); | ||
| } | ||
| }; | ||
| // Drain any in-flight incremental indexing before serializing the new | ||
| // card, so the JSON-API path tolerates an immediately-preceding deferred | ||
| // +source POST without disturbing the +json POST's own | ||
|
|
@@ -7631,6 +7657,7 @@ export class Realm { | |
| await pending; | ||
| } | ||
| } | ||
| mark('drain'); | ||
| let body = await request.text(); | ||
| let json; | ||
| try { | ||
|
|
@@ -7726,11 +7753,29 @@ export class Realm { | |
| lid: primaryResource.lid, | ||
| }); | ||
| } | ||
| let [{ lastModified, created }] = await this.writeMany(files, { | ||
| clientRequestId: request.headers.get('X-Boxel-Client-Request-Id'), | ||
| initiatingUser: requestContext.authenticatedUser ?? null, | ||
| ...(answerFromEcho ? { waitForIndex: false } : {}), | ||
| }); | ||
| mark('serialize'); | ||
| // Take the write lock explicitly (rather than through `writeMany`, which | ||
| // is exactly `withWriteLock(() => _batchWriteUnlocked(...))`) so the wait | ||
| // for it is its own `lockWait` stage, as on the PATCH path. `writeMany` | ||
| // folds the lock acquisition into the write, and under contention that | ||
| // wait — not the file write or index — is what dominates; leaving it | ||
| // inside `write` would make a POST's lock contention as invisible as it | ||
| // was before this instrumentation. | ||
| let [{ lastModified, created }] = await this.#dbAdapter.withWriteLock( | ||
| this.url, | ||
| async () => { | ||
| mark('lockWait'); | ||
| return this._batchWriteUnlocked(files, { | ||
| clientRequestId: request.headers.get('X-Boxel-Client-Request-Id'), | ||
| initiatingUser: requestContext.authenticatedUser ?? null, | ||
| ...(answerFromEcho ? { waitForIndex: false } : {}), | ||
| }); | ||
| }, | ||
| ); | ||
| // On the default path `_batchWriteUnlocked` also awaits the card's | ||
| // synchronous index; the echo path passes `waitForIndex: false`, so this | ||
| // stage is the durable file write alone. | ||
| mark('write'); | ||
|
|
||
| let newURL = primaryResourceURL.href.replace(/\.json$/, ''); | ||
| let doc: SingleCardDocument; | ||
|
|
@@ -7742,6 +7787,8 @@ export class Realm { | |
| newURL, | ||
| lastModified, | ||
| ); | ||
| mark('echo'); | ||
| emit('echo'); | ||
| } else { | ||
| // The write response is read only for the primary card's assigned id | ||
| // and realm-info; the client discards its attributes, relationships and | ||
|
|
@@ -7757,6 +7804,8 @@ export class Realm { | |
| let err = entry | ||
| ? CardError.fromSerializableError(entry.error) | ||
| : undefined; | ||
| mark('readback'); | ||
| emit('error'); | ||
| return systemError({ | ||
| requestContext, | ||
| message: `Unable to index newly created card: ${newURL}, can't find new instance in index`, | ||
|
|
@@ -7770,6 +7819,8 @@ export class Realm { | |
| meta: { lastModified }, | ||
| }, | ||
| }); | ||
| mark('readback'); | ||
| emit('indexed'); | ||
| } | ||
| this.#serveInstanceIdsAsRRI(doc); | ||
| return createResponse({ | ||
|
|
@@ -7809,6 +7860,32 @@ export class Realm { | |
| // serialized echo rather than a readback. | ||
| let answerFromEcho = duringPrerender || isSkipIndexWaitRequest(request); | ||
|
|
||
| // Per-write stage timing, emitted as one `realm:write-timing` line when the | ||
| // request carries a correlation id (see write-timings.ts). Unlike the POST | ||
| // path, a PATCH also waits on the realm-wide write lock — `lockWait` is | ||
| // stamped when the critical section opens, so contention is attributable. | ||
| // `mark`/`emit` are no-ops for an uninstrumented write. | ||
| let handlerStart = Date.now(); | ||
| let correlationId = sanitizeLoggingCorrelationId( | ||
| request.headers.get(X_BOXEL_LOGGING_CORRELATION_ID_HEADER), | ||
| ); | ||
| let timings = correlationId ? new RequestTimings() : undefined; | ||
| let lastMark = handlerStart; | ||
| let mark = (stage: string) => { | ||
| let now = Date.now(); | ||
| timings?.add(stage, now - lastMark); | ||
| lastMark = now; | ||
| }; | ||
| let emit = (outcome: string) => { | ||
| if (timings && correlationId) { | ||
| emitWriteTiming( | ||
| `corr=${correlationId} op=PATCH outcome=${outcome} total=${ | ||
| Date.now() - handlerStart | ||
| }ms ${timings.toLogFragment()}`, | ||
| ); | ||
| } | ||
| }; | ||
|
Comment on lines
+7868
to
+7887
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Claude Code 🤖] This 20-line collector is byte-identical to the POST one apart from Suggest one home in export function writeTimer(request: Request, op: 'POST' | 'PATCH') {
let start = Date.now();
let corr = sanitizeLoggingCorrelationId(
request.headers.get(X_BOXEL_LOGGING_CORRELATION_ID_HEADER),
);
let timings = corr ? new RequestTimings() : undefined;
let last = start;
return {
mark(stage: string) { let now = Date.now(); timings?.add(stage, now - last); last = now; },
emit(outcome: string) {
if (!timings || !corr) return;
emitWriteTiming(`corr=${corr} op=${op} outcome=${outcome} total=${Date.now() - start}ms ${timings.toLogFragment()}`);
},
};
}It also makes the Class: follow-up. Non-blocking. Generated by Claude Code |
||
|
|
||
| let { data: patch, included: maybeIncluded } = await request.json(); | ||
| if (!isCardResource(patch)) { | ||
| return badRequest({ | ||
|
|
@@ -7843,6 +7920,9 @@ export class Realm { | |
| // public `writeMany` — re-entering the lock through the public method | ||
| // would block on a different pinned pool connection. | ||
| return await this.#dbAdapter.withWriteLock(this.url, async () => { | ||
| // Time from the handler start (before validation) to the lock opening. | ||
| // Under contention this is the term that dominates a slow PATCH. | ||
| mark('lockWait'); | ||
|
Comment on lines
+7923
to
+7925
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Claude Code 🤖] PATCH's The POST path doesn't have this — its Class: regression (introduced here, and it's the stage the PR's own motivating case turns on). Non-blocking — the line is still strictly more than exists today — but worth the two lines before this ships as the thing people read lock contention off. Generated by Claude Code |
||
| let primarySerialization: LooseSingleCardDocument | undefined; | ||
| // The merge base is the stored source file, not the index. The | ||
| // index is downstream of the file and can lag it — a backlogged or | ||
|
|
@@ -7944,6 +8024,9 @@ export class Realm { | |
| } | ||
| } | ||
|
|
||
| // The existing-file read plus the merge above are the prepare stage; | ||
| // the write and readback below are timed separately. | ||
| mark('prepare'); | ||
| // If the patch makes no semantic changes and doesn't include side-loaded | ||
| // resources, short-circuit to avoid touching the file (and changing mtime). | ||
| if (included.length === 0 && isEqual(primaryResource, original)) { | ||
|
|
@@ -7954,7 +8037,12 @@ export class Realm { | |
| let entry = await this.#realmIndexQueryEngine.cardDocument( | ||
| new URL(instanceURL), | ||
| ); | ||
| // Marked inside the guard, not before it: on the rare fall-through | ||
| // (a no-op patch against an errored index row) the normal write path | ||
| // below marks its own `readback`, and `RequestTimings.add` sums | ||
| // repeated keys — so an unconditional mark here would double-count. | ||
| if (entry && entry.type !== 'error') { | ||
| mark('readback'); | ||
| let existingDoc = merge({}, entry.doc, { | ||
| data: { | ||
| links: { self: instanceURL }, | ||
|
|
@@ -7983,6 +8071,7 @@ export class Realm { | |
| // conditional GET 304 onto this closure-less body. The client | ||
| // discards the echo body anyway, so it has no validator to gain. | ||
| this.#serveInstanceIdsAsRRI(existingDoc); | ||
| emit('noop'); | ||
| return createResponse({ | ||
| body: JSON.stringify(existingDoc, null, 2), | ||
| init: { | ||
|
|
@@ -8060,6 +8149,7 @@ export class Realm { | |
| primarySerialization = fileSerialization; | ||
| } | ||
| } | ||
| mark('serialize'); | ||
| // Use the unlocked inner write so we don't re-enter | ||
| // withWriteLock (which would block on a different pinned pool | ||
| // connection). | ||
|
|
@@ -8068,6 +8158,9 @@ export class Realm { | |
| initiatingUser: requestContext.authenticatedUser ?? null, | ||
| ...(answerFromEcho ? { waitForIndex: false } : {}), | ||
| }); | ||
| // On the default path `_batchWriteUnlocked` also awaits the card's | ||
| // synchronous index; the echo path passes `waitForIndex: false`. | ||
| mark('write'); | ||
| let doc: SingleCardDocument; | ||
| if (answerFromEcho) { | ||
| // See serializedInstanceEcho: the write indexed deferred, so there is | ||
|
|
@@ -8078,6 +8171,8 @@ export class Realm { | |
| lastModified, | ||
| ); | ||
| this.#serveInstanceIdsAsRRI(doc); | ||
| mark('echo'); | ||
| emit('echo'); | ||
| return createResponse({ | ||
| body: JSON.stringify(doc, null, 2), | ||
| init: { | ||
|
|
@@ -8103,6 +8198,7 @@ export class Realm { | |
| let entry = await this.#realmIndexQueryEngine.cardDocument( | ||
| new URL(instanceURL), | ||
| ); | ||
| mark('readback'); | ||
| if (!entry || entry?.type === 'error') { | ||
| if ( | ||
| primarySerialization && | ||
|
|
@@ -8120,6 +8216,7 @@ export class Realm { | |
| }, | ||
| }) as SingleCardDocument; | ||
| } else { | ||
| emit('error'); | ||
| return systemError({ | ||
| requestContext, | ||
| message: `Unable to index card: can't find patched instance, ${instanceURL} in index`, | ||
|
|
@@ -8156,6 +8253,7 @@ export class Realm { | |
| // emitting one would let a conditional GET 304 onto this closure-less | ||
| // body. The client discards the echo body anyway. | ||
| this.#serveInstanceIdsAsRRI(doc); | ||
| emit('indexed'); | ||
| return createResponse({ | ||
| body: JSON.stringify(doc, null, 2), | ||
| init: { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Claude Code 🤖] Every non-happy exit still logs nothing, including the slow ones.
emitfires only from the explicit return sites, so nothing is emitted when: a POST bails at any of the fivebadRequestreturns aftermark('drain')(the drain can be the long stage); a PATCH returnsnotFoundfor a missing source file — after paying the full lock wait; or anything throws out of_batchWriteUnlocked, the readback, or the merge. A write that takes 190s and then throws is the case most worth attributing, and it's the one that produces no line at all.A
try { … } finally { if (!emitted) emit('throw') }around each handler body covers all three shapes at once, withemitsetting the flag so a normal return still wins the outcome label.Class: follow-up (the same gap exists in
emitSearchTiming, so not introduced here). Non-blocking.Generated by Claude Code