mirror of
https://github.com/hcengineering/platform.git
synced 2026-08-17 18:05:42 +02:00
Rescan accounts in backup (#10954)
Signed-off-by: Artyom Savchenko <armisav@gmail.com>
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
@@ -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>>
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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')) {
|
||||
|
||||
Reference in New Issue
Block a user