Rescan accounts in backup (#10954)

Signed-off-by: Artyom Savchenko <armisav@gmail.com>
This commit is contained in:
Artyom Savchenko
2026-07-04 16:39:16 +07:00
committed by GitHub
parent 9069f9e92a
commit bc9220e977
4 changed files with 128 additions and 10 deletions
@@ -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)
})
})
+18 -10
View File
@@ -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<BackupDocId>
// 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<void> => {
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<PersonUuid>
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
+2
View File
@@ -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<Record<Domain, boolean>>
}
/**
+33
View File
@@ -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')) {