diff --git a/server/backup/src/__tests__/accountDomainKey.spec.ts b/server/backup/src/__tests__/accountDomainKey.spec.ts new file mode 100644 index 0000000000..a8a3a59007 --- /dev/null +++ b/server/backup/src/__tests__/accountDomainKey.spec.ts @@ -0,0 +1,75 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { isValidAccountDomainKey, shouldRescanAccountDomain, toAccountDomain } from '../utils' + +const personDomain = toAccountDomain('person') +const socialIdDomain = toAccountDomain('socialId') + +describe('isValidAccountDomainKey', () => { + describe(personDomain, () => { + it('accepts person UUID keys', () => { + expect(isValidAccountDomainKey(personDomain, '0f2eba47-3773-4b04-a1e3-f75c3ed3e0c1')).toBe(true) + expect(isValidAccountDomainKey(personDomain, 'F2E9A9D0-6C21-4F32-9B71-000000000001')).toBe(true) + }) + + it('rejects numeric socialId keys leaked into person digest', () => { + expect(isValidAccountDomainKey(personDomain, '1057485760843415553')).toBe(false) + }) + + it('rejects garbage keys', () => { + expect(isValidAccountDomainKey(personDomain, 'not-a-uuid')).toBe(false) + expect(isValidAccountDomainKey(personDomain, '')).toBe(false) + }) + }) + + describe(socialIdDomain, () => { + it('accepts numeric socialId keys', () => { + expect(isValidAccountDomainKey(socialIdDomain, '1057485760843415553')).toBe(true) + expect(isValidAccountDomainKey(socialIdDomain, '42')).toBe(true) + }) + + it('rejects UUID keys leaked into socialId digest', () => { + expect(isValidAccountDomainKey(socialIdDomain, '0f2eba47-3773-4b04-a1e3-f75c3ed3e0c1')).toBe(false) + }) + + it('rejects garbage keys', () => { + expect(isValidAccountDomainKey(socialIdDomain, 'abc')).toBe(false) + }) + }) +}) + +describe('shouldRescanAccountDomain', () => { + it('always rescans on full check', () => { + expect(shouldRescanAccountDomain(personDomain, true, { accountsRescan: { [personDomain]: true } })).toBe(true) + expect(shouldRescanAccountDomain(socialIdDomain, true, { accountsRescan: { [socialIdDomain]: true } })).toBe(true) + }) + + it('rescans when initial accounts rescan was never completed', () => { + expect(shouldRescanAccountDomain(personDomain, false, {})).toBe(true) + expect(shouldRescanAccountDomain(socialIdDomain, false, { accountsRescan: {} })).toBe(true) + }) + + it('does not rescan when the domain is already marked as scanned', () => { + expect(shouldRescanAccountDomain(personDomain, false, { accountsRescan: { [personDomain]: true } })).toBe(false) + expect(shouldRescanAccountDomain(socialIdDomain, false, { accountsRescan: { [socialIdDomain]: true } })).toBe(false) + }) + + it('tracks domains independently', () => { + const migrations = { accountsRescan: { [personDomain]: true } } + expect(shouldRescanAccountDomain(personDomain, false, migrations)).toBe(false) + expect(shouldRescanAccountDomain(socialIdDomain, false, migrations)).toBe(true) + }) +}) diff --git a/server/backup/src/backup.ts b/server/backup/src/backup.ts index abf9db37cd..59542f2cd4 100644 --- a/server/backup/src/backup.ts +++ b/server/backup/src/backup.ts @@ -65,7 +65,9 @@ import { extendZero, getObjectHash, isAccountDomain, + isValidAccountDomainKey, loadDigest, + shouldRescanAccountDomain, rebuildSizeInfo, toAccountDomain, verifyDocsFromSnapshot, @@ -939,12 +941,22 @@ export async function backup ( let getObjKey: (obj: any) => string let affectedObjects: Set + // Rescan all persons/social identities on full check or until the initial rescan is completed, + // to make sure accounts are present even if there were no recent contact/channel updates. + const rescan = shouldRescanAccountDomain(domain, fullCheck, backupInfo.migrations) + const markRescanDone = async (): Promise => { + if (backupInfo.migrations.accountsRescan?.[domain] !== true) { + backupInfo.migrations.accountsRescan = { ...backupInfo.migrations.accountsRescan, [domain]: true } + await storage.writeFile(infoFile, gzipSync(JSON.stringify(backupInfo, undefined, 2), { level: defaultLevel })) + } + } + if (isPersonDomain) { collection = 'person' key = 'uuid' getObjKey = (obj: GlobalPerson) => obj.uuid - if (fullCheck) { + if (rescan) { let idx: number | undefined while (true) { const currentChunk = await ctx.with('loadChunk', {}, () => connection.loadChunk(ctx, DOMAIN_CONTACT, idx)) @@ -973,7 +985,7 @@ export async function backup ( key = '_id' getObjKey = (obj: SocialId) => obj._id - if (fullCheck) { + if (rescan) { let idx: number | undefined while (true) { const currentChunk = await ctx.with('loadChunk', {}, () => connection.loadChunk(ctx, DOMAIN_CHANNEL, idx)) @@ -1031,17 +1043,11 @@ export async function backup ( // 2. We need to check updates for all records present in digest const batchSize = 1000 const toLoad = new Set( - [...digest.keys(), ...affectedObjects].filter((it) => { - try { - BigInt(it) - return true - } catch (err: any) { - return false - } - }) + [...digest.keys(), ...affectedObjects].filter((it) => isValidAccountDomainKey(domain, it)) ) as Set if (toLoad.size === 0) { ctx.info('No records updates') + await markRescanDone() return } @@ -1184,6 +1190,8 @@ export async function backup ( // This will allow to retry in case of critical error. await storage.writeFile(infoFile, gzipSync(JSON.stringify(backupInfo, undefined, 2), { level: defaultLevel })) } + // Domain is processed completely, no need to rescan it on the next run. + await markRescanDone() } let domainProgress = 0 diff --git a/server/backup/src/types.ts b/server/backup/src/types.ts index 9df9a25528..11a5eb7289 100644 --- a/server/backup/src/types.ts +++ b/server/backup/src/types.ts @@ -86,6 +86,8 @@ export interface BackupMigrations { forcedCompact?: string /** Version of forced full check migration */ forcedFullCheck?: string + /** Account domains for which the initial full rescan has been completed */ + accountsRescan?: Partial> } /** diff --git a/server/backup/src/utils.ts b/server/backup/src/utils.ts index 6b93cd0407..5316d82521 100644 --- a/server/backup/src/utils.ts +++ b/server/backup/src/utils.ts @@ -47,6 +47,7 @@ import { BackupStorage } from './storage' import type { BackupDocId, BackupInfo, + BackupMigrations, BackupResult, BackupSnapshot, BlobData, @@ -1457,6 +1458,38 @@ export function isAccountDomain (domain: Domain): boolean { return domain.startsWith(accountPrefix) } +const uuidRegExp = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + +/** + * Checks that a backup digest key is valid for the given account domain. + * Person records are keyed by UUID while socialId records are keyed by + * numeric (INT8) identifiers. Keys of the wrong shape (e.g. leaked from + * another domain by older backup versions) must be filtered out. + */ +export function isValidAccountDomainKey (domain: Domain, key: BackupDocId): boolean { + if (domain === toAccountDomain('person')) { + return uuidRegExp.test(key) + } + try { + BigInt(key) + return true + } catch (err: any) { + return false + } +} + +/** + * Decides whether an account domain requires a full rescan of the workspace + * contact/channel domains to collect affected persons/social identities. + * A rescan is needed on explicit full check or until the initial rescan has + * been completed and recorded in backup info migrations — i.e. the very first + * accounts backup or a backup produced by a version which failed to dump + * account domains. + */ +export function shouldRescanAccountDomain (domain: Domain, fullCheck: boolean, migrations: BackupMigrations): boolean { + return fullCheck || migrations.accountsRescan?.[domain] !== true +} + export function getGetObjKey (domain: Domain): GetObjKeyFn { if (isAccountDomain(domain)) { if (domain === toAccountDomain('person')) {