Skip to content

Commit 0445b17

Browse files
author
ClaudiaFang
committed
perf(push): eliminate redundant GitHub requests
1 parent 49f16c6 commit 0445b17

9 files changed

Lines changed: 352 additions & 142 deletions

docs/push-strategy-benchmark.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# GitHub push strategy benchmark (#61)
2+
3+
## Procedure
4+
5+
Use a disposable repository and branch. For each file count (1, 5, 10, 25, 50, 100, 200), generate equal-size unique files, run the GraphQL path and then the developer-only `pushBatchViaGitDataApiForBenchmark` path on separate branches, and record the opt-in `PushTimingRecord` plus the REST wall time. Do not run this against a user vault or production branch.
6+
7+
The GraphQL record is enabled only by registering `setPushTimingHandler`; it contains no file paths, contents, token, repository identity, or network transmission. `providerProcessingMs` is intentionally `null`, because Obsidian's `requestUrl` API does not expose server-only timing.
8+
9+
## Deterministic request-count result
10+
11+
| Files | GraphQL requests | Git Data API requests | Git Data API request waves (8 concurrent blobs) |
12+
| ---: | ---: | ---: | ---: |
13+
| 1 | 2 | 6 | 6 |
14+
| 5 | 2 | 10 | 6 |
15+
| 10 | 2 | 15 | 7 |
16+
| 25 | 2 | 30 | 9 |
17+
| 50 | 2 | 55 | 12 |
18+
| 100 | 2 | 105 | 18 |
19+
| 200 | 2 | 205 | 30 |
20+
21+
GraphQL performs a branch-head read and one mutation; the caller marks the committed paths synced without a tree readback. Git Data API performs a ref read, commit read, one blob upload per file, tree creation, commit creation, and ref update. The table is covered by service tests for both implementations.
22+
23+
## Decision
24+
25+
Keep GraphQL as the production strategy for every supported batch size (1–200). There is no request-count crossover: Git Data API starts with four additional round trips and adds one request per file. The REST path remains developer-only so provider benchmarks can challenge this conclusion without adding a user-facing strategy switch.

src/logic/sync-manager.ts

Lines changed: 4 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -190,13 +190,8 @@ export class SyncManager {
190190
);
191191

192192
// Update metadata
193-
let newSha = result.sha;
194-
if (!newSha) {
195-
const newRemote = await this.gitService.getFile(repoPath, this.settings.branch);
196-
newSha = newRemote.sha;
197-
}
198-
199-
if (newSha) await this.updateMetadata(file.path, newSha);
193+
const newSha = result.sha ?? await gitBlobSha(content);
194+
await this.updateMetadata(file.path, newSha);
200195

201196
// Remove old metadata
202197
delete this.settings.syncMetadata[oldPath];
@@ -220,13 +215,8 @@ export class SyncManager {
220215
);
221216

222217
// Update metadata
223-
let newSha = result.sha;
224-
if (!newSha) {
225-
const newRemote = await this.gitService.getFile(repoPath, this.settings.branch);
226-
newSha = newRemote.sha;
227-
}
228-
229-
if (newSha) await this.updateMetadata(file.path, newSha);
218+
const newSha = result.sha ?? await gitBlobSha(content);
219+
await this.updateMetadata(file.path, newSha);
230220

231221
if (!silent) new Notice(`Pushed ${file.name} to ${this.serviceName}`);
232222
return newSha;

src/services/git-service-interface.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ export interface GitServiceInterface {
4646
updateConfig(...args: unknown[]): void;
4747
getFile(path: string, branch: string): Promise<GitFile>;
4848
pushFile(path: string, content: string | ArrayBuffer, branch: string, commitMessage: string, existingSha?: string): Promise<{ path: string, sha?: string }>;
49+
/** Returns the branch's current commit SHA when the provider can expose it cheaply. */
50+
getBranchHead?(branch: string): Promise<string>;
4951
/** Checks the repository is reachable and the given branch exists. */
5052
testConnection(branch: string): Promise<ConnectionTestResult>;
5153
listFiles(branch: string, useFilter?: boolean): Promise<string[]>;

src/services/github-service.ts

Lines changed: 86 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { GitServiceInterface, GitTreeEntry, BatchPushItem, BatchPushResult } from './git-service-interface';
2-
import { BaseGitService, ConnectionTestResult, GitFile, GitHubContentResponse, GitHubTreeResponse, GIT_SYMLINK_MODE } from './git-service-base';
2+
import { BaseGitService, ConnectionTestResult, GitFile, GitHubContentResponse, GitHubTreeResponse, GIT_SYMLINK_MODE, BLOB_CREATE_CONCURRENCY } from './git-service-base';
33
import { logger } from '../utils/logger';
4+
import { PushTimingCollector, PushTimingHandler, PushTimingRecord } from './push-timing';
45

56
/**
67
* Commits any mix of file additions/deletions in one request. Used instead of
@@ -20,6 +21,12 @@ const CREATE_COMMIT_MUTATION = `
2021
export class GitHubService extends BaseGitService implements GitServiceInterface {
2122
private owner: string = '';
2223
private repo: string = '';
24+
private pushTimingHandler?: PushTimingHandler;
25+
26+
/** Enables local diagnostic records; the plugin itself never enables this. */
27+
setPushTimingHandler(handler?: PushTimingHandler): void {
28+
this.pushTimingHandler = handler;
29+
}
2330

2431
updateConfig(token: string, owner: string, repo: string, rootPath: string = '') {
2532
this.token = token;
@@ -42,6 +49,10 @@ export class GitHubService extends BaseGitService implements GitServiceInterface
4249
return `https://api.github.com/repos/${this.owner}/${this.repo}`;
4350
}
4451

52+
async getBranchHead(branch: string): Promise<string> {
53+
return this.getLatestCommitSha(branch);
54+
}
55+
4556
async getFile(path: string, branch: string): Promise<GitFile> {
4657
try {
4758
const url = `${this.getApiUrl(path)}?ref=${branch}`;
@@ -64,21 +75,9 @@ export class GitHubService extends BaseGitService implements GitServiceInterface
6475
}
6576
}
6677

67-
async pushFile(path: string, content: string | ArrayBuffer, branch: string, message: string, sha?: string): Promise<{ path: string, sha?: string }> {
68-
const url = this.getApiUrl(path);
69-
const body: { message: string; content: string; branch: string; sha?: string } = {
70-
message,
71-
content: this.encodeContent(content),
72-
branch,
73-
};
74-
// GitHub's Contents API rejects a blank sha with HTTP 422. Only include
75-
// it when updating an existing file; a 404 lookup yields sha === '' for
76-
// new files, which must be created without a sha.
77-
if (sha) body.sha = sha;
78-
79-
const response = await this.safeRequest(url, 'PUT', body);
80-
const data = this.parseJson<{ content: { path: string, sha: string } }>(response);
81-
return { path: data.content.path, sha: data.content.sha };
78+
async pushFile(path: string, content: string | ArrayBuffer, branch: string, message: string, _existingSha?: string): Promise<{ path: string, sha?: string }> {
79+
const [result] = await this.pushBatch([{ path, content }], branch, message);
80+
return result ?? { path };
8281
}
8382

8483
async pushSymlink(path: string, target: string, branch: string, message: string): Promise<{ path: string, sha?: string }> {
@@ -108,9 +107,11 @@ export class GitHubService extends BaseGitService implements GitServiceInterface
108107
* rather than an HTTP error status, so this checks for that on top of
109108
* safeRequest's status-code check.
110109
*/
111-
private async githubGraphQL<T>(query: string, variables: Record<string, unknown>): Promise<T> {
112-
const response = await this.safeRequest('https://api.github.com/graphql', 'POST', { query, variables });
113-
const body = this.parseJson<{ data?: T; errors?: Array<{ message: string }> }>(response);
110+
private async githubGraphQL<T>(query: string, variables: Record<string, unknown>, timing?: PushTimingCollector): Promise<T> {
111+
const request = () => this.safeRequest('https://api.github.com/graphql', 'POST', { query, variables });
112+
const response = timing ? await timing.measureRequest(request) : await request();
113+
const parse = () => this.parseJson<{ data?: T; errors?: Array<{ message: string }> }>(response);
114+
const body = timing ? timing.measureParsing(parse) : parse();
114115
if (body.errors && body.errors.length > 0) {
115116
throw new Error(`GitHub GraphQL error: ${body.errors.map(e => e.message).join('; ')}`);
116117
}
@@ -130,10 +131,11 @@ export class GitHubService extends BaseGitService implements GitServiceInterface
130131
* an obvious staleness error. A short retry with a freshly re-read HEAD
131132
* self-heals once GitHub's read catches up.
132133
*/
133-
private async commitOnBranch(branch: string, message: string, fileChanges: Record<string, unknown>): Promise<string> {
134+
private async commitOnBranch(branch: string, message: string, fileChanges: Record<string, unknown>, timing?: PushTimingCollector): Promise<string> {
134135
const maxAttempts = 3;
135136
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
136-
const expectedHeadOid = await this.getLatestCommitSha(branch);
137+
const getHead = () => this.getLatestCommitSha(branch);
138+
const expectedHeadOid = timing ? await timing.measureRequest(getHead) : await getHead();
137139
try {
138140
const data = await this.githubGraphQL<{ createCommitOnBranch: { commit: { oid: string } } }>(CREATE_COMMIT_MUTATION, {
139141
input: {
@@ -142,7 +144,7 @@ export class GitHubService extends BaseGitService implements GitServiceInterface
142144
expectedHeadOid,
143145
fileChanges,
144146
},
145-
});
147+
}, timing);
146148
return data.createCommitOnBranch.commit.oid;
147149
} catch (e) {
148150
const errorMessage = e instanceof Error ? e.message : String(e);
@@ -157,31 +159,71 @@ export class GitHubService extends BaseGitService implements GitServiceInterface
157159

158160
async pushBatch(items: BatchPushItem[], branch: string, message: string): Promise<BatchPushResult[]> {
159161
if (items.length === 0) return [];
162+
const timing = this.pushTimingHandler ? new PushTimingCollector() : undefined;
163+
const preparationStartedAt = performance.now();
164+
const preparedItems = items.map(item => ({ item, path: this.getFullPath(item.path) }));
165+
const rawBytes = items.reduce((total, item) => total + this.getByteLength(item.content), 0);
166+
const changePreparationMs = performance.now() - preparationStartedAt;
167+
const encodingStartedAt = performance.now();
168+
const additions = preparedItems.map(({ item, path }) => ({ path, contents: this.encodeContent(item.content) }));
169+
const encodedBytes = additions.reduce((total, addition) => total + this.getByteLength(addition.contents), 0);
170+
const encodingMs = performance.now() - encodingStartedAt;
171+
let failure: unknown;
160172

161-
await this.commitOnBranch(branch, message, {
162-
additions: items.map(item => ({
163-
path: this.getFullPath(item.path),
164-
contents: this.encodeContent(item.content),
165-
})),
166-
});
173+
try {
174+
await this.commitOnBranch(branch, message, { additions }, timing);
175+
// The caller already marks committed paths as synced. Avoiding a
176+
// full recursive tree read saves a request and sidesteps GitHub's
177+
// briefly stale tree reads after a successful mutation.
178+
return items.map(item => ({ path: item.path }));
179+
} catch (error) {
180+
failure = error;
181+
throw error;
182+
} finally {
183+
this.emitPushTiming(timing, 'github-graphql', items.length, rawBytes, encodedBytes, changePreparationMs, encodingMs, failure);
184+
}
185+
}
186+
187+
/**
188+
* Developer-only Git Data API control path for benchmark #61. Production
189+
* pushes continue to use GraphQL because this path requires one blob POST
190+
* per file. It is intentionally not part of GitServiceInterface.
191+
*/
192+
async pushBatchViaGitDataApiForBenchmark(items: BatchPushItem[], branch: string, message: string): Promise<BatchPushResult[]> {
193+
if (items.length === 0) return [];
167194

168-
// createCommitOnBranch only returns the new commit's oid, not each
169-
// file's blob sha, so read them back with a follow-up tree fetch
170-
// (mirrors GitLab's pushBatch, which has the same limitation). That
171-
// fetch is exposed to the same eventual-consistency lag the retry
172-
// above works around, so a fresh tree can still be briefly missing an
173-
// entry that was just committed; retry it too rather than silently
174-
// returning an undefined sha for that file.
195+
const base = this.getGitDataApiBase();
196+
const { latestCommitSha, baseTreeSha } = await this.resolveGitHubStyleBaseTree(branch);
175197
const fullPaths = items.map(item => this.getFullPath(item.path));
176-
for (let attempt = 1; attempt <= 3; attempt++) {
177-
const freshTree = await this.listFilesDetailed(branch, false);
178-
const shaByPath = new Map(freshTree.map(e => [e.path, e.sha]));
179-
const results = items.map((item, i) => ({ path: item.path, sha: shaByPath.get(fullPaths[i] as string) }));
180-
if (results.every(r => r.sha) || attempt === 3) return results;
181-
await new Promise(resolve => window.setTimeout(resolve, 500 * attempt));
182-
}
183-
// Unreachable: the loop always returns on its last iteration.
184-
throw new Error('pushBatch: exhausted retries reading back blob shas');
198+
const blobShas = await this.mapWithConcurrency(items, BLOB_CREATE_CONCURRENCY, async item => {
199+
const response = await this.safeRequest(`${base}/git/blobs`, 'POST', {
200+
content: this.encodeContent(item.content),
201+
encoding: 'base64',
202+
});
203+
return this.parseJson<{ sha: string }>(response).sha;
204+
});
205+
206+
await this.commitGitHubStyleTree(
207+
base, branch, baseTreeSha, latestCommitSha,
208+
fullPaths.map((path, index) => ({ path, mode: '100644', type: 'blob' as const, sha: blobShas[index] as string })),
209+
message
210+
);
211+
return items.map((item, index) => ({ path: item.path, sha: blobShas[index] }));
212+
}
213+
214+
private getByteLength(content: string | ArrayBuffer): number {
215+
return typeof content === 'string' ? new TextEncoder().encode(content).byteLength : content.byteLength;
216+
}
217+
218+
private getErrorMessage(error: unknown): string | undefined {
219+
if (error === undefined) return undefined;
220+
return error instanceof Error ? error.message : 'Non-Error push failure';
221+
}
222+
223+
private emitPushTiming(timing: PushTimingCollector | undefined, strategy: PushTimingRecord['strategy'], fileCount: number, rawBytes: number, encodedBytes: number, changePreparationMs: number, encodingMs: number, error?: unknown): void {
224+
if (!timing || !this.pushTimingHandler) return;
225+
const failure = this.getErrorMessage(error);
226+
this.pushTimingHandler(timing.createRecord(strategy, fileCount, rawBytes, encodedBytes, changePreparationMs, encodingMs, failure));
185227
}
186228

187229
async listFilesDetailed(branch: string, useFilter = true): Promise<GitTreeEntry[]> {

src/services/push-timing.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/**
2+
* Developer-facing, telemetry-free measurements for one batched push.
3+
* The plugin never stores or transmits these records; callers opt in by
4+
* registering a handler on GitHubService.
5+
*/
6+
export interface PushTimingRecord {
7+
strategy: 'github-graphql' | 'github-git-data';
8+
fileCount: number;
9+
rawBytes: number;
10+
encodedBytes: number;
11+
changePreparationMs: number;
12+
encodingMs: number;
13+
requestUploadMs: number;
14+
responseParsingMs: number;
15+
/** requestUrl does not expose server-only timing, so it is intentionally null. */
16+
providerProcessingMs: null;
17+
requestCount: number;
18+
totalMs: number;
19+
failure?: string;
20+
}
21+
22+
export type PushTimingHandler = (record: PushTimingRecord) => void;
23+
24+
export class PushTimingCollector {
25+
private readonly startedAt = performance.now();
26+
private requestUploadMs = 0;
27+
private responseParsingMs = 0;
28+
private requestCount = 0;
29+
30+
async measureRequest<T>(operation: () => Promise<T>): Promise<T> {
31+
const startedAt = performance.now();
32+
try {
33+
return await operation();
34+
} finally {
35+
this.requestUploadMs += performance.now() - startedAt;
36+
this.requestCount++;
37+
}
38+
}
39+
40+
measureParsing<T>(operation: () => T): T {
41+
const startedAt = performance.now();
42+
try {
43+
return operation();
44+
} finally {
45+
this.responseParsingMs += performance.now() - startedAt;
46+
}
47+
}
48+
49+
createRecord(
50+
strategy: PushTimingRecord['strategy'], fileCount: number, rawBytes: number,
51+
encodedBytes: number, changePreparationMs: number, encodingMs: number, failure?: string
52+
): PushTimingRecord {
53+
return {
54+
strategy, fileCount, rawBytes, encodedBytes, changePreparationMs, encodingMs,
55+
requestUploadMs: this.requestUploadMs, responseParsingMs: this.responseParsingMs,
56+
providerProcessingMs: null, requestCount: this.requestCount,
57+
totalMs: performance.now() - this.startedAt,
58+
...(failure ? { failure } : {}),
59+
};
60+
}
61+
}

0 commit comments

Comments
 (0)