Bug fixes
This commit is contained in:
parent
bbec7f14dd
commit
df37e6c236
15 changed files with 632 additions and 157 deletions
|
|
@ -71,6 +71,10 @@ export class Syncer {
|
|||
if (isConnected) {
|
||||
// The JS WebSocket API doesn't support setting headers, so we have to send the token as a message
|
||||
this.sendHandshakeMessage();
|
||||
} else {
|
||||
// Clear so that the next reconnect re-runs scheduleSyncForOfflineChanges
|
||||
// instead of returning the stale resolved promise.
|
||||
this.runningScheduleSyncForOfflineChanges = undefined;
|
||||
}
|
||||
});
|
||||
this.webSocketManager.onRemoteVaultUpdateReceived.add(
|
||||
|
|
@ -267,7 +271,7 @@ export class Syncer {
|
|||
|
||||
public async waitUntilFinished(): Promise<void> {
|
||||
await this.runningScheduleSyncForOfflineChanges;
|
||||
await this.syncQueue.onIdle(); // Wait for queue to be empty and running tasks to finish
|
||||
await this.syncQueue.onIdle();
|
||||
}
|
||||
|
||||
public async syncRemotelyUpdatedFile(
|
||||
|
|
@ -330,19 +334,19 @@ export class Syncer {
|
|||
remoteVersion.documentId
|
||||
);
|
||||
await this.enqueueSyncOperation(
|
||||
async () =>
|
||||
this.unrestrictedSyncer.unrestrictedSyncRemotelyUpdatedFile(
|
||||
async () => {
|
||||
await this.unrestrictedSyncer.unrestrictedSyncRemotelyUpdatedFile(
|
||||
remoteVersion,
|
||||
document
|
||||
),
|
||||
);
|
||||
this.database.addSeenUpdateId(remoteVersion.vaultUpdateId);
|
||||
},
|
||||
[
|
||||
document?.relativePath,
|
||||
remoteVersion.relativePath,
|
||||
remoteVersion.documentId
|
||||
]
|
||||
);
|
||||
|
||||
this.database.addSeenUpdateId(remoteVersion.vaultUpdateId);
|
||||
}
|
||||
|
||||
private async internalScheduleSyncForOfflineChanges(): Promise<void> {
|
||||
|
|
@ -371,9 +375,12 @@ export class Syncer {
|
|||
}
|
||||
const instructions: (Instruction | undefined)[] = await awaitAll(
|
||||
allLocalFiles.map(async (relativePath) => {
|
||||
if (
|
||||
const existingMetadata =
|
||||
this.database.getLatestDocumentByRelativePath(relativePath)
|
||||
?.metadata !== undefined
|
||||
?.metadata;
|
||||
if (
|
||||
existingMetadata !== undefined &&
|
||||
existingMetadata.parentVersionId > 0
|
||||
) {
|
||||
this.logger.debug(
|
||||
`Document ${relativePath} might have been updated locally, scheduling sync to validate and update it`
|
||||
|
|
@ -382,12 +389,27 @@ export class Syncer {
|
|||
return { type: "update", relativePath } as Instruction;
|
||||
}
|
||||
|
||||
// Perhaps the file has been moved; let's check by looking at the deleted files
|
||||
const contentHash = await this.syncQueue.add(async () => {
|
||||
// Perhaps the file has been moved; let's check by looking at the deleted files.
|
||||
// Skip reading oversized files into memory for hash computation —
|
||||
// they can't participate in move detection and will be scheduled as creates.
|
||||
const hashResult = await this.syncQueue.add(async () => {
|
||||
try {
|
||||
const sizeInBytes =
|
||||
await this.operations.getFileSize(relativePath);
|
||||
const sizeInMB = Math.ceil(
|
||||
sizeInBytes / 1024 / 1024
|
||||
);
|
||||
const { maxFileSizeMB } =
|
||||
this.settings.getSettings();
|
||||
if (sizeInMB > maxFileSizeMB) {
|
||||
// File exceeds size limit — skip hash-based move
|
||||
// detection and schedule as a create instead
|
||||
return { skippedOversized: true } as const;
|
||||
}
|
||||
|
||||
const contentBytes =
|
||||
await this.operations.read(relativePath); // this can throw FileNotFoundError
|
||||
return hash(contentBytes);
|
||||
return { hash: hash(contentBytes) } as const;
|
||||
} catch (e) {
|
||||
if (
|
||||
e instanceof Error &&
|
||||
|
|
@ -399,15 +421,21 @@ export class Syncer {
|
|||
}
|
||||
});
|
||||
|
||||
if (contentHash == undefined) {
|
||||
if (hashResult == undefined) {
|
||||
// The file was deleted before we had a chance to read it, no need to sync it here
|
||||
return;
|
||||
}
|
||||
|
||||
const originalFile = findMatchingFile(
|
||||
contentHash,
|
||||
locallyPossiblyDeletedFiles
|
||||
);
|
||||
const contentHash =
|
||||
"hash" in hashResult ? hashResult.hash : undefined;
|
||||
|
||||
const originalFile =
|
||||
contentHash != undefined
|
||||
? findMatchingFile(
|
||||
contentHash,
|
||||
locallyPossiblyDeletedFiles
|
||||
)
|
||||
: undefined;
|
||||
if (originalFile !== undefined) {
|
||||
// `originalFile` hasn't been deleted but it got moved instead
|
||||
/* eslint-disable no-restricted-syntax -- Comparing by property, not direct equality */
|
||||
|
|
@ -505,12 +533,25 @@ export class Syncer {
|
|||
//
|
||||
// The result type needs special handling since syncQueue.add() can
|
||||
// return undefined when the queue is paused/cleared.
|
||||
const result = await this.syncQueue.add(async () =>
|
||||
this.updatedDocumentsByPathAndKeysLocks.withLock(
|
||||
filteredKeys,
|
||||
operation
|
||||
)
|
||||
);
|
||||
const result = await this.syncQueue.add(async () => {
|
||||
try {
|
||||
return await this.updatedDocumentsByPathAndKeysLocks.withLock(
|
||||
filteredKeys,
|
||||
operation
|
||||
);
|
||||
} catch (e) {
|
||||
// Catch all errors to prevent unhandled promise rejections.
|
||||
// SyncResetError: lock waiter rejected during reset (expected).
|
||||
// Other errors: logged by executeSync's history entry, will
|
||||
// be retried on the next scheduleSyncForOfflineChanges cycle.
|
||||
if (!(e instanceof SyncResetError)) {
|
||||
this.logger.info(
|
||||
`Sync operation failed, will retry on next cycle: ${e}`
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
return result as T;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -83,6 +83,15 @@ export class UnrestrictedSyncer {
|
|||
doc.idempotencyKey !== undefined &&
|
||||
resolved.has(doc.idempotencyKey)
|
||||
) {
|
||||
// Check if document was removed by a concurrent operation
|
||||
// (e.g., a delete) between the snapshot and now
|
||||
if (!this.database.containsDocument(doc)) {
|
||||
this.logger.info(
|
||||
`Pending doc at ${doc.relativePath} was removed during key resolution, skipping`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const documentId = resolved.get(doc.idempotencyKey)!; // eslint-disable-line @typescript-eslint/no-non-null-assertion
|
||||
|
||||
// Skip if this documentId is already assigned to another document
|
||||
|
|
@ -160,7 +169,14 @@ export class UnrestrictedSyncer {
|
|||
|
||||
let response: DocumentVersion | DocumentUpdateResponse | undefined =
|
||||
undefined;
|
||||
if (document.metadata === undefined) {
|
||||
if (
|
||||
document.metadata === undefined ||
|
||||
document.metadata.parentVersionId === 0
|
||||
) {
|
||||
// parentVersionId === 0 occurs when resolveIdempotencyKeys
|
||||
// assigned a documentId but hasn't synced yet. Treat as a
|
||||
// create — the server will recognise the idempotency key
|
||||
// and return the existing document.
|
||||
response = await this.syncService.create({
|
||||
relativePath: originalRelativePath,
|
||||
contentBytes,
|
||||
|
|
@ -188,16 +204,22 @@ export class UnrestrictedSyncer {
|
|||
(await this.serverConfig.getConfig())
|
||||
.mergeableFileExtensions
|
||||
);
|
||||
// Snapshot parentVersionId atomically with the cache
|
||||
// lookup. document.metadata is a mutable shared
|
||||
// reference — a concurrent operation could update
|
||||
// parentVersionId between the cache lookup and the
|
||||
// putText call, causing a diff/version mismatch.
|
||||
const parentVersionIdForUpdate =
|
||||
document.metadata.parentVersionId;
|
||||
const cachedVersion = this.contentCache.get(
|
||||
document.metadata.parentVersionId
|
||||
parentVersionIdForUpdate
|
||||
);
|
||||
|
||||
response =
|
||||
isText && cachedVersion !== undefined
|
||||
? await this.syncService.putText({
|
||||
documentId: document.metadata.documentId,
|
||||
parentVersionId:
|
||||
document.metadata.parentVersionId,
|
||||
parentVersionId: parentVersionIdForUpdate,
|
||||
relativePath: document.relativePath,
|
||||
content: diff(
|
||||
new TextDecoder().decode(cachedVersion),
|
||||
|
|
@ -206,8 +228,7 @@ export class UnrestrictedSyncer {
|
|||
})
|
||||
: await this.syncService.putBinary({
|
||||
documentId: document.metadata.documentId,
|
||||
parentVersionId:
|
||||
document.metadata.parentVersionId,
|
||||
parentVersionId: parentVersionIdForUpdate,
|
||||
relativePath: document.relativePath,
|
||||
contentBytes
|
||||
});
|
||||
|
|
@ -522,6 +543,31 @@ export class UnrestrictedSyncer {
|
|||
this.logger.info(
|
||||
`Document ${document.relativePath} has been deleted before we could finish updating it`
|
||||
);
|
||||
// Assign metadata so the pending delete can inform the server
|
||||
if (document.metadata === undefined) {
|
||||
const existingWithSameId =
|
||||
this.database.getDocumentByDocumentId(
|
||||
response.documentId
|
||||
);
|
||||
if (
|
||||
existingWithSameId !== undefined &&
|
||||
existingWithSameId !== document
|
||||
) {
|
||||
// Another doc already has this documentId — the server
|
||||
// knows about it. Just remove this stale pending doc.
|
||||
this.database.removeDocument(document);
|
||||
} else {
|
||||
this.database.updateDocumentMetadata(
|
||||
{
|
||||
documentId: response.documentId,
|
||||
parentVersionId: response.vaultUpdateId,
|
||||
hash: contentHash,
|
||||
remoteRelativePath: response.relativePath
|
||||
},
|
||||
document
|
||||
);
|
||||
}
|
||||
}
|
||||
this.database.addSeenUpdateId(response.vaultUpdateId);
|
||||
return;
|
||||
}
|
||||
|
|
@ -615,18 +661,9 @@ export class UnrestrictedSyncer {
|
|||
|
||||
if (!("type" in response) || response.type === "MergingUpdate") {
|
||||
const responseBytes = base64ToBytes(response.contentBase64);
|
||||
contentHash = hash(responseBytes);
|
||||
|
||||
this.database.updateDocumentMetadata(
|
||||
{
|
||||
documentId: response.documentId,
|
||||
parentVersionId: response.vaultUpdateId,
|
||||
hash: contentHash,
|
||||
remoteRelativePath: response.relativePath
|
||||
},
|
||||
document
|
||||
);
|
||||
|
||||
// Write file BEFORE updating metadata so that if the write fails,
|
||||
// metadata doesn't point to a version whose content was never written.
|
||||
await this.operations.write(
|
||||
actualPath,
|
||||
originalContentBytes,
|
||||
|
|
@ -642,27 +679,90 @@ export class UnrestrictedSyncer {
|
|||
);
|
||||
}
|
||||
|
||||
// Re-read and re-hash after write because the 3-way merge in
|
||||
// operations.write() may produce content different from responseBytes.
|
||||
const actualContent = await this.operations.read(actualPath);
|
||||
const actualHash = hash(actualContent);
|
||||
|
||||
// The document may have been removed by a concurrent operation
|
||||
// (e.g., a delete) during the awaited file write/read above.
|
||||
// The file is safely on disk; recovery will re-detect it.
|
||||
if (!this.database.containsDocument(document)) {
|
||||
this.logger.info(
|
||||
`Document ${document.relativePath} was removed during sync, skipping metadata update`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.database.updateDocumentMetadata(
|
||||
{
|
||||
documentId: response.documentId,
|
||||
parentVersionId: response.vaultUpdateId,
|
||||
hash: actualHash,
|
||||
remoteRelativePath: response.relativePath
|
||||
},
|
||||
document
|
||||
);
|
||||
|
||||
// Cache the SERVER's content (responseBytes), not the local
|
||||
// content (actualContent). The cache is used to compute diffs
|
||||
// for subsequent updates: diff(cached, newFileContent). The
|
||||
// server applies this diff against its content at
|
||||
// parentVersionId, which is responseBytes. Using actualContent
|
||||
// would produce diffs that don't match the server's state.
|
||||
await this.updateCache(
|
||||
response.vaultUpdateId,
|
||||
responseBytes,
|
||||
actualPath
|
||||
);
|
||||
} else {
|
||||
this.database.updateDocumentMetadata(
|
||||
{
|
||||
documentId: response.documentId,
|
||||
parentVersionId: response.vaultUpdateId,
|
||||
hash: contentHash,
|
||||
remoteRelativePath: response.relativePath
|
||||
},
|
||||
document
|
||||
);
|
||||
await this.updateCache(
|
||||
response.vaultUpdateId,
|
||||
originalContentBytes,
|
||||
actualPath
|
||||
);
|
||||
// FastForwardUpdate — the server accepted our content as-is,
|
||||
// UNLESS this was an idempotent create return (the server
|
||||
// returned the original version, whose content may differ from
|
||||
// what we sent). Detect this by comparing contentSize.
|
||||
const serverContentMatchesLocal =
|
||||
!("contentSize" in response) ||
|
||||
response.contentSize === originalContentBytes.length;
|
||||
|
||||
if (serverContentMatchesLocal) {
|
||||
this.database.updateDocumentMetadata(
|
||||
{
|
||||
documentId: response.documentId,
|
||||
parentVersionId: response.vaultUpdateId,
|
||||
hash: contentHash,
|
||||
remoteRelativePath: response.relativePath
|
||||
},
|
||||
document
|
||||
);
|
||||
await this.updateCache(
|
||||
response.vaultUpdateId,
|
||||
originalContentBytes,
|
||||
actualPath
|
||||
);
|
||||
} else {
|
||||
// The server returned a stale idempotent version. Fetch
|
||||
// the actual content so the cache stays consistent, then
|
||||
// the hash mismatch will trigger a follow-up update sync.
|
||||
const serverContent =
|
||||
await this.syncService.getDocumentVersionContent({
|
||||
documentId: response.documentId,
|
||||
vaultUpdateId: response.vaultUpdateId
|
||||
});
|
||||
this.database.updateDocumentMetadata(
|
||||
{
|
||||
documentId: response.documentId,
|
||||
parentVersionId: response.vaultUpdateId,
|
||||
hash: hash(serverContent),
|
||||
remoteRelativePath: response.relativePath
|
||||
},
|
||||
document
|
||||
);
|
||||
await this.updateCache(
|
||||
response.vaultUpdateId,
|
||||
serverContent,
|
||||
actualPath
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this.database.addSeenUpdateId(response.vaultUpdateId);
|
||||
|
|
@ -672,9 +772,10 @@ export class UnrestrictedSyncer {
|
|||
sizeInBytes: number,
|
||||
relativePath: RelativePath
|
||||
): CommonHistoryEntry | undefined {
|
||||
const sizeInMB = Math.round(sizeInBytes / 1024 / 1024);
|
||||
const { maxFileSizeMB } = this.settings.getSettings();
|
||||
if (sizeInMB > maxFileSizeMB) {
|
||||
const maxFileSizeBytes = maxFileSizeMB * 1024 * 1024;
|
||||
if (sizeInBytes > maxFileSizeBytes) {
|
||||
const sizeInMB = (sizeInBytes / 1024 / 1024).toFixed(1);
|
||||
return {
|
||||
status: SyncStatus.SKIPPED,
|
||||
details: {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue