sliftutils 1.7.115 → 1.7.116
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.
- package/index.d.ts +5 -3
- package/package.json +1 -1
- package/storage/ArchivesDisk.ts +8 -8
- package/storage/IArchives.d.ts +1 -1
- package/storage/IArchives.ts +2 -2
- package/storage/TransactionFile.d.ts +1 -1
- package/storage/TransactionFile.ts +11 -11
- package/storage/archiveHelpers.d.ts +3 -1
- package/storage/archiveHelpers.ts +19 -14
- package/storage/dist/ArchivesDisk.ts.cache +10 -10
- package/storage/dist/IArchives.ts.cache +2 -2
- package/storage/dist/TransactionFile.ts.cache +13 -13
- package/storage/dist/archiveHelpers.ts.cache +19 -16
- package/storage/remoteStorage/ArchivesDelayed.ts +1 -1
- package/storage/remoteStorage/blobStore.ts +4 -4
- package/storage/remoteStorage/bucketDisk.ts +2 -2
- package/storage/remoteStorage/chainStartup.ts +1 -1
- package/storage/remoteStorage/createArchives.ts +1 -1
- package/storage/remoteStorage/dist/ArchivesDelayed.ts.cache +3 -3
- package/storage/remoteStorage/dist/blobStore.ts.cache +6 -6
- package/storage/remoteStorage/dist/bucketDisk.ts.cache +4 -4
- package/storage/remoteStorage/dist/chainStartup.ts.cache +3 -3
- package/storage/remoteStorage/dist/createArchives.ts.cache +3 -3
- package/storage/remoteStorage/dist/storeSync.ts.cache +5 -5
- package/storage/remoteStorage/storeSync.ts +3 -3
package/index.d.ts
CHANGED
|
@@ -2213,7 +2213,7 @@ declare module "sliftutils/storage/IArchives" {
|
|
|
2213
2213
|
routes?: [number, number][];
|
|
2214
2214
|
};
|
|
2215
2215
|
export type SetConfig = {
|
|
2216
|
-
/** The write time to stamp (see IArchives.set).
|
|
2216
|
+
/** The write time to stamp (see IArchives.set). ROUNDED to whole milliseconds by every implementation - the disk can't store fractional milliseconds anyway (utimes round-trips whole ms), so a fractional stamp could never be reproduced by propagation and would compare "newer" than its own copies forever. Rounded rather than floored because utimes goes through a seconds double and can read back a hair below the stamped millisecond (see ArchivesDisk.get2). */
|
|
2217
2217
|
lastModified?: number;
|
|
2218
2218
|
/** Makes the write acceptable on immutable targets: an existing path is simply kept (immutability wins - nothing is overwritten) instead of the write throwing. Requires lastModified. Synchronization MUST pass this on every push - a plain set throws on immutable targets, which would abort reconciliation whenever one source in a chain is immutable. */
|
|
2219
2219
|
forceSetImmutable?: boolean;
|
|
@@ -2773,7 +2773,7 @@ declare module "sliftutils/storage/TransactionFile" {
|
|
|
2773
2773
|
entries(): IterableIterator<[string, LogEntry<T>]>;
|
|
2774
2774
|
/** The tombstones, which is a much smaller walk than the values - so expiring them, or listing what was deleted since some time, costs what it should. */
|
|
2775
2775
|
deletedEntries(): IterableIterator<[string, LogTombstone<T>]>;
|
|
2776
|
-
/** Stores a value as of `time` (
|
|
2776
|
+
/** Stores a value as of `time` (rounded to whole milliseconds - see applySet). Returns false when something at least as new is already here, in which case nothing changed - an out-of-order write is not an error, it is just late. */
|
|
2777
2777
|
set(key: string, value: T, time: number): boolean;
|
|
2778
2778
|
/** Deletes as of `time`, keeping the tombstone. A key that had a live value keeps it in the tombstone as MARKED for deletion (readable and restorable until dropValue). Returns false when something at least as new is already here. */
|
|
2779
2779
|
delete(key: string, time: number): boolean;
|
|
@@ -2856,13 +2856,15 @@ declare module "sliftutils/storage/TransactionStorage" {
|
|
|
2856
2856
|
|
|
2857
2857
|
declare module "sliftutils/storage/archiveHelpers" {
|
|
2858
2858
|
import type { IArchives } from "./IArchives";
|
|
2859
|
-
/** Copies one file between two archives. The source's CURRENT size and write time always come from getInfo right here - callers never supply them, because a stale size turns into ranged reads of a file that has changed (failing forever), and a stale write time re-orders history. Small files go as a single get2+set; past LARGE_COPY_THRESHOLD the copy streams through setLargeFile in LARGE_COPY_CHUNK ranged reads, so the whole file is never in memory. Returns the copied file's info, and undefined for every way the copy did NOT land: the source doesn't have the file, the destination already
|
|
2859
|
+
/** Copies one file between two archives. The source's CURRENT size and write time always come from getInfo right here - callers never supply them, because a stale size turns into ranged reads of a file that has changed (failing forever), and a stale write time re-orders history. Small files go as a single get2+set; past LARGE_COPY_THRESHOLD the copy streams through setLargeFile in LARGE_COPY_CHUNK ranged reads, so the whole file is never in memory. Returns the copied file's info, and undefined for every way the copy did NOT land: the source doesn't have the file, and with preserveWriteTime the two guarded cases - the destination already held something NEWER (refused up front rather than roll it back), or the destination silently dropped the write (its own only-take-latest won a race we lost - caught by confirming with getInfo afterward). The refused/dropped cases are logged as errors; a caller that treats undefined as "missing at the source" must getInfo the destination to learn the actual latest value. */
|
|
2860
2860
|
export declare function copyArchiveFile(config: {
|
|
2861
2861
|
from: IArchives;
|
|
2862
2862
|
to: IArchives;
|
|
2863
2863
|
path: string;
|
|
2864
2864
|
/** The path at the destination - defaults to path (the common case: the same key moving between two archives). */
|
|
2865
2865
|
toPath?: string;
|
|
2866
|
+
/** Stamps the destination with the SOURCE's write time instead of now, and turns on the ordering guards around it (the newer-destination refusal up front, and the getInfo confirm after). ONLY for synchronization between replicas of the same key, where the higher write time must win and ordering must survive propagation - never for a user-triggered copy: a plain copy is a NEW write, and the source's old stamp would make it LOSE to any newer write or tombstone at the destination, silently (move a file back to a folder it was deleted from and the copy is dropped, then the caller deletes the source, and the file is gone entirely). */
|
|
2867
|
+
preserveWriteTime?: boolean;
|
|
2866
2868
|
forceSetImmutable?: boolean;
|
|
2867
2869
|
noChecks?: boolean;
|
|
2868
2870
|
internal?: boolean;
|
package/package.json
CHANGED
package/storage/ArchivesDisk.ts
CHANGED
|
@@ -164,8 +164,8 @@ export class ArchivesDisk implements IArchives {
|
|
|
164
164
|
await this.handles.run(filePath, async () => {
|
|
165
165
|
if (lastModified) {
|
|
166
166
|
let existing = await statOrUndefined(filePath);
|
|
167
|
-
// An older write never overwrites a newer one (see IArchives.set). Both sides
|
|
168
|
-
if (existing && Math.
|
|
167
|
+
// An older write never overwrites a newer one (see IArchives.set). Both sides rounded: sub-millisecond mtime digits are storage artifacts, not ordering (see get2)
|
|
168
|
+
if (existing && Math.round(lastModified) < Math.round(existing.mtimeMs)) return;
|
|
169
169
|
}
|
|
170
170
|
await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
|
|
171
171
|
let handle = await this.handles.getHandle(filePath, fs.constants.O_RDWR | fs.constants.O_CREAT);
|
|
@@ -238,14 +238,14 @@ export class ArchivesDisk implements IArchives {
|
|
|
238
238
|
if (!size && !config?.includeTombstones) return undefined;
|
|
239
239
|
let start = range && Math.min(range.start, size) || 0;
|
|
240
240
|
let end = range && Math.min(range.end, size) || size;
|
|
241
|
-
// mtimeMs is
|
|
242
|
-
if (end <= start) return { data: Buffer.alloc(0), writeTime: Math.
|
|
241
|
+
// mtimeMs is ROUNDED everywhere this disk reports a time: it carries fractional milliseconds (kernel-stamped files have nanosecond mtimes), but utimes round-trips only whole ones - so a fractional time handed to the rest of the system could never be reproduced by propagation, and every copy of the file would compare "older" than the original forever. Round rather than floor, because utimes goes through a seconds DOUBLE and can land a hair BELOW the stamped millisecond - flooring turned that representation error into a full lost millisecond, un-reproducing our own stamps.
|
|
242
|
+
if (end <= start) return { data: Buffer.alloc(0), writeTime: Math.round(stats.mtimeMs), size };
|
|
243
243
|
let buffer = Buffer.alloc(end - start);
|
|
244
244
|
let { bytesRead } = await handle.read(buffer, 0, buffer.length, start);
|
|
245
245
|
if (bytesRead !== buffer.length) {
|
|
246
246
|
throw new Error(`Expected ${buffer.length} bytes at ${filePath}:${start}, read ${bytesRead}`);
|
|
247
247
|
}
|
|
248
|
-
return { data: buffer, writeTime: Math.
|
|
248
|
+
return { data: buffer, writeTime: Math.round(stats.mtimeMs), size };
|
|
249
249
|
});
|
|
250
250
|
}
|
|
251
251
|
|
|
@@ -256,7 +256,7 @@ export class ArchivesDisk implements IArchives {
|
|
|
256
256
|
let stats = await statOrUndefined(filePath);
|
|
257
257
|
if (!stats || !stats.isFile()) return undefined;
|
|
258
258
|
if (!stats.size && !config?.includeTombstones) return undefined;
|
|
259
|
-
return { writeTime: Math.
|
|
259
|
+
return { writeTime: Math.round(stats.mtimeMs), size: stats.size };
|
|
260
260
|
});
|
|
261
261
|
}
|
|
262
262
|
|
|
@@ -297,7 +297,7 @@ export class ArchivesDisk implements IArchives {
|
|
|
297
297
|
let stats = await statOrUndefined(path.join(this.filesDir, relPath));
|
|
298
298
|
// Deleted while we were walking
|
|
299
299
|
if (!stats) continue;
|
|
300
|
-
infos.set(relPath, { path: relPath, createTime: Math.
|
|
300
|
+
infos.set(relPath, { path: relPath, createTime: Math.round(stats.mtimeMs), size: stats.size });
|
|
301
301
|
}
|
|
302
302
|
}
|
|
303
303
|
|
|
@@ -355,7 +355,7 @@ export class ArchivesDisk implements IArchives {
|
|
|
355
355
|
if (lastModified) {
|
|
356
356
|
// An older write never overwrites a newer one (see IArchives.set) - re-checked HERE, not just when the upload started, because a large upload streams for minutes and that window is exactly when a newer write lands
|
|
357
357
|
let existing = await statOrUndefined(filePath);
|
|
358
|
-
if (existing && Math.
|
|
358
|
+
if (existing && Math.round(lastModified) < Math.round(existing.mtimeMs)) {
|
|
359
359
|
await fs.promises.rm(tmpPath, { force: true });
|
|
360
360
|
return;
|
|
361
361
|
}
|
package/storage/IArchives.d.ts
CHANGED
|
@@ -118,7 +118,7 @@ export type ChangesAfterConfig = {
|
|
|
118
118
|
routes?: [number, number][];
|
|
119
119
|
};
|
|
120
120
|
export type SetConfig = {
|
|
121
|
-
/** The write time to stamp (see IArchives.set).
|
|
121
|
+
/** The write time to stamp (see IArchives.set). ROUNDED to whole milliseconds by every implementation - the disk can't store fractional milliseconds anyway (utimes round-trips whole ms), so a fractional stamp could never be reproduced by propagation and would compare "newer" than its own copies forever. Rounded rather than floored because utimes goes through a seconds double and can read back a hair below the stamped millisecond (see ArchivesDisk.get2). */
|
|
122
122
|
lastModified?: number;
|
|
123
123
|
/** Makes the write acceptable on immutable targets: an existing path is simply kept (immutability wins - nothing is overwritten) instead of the write throwing. Requires lastModified. Synchronization MUST pass this on every push - a plain set throws on immutable targets, which would abort reconciliation whenever one source in a chain is immutable. */
|
|
124
124
|
forceSetImmutable?: boolean;
|
package/storage/IArchives.ts
CHANGED
|
@@ -158,7 +158,7 @@ export type ChangesAfterConfig = {
|
|
|
158
158
|
};
|
|
159
159
|
|
|
160
160
|
export type SetConfig = {
|
|
161
|
-
/** The write time to stamp (see IArchives.set).
|
|
161
|
+
/** The write time to stamp (see IArchives.set). ROUNDED to whole milliseconds by every implementation - the disk can't store fractional milliseconds anyway (utimes round-trips whole ms), so a fractional stamp could never be reproduced by propagation and would compare "newer" than its own copies forever. Rounded rather than floored because utimes goes through a seconds double and can read back a hair below the stamped millisecond (see ArchivesDisk.get2). */
|
|
162
162
|
lastModified?: number;
|
|
163
163
|
/** Makes the write acceptable on immutable targets: an existing path is simply kept (immutability wins - nothing is overwritten) instead of the write throwing. Requires lastModified. Synchronization MUST pass this on every push - a plain set throws on immutable targets, which would abort reconciliation whenever one source in a chain is immutable. */
|
|
164
164
|
forceSetImmutable?: boolean;
|
|
@@ -182,7 +182,7 @@ export type SetLargeFileConfig = SetConfig & {
|
|
|
182
182
|
restartStream?(): Promise<void> | void;
|
|
183
183
|
};
|
|
184
184
|
|
|
185
|
-
// createTime is a misnomer kept for compatibility — it is really the LAST-WRITE time, same as getInfo's writeTime. Neither Backblaze nor our remote storage tracks a distinct creation date: each write stamps a fresh timestamp on the current version, so both fields are just "when the bytes served by get() were most recently written". Always WHOLE milliseconds: write times are
|
|
185
|
+
// createTime is a misnomer kept for compatibility — it is really the LAST-WRITE time, same as getInfo's writeTime. Neither Backblaze nor our remote storage tracks a distinct creation date: each write stamps a fresh timestamp on the current version, so both fields are just "when the bytes served by get() were most recently written". Always WHOLE milliseconds: write times are rounded at every producer (see SetConfig.lastModified).
|
|
186
186
|
export type ArchiveFileInfo = { path: string; createTime: number; size: number };
|
|
187
187
|
|
|
188
188
|
// An in-progress background synchronization task (see ArchivesConfig.syncing)
|
|
@@ -39,7 +39,7 @@ export declare class TransactionFile<T> {
|
|
|
39
39
|
entries(): IterableIterator<[string, LogEntry<T>]>;
|
|
40
40
|
/** The tombstones, which is a much smaller walk than the values - so expiring them, or listing what was deleted since some time, costs what it should. */
|
|
41
41
|
deletedEntries(): IterableIterator<[string, LogTombstone<T>]>;
|
|
42
|
-
/** Stores a value as of `time` (
|
|
42
|
+
/** Stores a value as of `time` (rounded to whole milliseconds - see applySet). Returns false when something at least as new is already here, in which case nothing changed - an out-of-order write is not an error, it is just late. */
|
|
43
43
|
set(key: string, value: T, time: number): boolean;
|
|
44
44
|
/** Deletes as of `time`, keeping the tombstone. A key that had a live value keeps it in the tombstone as MARKED for deletion (readable and restorable until dropValue). Returns false when something at least as new is already here. */
|
|
45
45
|
delete(key: string, time: number): boolean;
|
|
@@ -119,9 +119,9 @@ export class TransactionFile<T> {
|
|
|
119
119
|
return this.deleted.entries();
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
-
/** Stores a value as of `time` (
|
|
122
|
+
/** Stores a value as of `time` (rounded to whole milliseconds - see applySet). Returns false when something at least as new is already here, in which case nothing changed - an out-of-order write is not an error, it is just late. */
|
|
123
123
|
public set(key: string, value: T, time: number): boolean {
|
|
124
|
-
time = Math.
|
|
124
|
+
time = Math.round(time);
|
|
125
125
|
if (!this.applySet(key, value, time, Date.now())) return false;
|
|
126
126
|
this.append({ k: key, t: time, v: value });
|
|
127
127
|
return true;
|
|
@@ -129,7 +129,7 @@ export class TransactionFile<T> {
|
|
|
129
129
|
|
|
130
130
|
/** Deletes as of `time`, keeping the tombstone. A key that had a live value keeps it in the tombstone as MARKED for deletion (readable and restorable until dropValue). Returns false when something at least as new is already here. */
|
|
131
131
|
public delete(key: string, time: number): boolean {
|
|
132
|
-
time = Math.
|
|
132
|
+
time = Math.round(time);
|
|
133
133
|
let live = this.values.get(key);
|
|
134
134
|
if (!this.applyDelete(key, time, Date.now(), live?.value, live?.time)) return false;
|
|
135
135
|
if (live) {
|
|
@@ -142,7 +142,7 @@ export class TransactionFile<T> {
|
|
|
142
142
|
|
|
143
143
|
/** Undoes a marked deletion: the kept value becomes live again, as of `time` (a fresh time, so the restore outranks the deletion everywhere it propagated). Returns false when there is no marked value to restore, or something at least as new is already here. */
|
|
144
144
|
public unmark(key: string, time: number): boolean {
|
|
145
|
-
time = Math.
|
|
145
|
+
time = Math.round(time);
|
|
146
146
|
let tombstone = this.deleted.get(key);
|
|
147
147
|
if (!tombstone || tombstone.value === undefined) return false;
|
|
148
148
|
if (!this.applySet(key, tombstone.value, time, Date.now())) return false;
|
|
@@ -163,13 +163,13 @@ export class TransactionFile<T> {
|
|
|
163
163
|
let had = this.values.delete(key);
|
|
164
164
|
had = this.deleted.delete(key) || had;
|
|
165
165
|
if (!had) return;
|
|
166
|
-
this.append({ k: key, t: Math.
|
|
166
|
+
this.append({ k: key, t: Math.round(Date.now()), p: 1 });
|
|
167
167
|
}
|
|
168
168
|
|
|
169
|
-
// Times are
|
|
169
|
+
// Times are ROUNDED to whole milliseconds on every path into the maps (load replays through here too, so fractional times persisted by older code heal on startup). Disk mtimes carry fractional milliseconds but utimes round-trips only whole ones, so a fractional time can never be reproduced by propagation - every copy of the value would compare "older" than the original forever, and the same write would be re-pushed and re-copied every round. Round rather than floor, to match ArchivesDisk (see its get2): utimes goes through a seconds double and can land a hair below the stamped millisecond.
|
|
170
170
|
private applySet(key: string, value: T, time: number, changedAt: number): boolean {
|
|
171
|
-
time = Math.
|
|
172
|
-
changedAt = Math.
|
|
171
|
+
time = Math.round(time);
|
|
172
|
+
changedAt = Math.round(changedAt);
|
|
173
173
|
if (time < this.timeOf(key)) return false;
|
|
174
174
|
this.deleted.delete(key);
|
|
175
175
|
this.values.set(key, { value, time, changedAt });
|
|
@@ -177,10 +177,10 @@ export class TransactionFile<T> {
|
|
|
177
177
|
}
|
|
178
178
|
|
|
179
179
|
private applyDelete(key: string, time: number, changedAt: number, value?: T, valueTime?: number): boolean {
|
|
180
|
-
time = Math.
|
|
181
|
-
changedAt = Math.
|
|
180
|
+
time = Math.round(time);
|
|
181
|
+
changedAt = Math.round(changedAt);
|
|
182
182
|
if (valueTime !== undefined) {
|
|
183
|
-
valueTime = Math.
|
|
183
|
+
valueTime = Math.round(valueTime);
|
|
184
184
|
}
|
|
185
185
|
if (time < this.timeOf(key)) return false;
|
|
186
186
|
this.values.delete(key);
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import type { IArchives } from "./IArchives";
|
|
2
|
-
/** Copies one file between two archives. The source's CURRENT size and write time always come from getInfo right here - callers never supply them, because a stale size turns into ranged reads of a file that has changed (failing forever), and a stale write time re-orders history. Small files go as a single get2+set; past LARGE_COPY_THRESHOLD the copy streams through setLargeFile in LARGE_COPY_CHUNK ranged reads, so the whole file is never in memory. Returns the copied file's info, and undefined for every way the copy did NOT land: the source doesn't have the file, the destination already
|
|
2
|
+
/** Copies one file between two archives. The source's CURRENT size and write time always come from getInfo right here - callers never supply them, because a stale size turns into ranged reads of a file that has changed (failing forever), and a stale write time re-orders history. Small files go as a single get2+set; past LARGE_COPY_THRESHOLD the copy streams through setLargeFile in LARGE_COPY_CHUNK ranged reads, so the whole file is never in memory. Returns the copied file's info, and undefined for every way the copy did NOT land: the source doesn't have the file, and with preserveWriteTime the two guarded cases - the destination already held something NEWER (refused up front rather than roll it back), or the destination silently dropped the write (its own only-take-latest won a race we lost - caught by confirming with getInfo afterward). The refused/dropped cases are logged as errors; a caller that treats undefined as "missing at the source" must getInfo the destination to learn the actual latest value. */
|
|
3
3
|
export declare function copyArchiveFile(config: {
|
|
4
4
|
from: IArchives;
|
|
5
5
|
to: IArchives;
|
|
6
6
|
path: string;
|
|
7
7
|
/** The path at the destination - defaults to path (the common case: the same key moving between two archives). */
|
|
8
8
|
toPath?: string;
|
|
9
|
+
/** Stamps the destination with the SOURCE's write time instead of now, and turns on the ordering guards around it (the newer-destination refusal up front, and the getInfo confirm after). ONLY for synchronization between replicas of the same key, where the higher write time must win and ordering must survive propagation - never for a user-triggered copy: a plain copy is a NEW write, and the source's old stamp would make it LOSE to any newer write or tombstone at the destination, silently (move a file back to a folder it was deleted from and the copy is dropped, then the caller deletes the source, and the file is gone entirely). */
|
|
10
|
+
preserveWriteTime?: boolean;
|
|
9
11
|
forceSetImmutable?: boolean;
|
|
10
12
|
noChecks?: boolean;
|
|
11
13
|
internal?: boolean;
|
|
@@ -8,13 +8,15 @@ import { logStorageError } from "./remoteStorage/storageLogs";
|
|
|
8
8
|
const LARGE_COPY_THRESHOLD = 64 * 1024 * 1024;
|
|
9
9
|
const LARGE_COPY_CHUNK = 32 * 1024 * 1024;
|
|
10
10
|
|
|
11
|
-
/** Copies one file between two archives. The source's CURRENT size and write time always come from getInfo right here - callers never supply them, because a stale size turns into ranged reads of a file that has changed (failing forever), and a stale write time re-orders history. Small files go as a single get2+set; past LARGE_COPY_THRESHOLD the copy streams through setLargeFile in LARGE_COPY_CHUNK ranged reads, so the whole file is never in memory. Returns the copied file's info, and undefined for every way the copy did NOT land: the source doesn't have the file, the destination already
|
|
11
|
+
/** Copies one file between two archives. The source's CURRENT size and write time always come from getInfo right here - callers never supply them, because a stale size turns into ranged reads of a file that has changed (failing forever), and a stale write time re-orders history. Small files go as a single get2+set; past LARGE_COPY_THRESHOLD the copy streams through setLargeFile in LARGE_COPY_CHUNK ranged reads, so the whole file is never in memory. Returns the copied file's info, and undefined for every way the copy did NOT land: the source doesn't have the file, and with preserveWriteTime the two guarded cases - the destination already held something NEWER (refused up front rather than roll it back), or the destination silently dropped the write (its own only-take-latest won a race we lost - caught by confirming with getInfo afterward). The refused/dropped cases are logged as errors; a caller that treats undefined as "missing at the source" must getInfo the destination to learn the actual latest value. */
|
|
12
12
|
export async function copyArchiveFile(config: {
|
|
13
13
|
from: IArchives;
|
|
14
14
|
to: IArchives;
|
|
15
15
|
path: string;
|
|
16
16
|
/** The path at the destination - defaults to path (the common case: the same key moving between two archives). */
|
|
17
17
|
toPath?: string;
|
|
18
|
+
/** Stamps the destination with the SOURCE's write time instead of now, and turns on the ordering guards around it (the newer-destination refusal up front, and the getInfo confirm after). ONLY for synchronization between replicas of the same key, where the higher write time must win and ordering must survive propagation - never for a user-triggered copy: a plain copy is a NEW write, and the source's old stamp would make it LOSE to any newer write or tombstone at the destination, silently (move a file back to a folder it was deleted from and the copy is dropped, then the caller deletes the source, and the file is gone entirely). */
|
|
19
|
+
preserveWriteTime?: boolean;
|
|
18
20
|
forceSetImmutable?: boolean;
|
|
19
21
|
noChecks?: boolean;
|
|
20
22
|
internal?: boolean;
|
|
@@ -25,14 +27,15 @@ export async function copyArchiveFile(config: {
|
|
|
25
27
|
let info = await from.getInfo(path, { noFallbacks: config.noFallbacks });
|
|
26
28
|
if (!info) return undefined;
|
|
27
29
|
let size = info.size;
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
30
|
+
let writeTime = Math.round(config.preserveWriteTime && info.writeTime || Date.now());
|
|
31
|
+
if (config.preserveWriteTime) {
|
|
32
|
+
// A destination that already holds a NEWER file must not be overwritten with our older one - and the destination's own only-take-latest would drop the write SILENTLY, leaving the caller believing the copy happened. Refusing here, loudly, is what turns "the remote has something we missed" from a masked bug into a log line the caller can act on. (A fresh-stamped copy needs no such guard - nothing at the destination can be newer than now - so plain copies skip the round trip.)
|
|
33
|
+
let destInfo = await to.getInfo(toPath, { noFallbacks: config.noFallbacks });
|
|
34
|
+
// Compared at whole-millisecond precision (ROUNDED, matching ArchivesDisk - see its get2), here and at the confirm below: disk mtimes carry fractional milliseconds, but utimes round-trips only whole ones, so sub-millisecond differences are storage artifacts of the SAME time, not ordering
|
|
35
|
+
if (destInfo && Math.round(destInfo.writeTime) > Math.round(writeTime)) {
|
|
36
|
+
logStorageError(`Copy refused - a newer file exists at the destination. Refusing to copy ${JSON.stringify(path)} from ${from.getDebugName()} to ${to.getDebugName()}${toPath !== path && ` (as ${JSON.stringify(toPath)})` || ""}: ours ${size} bytes at ${formatDateTimeDetailed(writeTime)}, theirs ${destInfo.size} bytes at ${formatDateTimeDetailed(destInfo.writeTime)} - copying would roll it back`);
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
36
39
|
}
|
|
37
40
|
let copiedSize: number;
|
|
38
41
|
if (size <= LARGE_COPY_THRESHOLD) {
|
|
@@ -67,11 +70,13 @@ export async function copyArchiveFile(config: {
|
|
|
67
70
|
});
|
|
68
71
|
copiedSize = totalSize;
|
|
69
72
|
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
73
|
+
if (config.preserveWriteTime) {
|
|
74
|
+
// Every backend drops a superseded write SILENTLY (its only-take-latest is the last line of defense against races the up-front check can't see), so a returned set is not proof the copy landed - only the destination reporting the file at OUR time or newer is. Newer also counts as landed: the destination is at least as new as what we pushed (and b2 always stamps its own, later, upload time). Only for preserved stamps: a fresh stamp cannot lose the race, and moveArchiveFile does its own confirm before deleting anything.
|
|
75
|
+
let confirmed = await to.getInfo(toPath, { noFallbacks: config.noFallbacks });
|
|
76
|
+
if (!confirmed || Math.round(confirmed.writeTime) < Math.round(writeTime)) {
|
|
77
|
+
logStorageError(`Copy was silently dropped by the destination. Copy of ${JSON.stringify(path)} from ${from.getDebugName()} to ${to.getDebugName()}${toPath !== path && ` (as ${JSON.stringify(toPath)})` || ""}: our copy was ${copiedSize} bytes at ${formatDateTimeDetailed(writeTime)}, but the destination reports ${confirmed && `${confirmed.size} bytes at ${formatDateTimeDetailed(confirmed.writeTime)}` || "nothing"} (a newer write or deletion won the race)`);
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
75
80
|
}
|
|
76
81
|
return { writeTime, size: copiedSize };
|
|
77
82
|
}
|