kankaku 0.8.0 → 0.8.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -60,7 +60,11 @@ function isThrottled(state, trigger, now, minIntervalMs) {
|
|
|
60
60
|
export async function runSync(deps, options = {}) {
|
|
61
61
|
const startedAt = deps.clock.now();
|
|
62
62
|
if (options.trigger !== undefined) {
|
|
63
|
-
|
|
63
|
+
// Gated like every other read below: a state written for another hub
|
|
64
|
+
// says nothing about THIS hub, so neither its `logVersion` (which
|
|
65
|
+
// would otherwise skip the new hub's very first run until the next
|
|
66
|
+
// record is appended) nor its `lastRunAt` throttle may apply.
|
|
67
|
+
const peek = stateForTarget(deps, deps.stateStore.read());
|
|
64
68
|
const currentVersion = deps.log.version?.();
|
|
65
69
|
const versionUnchanged = currentVersion !== undefined && peek?.logVersion === currentVersion;
|
|
66
70
|
if (versionUnchanged && peek?.lastError === undefined) {
|
|
@@ -75,10 +79,17 @@ export async function runSync(deps, options = {}) {
|
|
|
75
79
|
try {
|
|
76
80
|
lockAcquired = deps.stateStore.tryLock();
|
|
77
81
|
if (!lockAcquired) {
|
|
78
|
-
const state = deps.stateStore.read();
|
|
82
|
+
const state = stateForTarget(deps, deps.stateStore.read());
|
|
79
83
|
return { ...emptySummary(deps.clock.now() - startedAt, state?.syncedThrough), locked: true };
|
|
80
84
|
}
|
|
81
85
|
const state = deps.stateStore.read();
|
|
86
|
+
// A state from another hub is ignored wholesale, on EVERY path below
|
|
87
|
+
// (the summary, the error path, and the successful merge): its
|
|
88
|
+
// watermark and its hashes describe rows the configured hub does not
|
|
89
|
+
// have (mirrors `domain/sync-plan.ts#planSync`'s gate). `planSync`
|
|
90
|
+
// still receives the raw `state`, since it applies that same gate
|
|
91
|
+
// itself and its `target` comparison is what makes the run a full one.
|
|
92
|
+
const priorState = stateForTarget(deps, state);
|
|
82
93
|
// Captured once, here, and persisted as-is below: this is the version
|
|
83
94
|
// the tasks below were actually built from, not whatever the log might
|
|
84
95
|
// become by the time an awaited push finishes.
|
|
@@ -93,10 +104,15 @@ export async function runSync(deps, options = {}) {
|
|
|
93
104
|
// WorkSink implementations are expected never to throw, but this
|
|
94
105
|
// runner must hold that guarantee even if one does.
|
|
95
106
|
const message = error instanceof Error ? error.message : String(error);
|
|
96
|
-
const summary = emptySummary(deps.clock.now() - startedAt,
|
|
107
|
+
const summary = emptySummary(deps.clock.now() - startedAt, priorState?.syncedThrough);
|
|
97
108
|
summary.skipped = plan.unchangedCount;
|
|
98
109
|
summary.error = message;
|
|
99
|
-
|
|
110
|
+
// `priorState`, never `state`: a throwing sink on the first run
|
|
111
|
+
// against a NEW hub must not carry the old hub's hashes and
|
|
112
|
+
// watermark over under the new target, or the next run would
|
|
113
|
+
// skip every task as "unchanged" (the exact bug 0.8.1 fixed on the
|
|
114
|
+
// successful path).
|
|
115
|
+
persistError(deps, priorState, message, logVersionAtRead);
|
|
100
116
|
return summary;
|
|
101
117
|
}
|
|
102
118
|
const byId = new Map(plan.toSync.map((task) => [task.id, task]));
|
|
@@ -111,7 +127,7 @@ export async function runSync(deps, options = {}) {
|
|
|
111
127
|
const unassigned = new Map();
|
|
112
128
|
let uploaded = 0;
|
|
113
129
|
let updated = 0;
|
|
114
|
-
let syncedThrough =
|
|
130
|
+
let syncedThrough = priorState?.syncedThrough;
|
|
115
131
|
let stopError;
|
|
116
132
|
// Whether at least one task was actually resolved (pushed or recorded
|
|
117
133
|
// as failed) this run — as opposed to the run stopping on its very
|
|
@@ -147,13 +163,16 @@ export async function runSync(deps, options = {}) {
|
|
|
147
163
|
}
|
|
148
164
|
else {
|
|
149
165
|
// "error": a network/timeout/5xx/auth failure. Stop here — nothing
|
|
150
|
-
// after this point in the
|
|
151
|
-
//
|
|
166
|
+
// after this point in the results is considered resolved. The
|
|
167
|
+
// results follow `plan.toSync`'s order (new work oldest-first,
|
|
168
|
+
// then corrections newest-first), and `syncedThrough` is a max
|
|
169
|
+
// over what WAS resolved, so stopping early can only leave it
|
|
170
|
+
// lower, never advance it past an unresolved task.
|
|
152
171
|
stopError = result.outcome.reason;
|
|
153
172
|
break;
|
|
154
173
|
}
|
|
155
174
|
}
|
|
156
|
-
const mergedHashes = { ...(
|
|
175
|
+
const mergedHashes = { ...(priorState?.hashes ?? {}), ...Object.fromEntries(newHashes) };
|
|
157
176
|
// G2: no longer window-bound — see `domain/sync-plan.ts#pruneHashes`'s
|
|
158
177
|
// doc comment. `tasks` here is every task `buildTasks` currently knows
|
|
159
178
|
// about (the full `readAll()`, not just this run's eligible/window
|
|
@@ -188,6 +207,11 @@ export async function runSync(deps, options = {}) {
|
|
|
188
207
|
deps.stateStore.unlock();
|
|
189
208
|
}
|
|
190
209
|
}
|
|
210
|
+
/** `state` when it was written against `deps.target`; `undefined` (as if there were no state at all) when it belongs to another hub. */
|
|
211
|
+
function stateForTarget(deps, state) {
|
|
212
|
+
return state !== undefined && state.target === deps.target ? state : undefined;
|
|
213
|
+
}
|
|
214
|
+
/** Persist a failed run. `state` must already be gated by {@link stateForTarget}: what it carries is re-written under `deps.target`. */
|
|
191
215
|
function persistError(deps, state, message, logVersionAtRead) {
|
|
192
216
|
deps.stateStore.write({
|
|
193
217
|
target: deps.target,
|
|
@@ -14,7 +14,7 @@ import type { TaskView } from "./task-view.ts";
|
|
|
14
14
|
export interface SyncState {
|
|
15
15
|
/** High-watermark ISO timestamp: everything with `endedAt` at or before `syncedThrough - window` is considered done. `undefined` before the first successful sync. */
|
|
16
16
|
syncedThrough?: string;
|
|
17
|
-
/** Content hash per task id (see {@link computeTaskContentHash}), pruned
|
|
17
|
+
/** Content hash per task id (see {@link computeTaskContentHash}), kept for as long as the task exists — never pruned by the revisit window (see {@link pruneHashes}). */
|
|
18
18
|
hashes: Record<string, string>;
|
|
19
19
|
/** The hub URL this state was synced against; a state written for a different URL is treated as absent (full sync). */
|
|
20
20
|
target: string;
|
|
@@ -36,7 +36,8 @@ export interface SyncState {
|
|
|
36
36
|
* `Clock`-based timestamp (ms) of the last real (non-short-circuited)
|
|
37
37
|
* automatic sync attempt, persisted so the automatic path's throttle
|
|
38
38
|
* (`KANKAKU_SYNC_MIN_INTERVAL_MINUTES`) holds across processes, not just
|
|
39
|
-
* within one.
|
|
39
|
+
* within one. Written by every run that reaches the sink, manual or
|
|
40
|
+
* automatic; only the automatic path READS it to throttle itself.
|
|
40
41
|
*/
|
|
41
42
|
lastRunAt?: number;
|
|
42
43
|
}
|
|
@@ -49,7 +50,7 @@ export interface SyncPlanOptions {
|
|
|
49
50
|
full?: boolean;
|
|
50
51
|
}
|
|
51
52
|
export interface SyncPlan {
|
|
52
|
-
/** Tasks whose content changed (or were never synced) and therefore need a request
|
|
53
|
+
/** Tasks whose content changed (or were never synced) and therefore need a request: new work in the window oldest-first, then corrections to rows the hub already holds, newest-first (see {@link planSync}). */
|
|
53
54
|
toSync: TaskView[];
|
|
54
55
|
/** How many eligible tasks were skipped because their stored hash already matched — no request needed for them. */
|
|
55
56
|
unchangedCount: number;
|
|
@@ -115,8 +116,8 @@ export declare function planSync(tasks: TaskView[], state: SyncState | undefined
|
|
|
115
116
|
* for "unchanged but I forgot"), so that window-based pruning made every
|
|
116
117
|
* task older than the window look permanently changed, forever, the moment
|
|
117
118
|
* its hash was first pruned: `/kankaku sync status` would report a
|
|
118
|
-
* never-shrinking "changed outside the window" count that
|
|
119
|
-
*
|
|
119
|
+
* never-shrinking "changed outside the window" count that trained users
|
|
120
|
+
* to ignore it (the bug this rewrite fixes).
|
|
120
121
|
*
|
|
121
122
|
* Never pruning by window instead means `hashes` grows with the total
|
|
122
123
|
* number of distinct tasks a directory has ever synced, not with time — an
|
package/dist/domain/sync-plan.js
CHANGED
|
@@ -110,7 +110,11 @@ export function planSync(tasks, state, options) {
|
|
|
110
110
|
eligible = sorted.filter((task) => Date.parse(task.endedAt) > cutoff);
|
|
111
111
|
outsideWindow = sorted.filter((task) => Date.parse(task.endedAt) <= cutoff);
|
|
112
112
|
}
|
|
113
|
-
|
|
113
|
+
// A state written for another hub contributes nothing, not even its
|
|
114
|
+
// hashes: the new hub has none of these rows, so a task that matched
|
|
115
|
+
// the OLD hub's hash must still be pushed. `isFullSync` alone only
|
|
116
|
+
// widened the window; without this gate every task looked "unchanged".
|
|
117
|
+
const hashes = state !== undefined && state.target === options.target ? state.hashes : {};
|
|
114
118
|
const changed = (task) => hashes[task.id] !== computeTaskContentHash(task);
|
|
115
119
|
// A row the hub ALREADY holds is corrected wherever it sits: the window
|
|
116
120
|
// bounds how far back NEW work is looked for, never whether a known row
|
|
@@ -128,10 +132,10 @@ export function planSync(tasks, state, options) {
|
|
|
128
132
|
const knownAndChanged = isFullSync ? allCorrections : allCorrections.slice(0, MAX_CORRECTIONS_PER_RUN);
|
|
129
133
|
const correctionsDeferred = allCorrections.length - knownAndChanged.length;
|
|
130
134
|
const toSync = [...eligible.filter(changed), ...knownAndChanged];
|
|
131
|
-
// R3: cheap, pure visibility into a task
|
|
132
|
-
//
|
|
133
|
-
// comment.
|
|
134
|
-
//
|
|
135
|
+
// R3: cheap, pure visibility into a task this incremental run's window
|
|
136
|
+
// will not look at and that this hub has NEVER received (no stored hash)
|
|
137
|
+
// — see SyncPlan's doc comment. A known row that changed is a correction
|
|
138
|
+
// and is handled above, not reported here.
|
|
135
139
|
const staleOutsideWindow = outsideWindow.filter((task) => hashes[task.id] === undefined);
|
|
136
140
|
return { toSync, unchangedCount: eligible.length - eligible.filter(changed).length, isFullSync, staleOutsideWindow, correctionsDeferred };
|
|
137
141
|
}
|
|
@@ -149,8 +153,8 @@ export function planSync(tasks, state, options) {
|
|
|
149
153
|
* for "unchanged but I forgot"), so that window-based pruning made every
|
|
150
154
|
* task older than the window look permanently changed, forever, the moment
|
|
151
155
|
* its hash was first pruned: `/kankaku sync status` would report a
|
|
152
|
-
* never-shrinking "changed outside the window" count that
|
|
153
|
-
*
|
|
156
|
+
* never-shrinking "changed outside the window" count that trained users
|
|
157
|
+
* to ignore it (the bug this rewrite fixes).
|
|
154
158
|
*
|
|
155
159
|
* Never pruning by window instead means `hashes` grows with the total
|
|
156
160
|
* number of distinct tasks a directory has ever synced, not with time — an
|
package/package.json
CHANGED
|
@@ -108,7 +108,11 @@ export async function runSync(deps: SyncRunnerDeps, options: { full?: boolean; t
|
|
|
108
108
|
const startedAt = deps.clock.now();
|
|
109
109
|
|
|
110
110
|
if (options.trigger !== undefined) {
|
|
111
|
-
|
|
111
|
+
// Gated like every other read below: a state written for another hub
|
|
112
|
+
// says nothing about THIS hub, so neither its `logVersion` (which
|
|
113
|
+
// would otherwise skip the new hub's very first run until the next
|
|
114
|
+
// record is appended) nor its `lastRunAt` throttle may apply.
|
|
115
|
+
const peek = stateForTarget(deps, deps.stateStore.read());
|
|
112
116
|
const currentVersion = deps.log.version?.();
|
|
113
117
|
const versionUnchanged = currentVersion !== undefined && peek?.logVersion === currentVersion;
|
|
114
118
|
if (versionUnchanged && peek?.lastError === undefined) {
|
|
@@ -125,11 +129,18 @@ export async function runSync(deps: SyncRunnerDeps, options: { full?: boolean; t
|
|
|
125
129
|
try {
|
|
126
130
|
lockAcquired = deps.stateStore.tryLock();
|
|
127
131
|
if (!lockAcquired) {
|
|
128
|
-
const state = deps.stateStore.read();
|
|
132
|
+
const state = stateForTarget(deps, deps.stateStore.read());
|
|
129
133
|
return { ...emptySummary(deps.clock.now() - startedAt, state?.syncedThrough), locked: true };
|
|
130
134
|
}
|
|
131
135
|
|
|
132
136
|
const state = deps.stateStore.read();
|
|
137
|
+
// A state from another hub is ignored wholesale, on EVERY path below
|
|
138
|
+
// (the summary, the error path, and the successful merge): its
|
|
139
|
+
// watermark and its hashes describe rows the configured hub does not
|
|
140
|
+
// have (mirrors `domain/sync-plan.ts#planSync`'s gate). `planSync`
|
|
141
|
+
// still receives the raw `state`, since it applies that same gate
|
|
142
|
+
// itself and its `target` comparison is what makes the run a full one.
|
|
143
|
+
const priorState = stateForTarget(deps, state);
|
|
133
144
|
// Captured once, here, and persisted as-is below: this is the version
|
|
134
145
|
// the tasks below were actually built from, not whatever the log might
|
|
135
146
|
// become by the time an awaited push finishes.
|
|
@@ -144,10 +155,15 @@ export async function runSync(deps: SyncRunnerDeps, options: { full?: boolean; t
|
|
|
144
155
|
// WorkSink implementations are expected never to throw, but this
|
|
145
156
|
// runner must hold that guarantee even if one does.
|
|
146
157
|
const message = error instanceof Error ? error.message : String(error);
|
|
147
|
-
const summary = emptySummary(deps.clock.now() - startedAt,
|
|
158
|
+
const summary = emptySummary(deps.clock.now() - startedAt, priorState?.syncedThrough);
|
|
148
159
|
summary.skipped = plan.unchangedCount;
|
|
149
160
|
summary.error = message;
|
|
150
|
-
|
|
161
|
+
// `priorState`, never `state`: a throwing sink on the first run
|
|
162
|
+
// against a NEW hub must not carry the old hub's hashes and
|
|
163
|
+
// watermark over under the new target, or the next run would
|
|
164
|
+
// skip every task as "unchanged" (the exact bug 0.8.1 fixed on the
|
|
165
|
+
// successful path).
|
|
166
|
+
persistError(deps, priorState, message, logVersionAtRead);
|
|
151
167
|
return summary;
|
|
152
168
|
}
|
|
153
169
|
|
|
@@ -163,7 +179,7 @@ export async function runSync(deps: SyncRunnerDeps, options: { full?: boolean; t
|
|
|
163
179
|
const unassigned = new Map<string, number>();
|
|
164
180
|
let uploaded = 0;
|
|
165
181
|
let updated = 0;
|
|
166
|
-
let syncedThrough =
|
|
182
|
+
let syncedThrough = priorState?.syncedThrough;
|
|
167
183
|
let stopError: string | undefined;
|
|
168
184
|
// Whether at least one task was actually resolved (pushed or recorded
|
|
169
185
|
// as failed) this run — as opposed to the run stopping on its very
|
|
@@ -196,14 +212,17 @@ export async function runSync(deps: SyncRunnerDeps, options: { full?: boolean; t
|
|
|
196
212
|
progressed = true;
|
|
197
213
|
} else {
|
|
198
214
|
// "error": a network/timeout/5xx/auth failure. Stop here — nothing
|
|
199
|
-
// after this point in the
|
|
200
|
-
//
|
|
215
|
+
// after this point in the results is considered resolved. The
|
|
216
|
+
// results follow `plan.toSync`'s order (new work oldest-first,
|
|
217
|
+
// then corrections newest-first), and `syncedThrough` is a max
|
|
218
|
+
// over what WAS resolved, so stopping early can only leave it
|
|
219
|
+
// lower, never advance it past an unresolved task.
|
|
201
220
|
stopError = result.outcome.reason;
|
|
202
221
|
break;
|
|
203
222
|
}
|
|
204
223
|
}
|
|
205
224
|
|
|
206
|
-
const mergedHashes = { ...(
|
|
225
|
+
const mergedHashes = { ...(priorState?.hashes ?? {}), ...Object.fromEntries(newHashes) };
|
|
207
226
|
// G2: no longer window-bound — see `domain/sync-plan.ts#pruneHashes`'s
|
|
208
227
|
// doc comment. `tasks` here is every task `buildTasks` currently knows
|
|
209
228
|
// about (the full `readAll()`, not just this run's eligible/window
|
|
@@ -239,6 +258,12 @@ export async function runSync(deps: SyncRunnerDeps, options: { full?: boolean; t
|
|
|
239
258
|
}
|
|
240
259
|
}
|
|
241
260
|
|
|
261
|
+
/** `state` when it was written against `deps.target`; `undefined` (as if there were no state at all) when it belongs to another hub. */
|
|
262
|
+
function stateForTarget(deps: SyncRunnerDeps, state: ReturnType<SyncStateStore["read"]>): ReturnType<SyncStateStore["read"]> {
|
|
263
|
+
return state !== undefined && state.target === deps.target ? state : undefined;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Persist a failed run. `state` must already be gated by {@link stateForTarget}: what it carries is re-written under `deps.target`. */
|
|
242
267
|
function persistError(deps: SyncRunnerDeps, state: ReturnType<SyncStateStore["read"]>, message: string, logVersionAtRead: string | number | undefined): void {
|
|
243
268
|
deps.stateStore.write({
|
|
244
269
|
target: deps.target,
|
package/src/domain/sync-plan.ts
CHANGED
|
@@ -17,7 +17,7 @@ import type { TaskView } from "./task-view.ts";
|
|
|
17
17
|
export interface SyncState {
|
|
18
18
|
/** High-watermark ISO timestamp: everything with `endedAt` at or before `syncedThrough - window` is considered done. `undefined` before the first successful sync. */
|
|
19
19
|
syncedThrough?: string;
|
|
20
|
-
/** Content hash per task id (see {@link computeTaskContentHash}), pruned
|
|
20
|
+
/** Content hash per task id (see {@link computeTaskContentHash}), kept for as long as the task exists — never pruned by the revisit window (see {@link pruneHashes}). */
|
|
21
21
|
hashes: Record<string, string>;
|
|
22
22
|
/** The hub URL this state was synced against; a state written for a different URL is treated as absent (full sync). */
|
|
23
23
|
target: string;
|
|
@@ -36,7 +36,8 @@ export interface SyncState {
|
|
|
36
36
|
* `Clock`-based timestamp (ms) of the last real (non-short-circuited)
|
|
37
37
|
* automatic sync attempt, persisted so the automatic path's throttle
|
|
38
38
|
* (`KANKAKU_SYNC_MIN_INTERVAL_MINUTES`) holds across processes, not just
|
|
39
|
-
* within one.
|
|
39
|
+
* within one. Written by every run that reaches the sink, manual or
|
|
40
|
+
* automatic; only the automatic path READS it to throttle itself.
|
|
40
41
|
*/
|
|
41
42
|
lastRunAt?: number;
|
|
42
43
|
}
|
|
@@ -51,7 +52,7 @@ export interface SyncPlanOptions {
|
|
|
51
52
|
}
|
|
52
53
|
|
|
53
54
|
export interface SyncPlan {
|
|
54
|
-
/** Tasks whose content changed (or were never synced) and therefore need a request
|
|
55
|
+
/** Tasks whose content changed (or were never synced) and therefore need a request: new work in the window oldest-first, then corrections to rows the hub already holds, newest-first (see {@link planSync}). */
|
|
55
56
|
toSync: TaskView[];
|
|
56
57
|
/** How many eligible tasks were skipped because their stored hash already matched — no request needed for them. */
|
|
57
58
|
unchangedCount: number;
|
|
@@ -177,7 +178,11 @@ export function planSync(tasks: TaskView[], state: SyncState | undefined, option
|
|
|
177
178
|
outsideWindow = sorted.filter((task) => Date.parse(task.endedAt) <= cutoff);
|
|
178
179
|
}
|
|
179
180
|
|
|
180
|
-
|
|
181
|
+
// A state written for another hub contributes nothing, not even its
|
|
182
|
+
// hashes: the new hub has none of these rows, so a task that matched
|
|
183
|
+
// the OLD hub's hash must still be pushed. `isFullSync` alone only
|
|
184
|
+
// widened the window; without this gate every task looked "unchanged".
|
|
185
|
+
const hashes = state !== undefined && state.target === options.target ? state.hashes : {};
|
|
181
186
|
const changed = (task: TaskView): boolean => hashes[task.id] !== computeTaskContentHash(task);
|
|
182
187
|
// A row the hub ALREADY holds is corrected wherever it sits: the window
|
|
183
188
|
// bounds how far back NEW work is looked for, never whether a known row
|
|
@@ -195,10 +200,10 @@ export function planSync(tasks: TaskView[], state: SyncState | undefined, option
|
|
|
195
200
|
const knownAndChanged = isFullSync ? allCorrections : allCorrections.slice(0, MAX_CORRECTIONS_PER_RUN);
|
|
196
201
|
const correctionsDeferred = allCorrections.length - knownAndChanged.length;
|
|
197
202
|
const toSync = [...eligible.filter(changed), ...knownAndChanged];
|
|
198
|
-
// R3: cheap, pure visibility into a task
|
|
199
|
-
//
|
|
200
|
-
// comment.
|
|
201
|
-
//
|
|
203
|
+
// R3: cheap, pure visibility into a task this incremental run's window
|
|
204
|
+
// will not look at and that this hub has NEVER received (no stored hash)
|
|
205
|
+
// — see SyncPlan's doc comment. A known row that changed is a correction
|
|
206
|
+
// and is handled above, not reported here.
|
|
202
207
|
const staleOutsideWindow = outsideWindow.filter((task) => hashes[task.id] === undefined);
|
|
203
208
|
|
|
204
209
|
return { toSync, unchangedCount: eligible.length - eligible.filter(changed).length, isFullSync, staleOutsideWindow, correctionsDeferred };
|
|
@@ -218,8 +223,8 @@ export function planSync(tasks: TaskView[], state: SyncState | undefined, option
|
|
|
218
223
|
* for "unchanged but I forgot"), so that window-based pruning made every
|
|
219
224
|
* task older than the window look permanently changed, forever, the moment
|
|
220
225
|
* its hash was first pruned: `/kankaku sync status` would report a
|
|
221
|
-
* never-shrinking "changed outside the window" count that
|
|
222
|
-
*
|
|
226
|
+
* never-shrinking "changed outside the window" count that trained users
|
|
227
|
+
* to ignore it (the bug this rewrite fixes).
|
|
223
228
|
*
|
|
224
229
|
* Never pruning by window instead means `hashes` grows with the total
|
|
225
230
|
* number of distinct tasks a directory has ever synced, not with time — an
|