Fix controlled doc sequence conflicts (#10406)

* Fix document sequence conflicts

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* Clean up

Signed-off-by: Artem Savchenko <armisav@gmail.com>

---------

Signed-off-by: Artem Savchenko <armisav@gmail.com>
This commit is contained in:
Artyom Savchenko
2026-01-15 21:29:48 +07:00
committed by GitHub
parent 79f847e6d9
commit 1e4926fd80
3 changed files with 155 additions and 10 deletions
@@ -531,6 +531,17 @@ export class ApplyOperations extends TxOperations {
if (typeof window === 'object' && window !== null && this.measureName != null) {
console.log(`measure ${this.measureName}`, dnow - st, 'server time', result.serverTime)
}
if (!result.success) {
console.warn('ops.commit() failed', {
scope: this.scope,
measureName: this.measureName,
matchCount: this.matches.length,
notMatchCount: this.notMatches.length,
txCount: this.txes.length,
matches: this.matches.map((m) => ({ _class: m._class, query: m.query })),
notMatches: this.notMatches.map((m) => ({ _class: m._class, query: m.query }))
})
}
this.txes = []
return {
result: result.success,
@@ -63,6 +63,14 @@ export class ApplyTxMiddleware extends BaseMiddleware implements Middleware {
}
applyResult.serverTime = Date.now() - st
} else {
ctx.warn('TxApplyIf failed', {
scope: applyIf.scope,
reason: passed.reason,
measureName: applyIf.measureName,
matchCount: applyIf.match?.length ?? 0,
notMatchCount: applyIf.notMatch?.length ?? 0,
txCount: applyIf.txes.length
})
result.push({
success: false
})
@@ -92,6 +100,7 @@ export class ApplyTxMiddleware extends BaseMiddleware implements Middleware {
): Promise<{
onEnd: () => void
passed: boolean
reason?: string
}> {
if (applyIf.scope == null) {
return { passed: true, onEnd: () => {} }
@@ -115,11 +124,13 @@ export class ApplyTxMiddleware extends BaseMiddleware implements Middleware {
})
)
let passed = true
let reason: string | undefined
if (applyIf.match != null) {
for (const { _class, query } of applyIf.match) {
const res = await this.provideFindAll(ctx, _class, query, { limit: 1 })
if (res.length === 0) {
passed = false
reason = `match query failed: class=${_class}, query=${JSON.stringify(query)}`
break
}
}
@@ -129,10 +140,11 @@ export class ApplyTxMiddleware extends BaseMiddleware implements Middleware {
const res = await this.provideFindAll(ctx, _class, query, { limit: 1 })
if (res.length > 0) {
passed = false
reason = `notMatch query failed: class=${_class}, query=${JSON.stringify(query)} (found ${res.length} matching document(s))`
break
}
}
}
return { passed, onEnd }
return { passed, onEnd, reason }
}
}
+131 -9
View File
@@ -30,7 +30,7 @@ import {
import { makeRank } from '@hcengineering/rank'
import documents from './plugin'
import { getFirstRank, TEMPLATE_PREFIX } from './utils'
import { getDocumentId, getFirstRank, TEMPLATE_PREFIX } from './utils'
async function getParentPath (client: TxOperations, parent: Ref<ProjectDocument>): Promise<Array<Ref<DocumentMeta>>> {
const parentDocObj = await client.findOne(documents.class.ProjectDocument, {
@@ -68,8 +68,10 @@ export async function createControlledDocFromTemplate (
return { seqNumber: -1, success: false }
}
const { seqNumber, prefix, content, category } = await useDocumentTemplate(client, templateId)
const { success, documentMetaId } = await createControlledDocMetadata(
// Try fast path first (assumes template sequence is in sync)
let { seqNumber, prefix, content, category } = await useDocumentTemplate(client, templateId, false)
let actualCode = getDocumentId({ prefix, seqNumber })
let { success, documentMetaId } = await createControlledDocMetadata(
client,
templateId,
documentId,
@@ -78,10 +80,35 @@ export async function createControlledDocFromTemplate (
parent,
prefix,
seqNumber,
spec.code,
actualCode,
spec.title
)
// If creation failed due to seqNumber conflict, retry with full uniqueness check
if (!success) {
// Retry with expensive check to find actual max seqNumber
const retryResult = await useDocumentTemplate(client, templateId, true)
seqNumber = retryResult.seqNumber
prefix = retryResult.prefix
content = retryResult.content
category = retryResult.category
actualCode = getDocumentId({ prefix, seqNumber })
const retryMetadata = await createControlledDocMetadata(
client,
templateId,
documentId,
space,
project,
parent,
prefix,
seqNumber,
actualCode,
spec.title
)
success = retryMetadata.success
documentMetaId = retryMetadata.documentMetaId
}
if (!success) {
return { seqNumber: -1, success: false }
}
@@ -107,9 +134,33 @@ export async function createControlledDocFromTemplate (
return { seqNumber, success: true }
}
/**
* Calculate the next available seqNumber by checking existing documents with the template.
*/
async function calculateNextSeqNumberWithCheck (
client: TxOperations,
templateId: Ref<DocumentTemplate>,
currentTemplateSequence: number
): Promise<number> {
const existingDocs = await client.findAll(
documents.class.Document,
{
template: templateId
},
{
projection: { seqNumber: 1 }
}
)
const maxExistingSeqNumber = existingDocs.length > 0 ? Math.max(...existingDocs.map((doc) => doc.seqNumber ?? 0)) : -1
return Math.max(currentTemplateSequence, maxExistingSeqNumber) + 1
}
export async function useDocumentTemplate (
client: TxOperations,
templateId: Ref<DocumentTemplate>
templateId: Ref<DocumentTemplate>,
checkExisting: boolean = false
): Promise<{ seqNumber: number, prefix: string, content: Ref<Blob> | null, category: Ref<DocumentCategory> }> {
const template = await client.findOne(documents.mixin.DocumentTemplate, {
_id: templateId
@@ -119,15 +170,27 @@ export async function useDocumentTemplate (
return { seqNumber: -1, prefix: '', content: null, category: '' as Ref<DocumentCategory> }
}
let nextSeqNumber: number
if (checkExisting) {
nextSeqNumber = await calculateNextSeqNumberWithCheck(client, templateId, template.sequence)
} else {
nextSeqNumber = template.sequence + 1
}
// Update template sequence to nextSeqNumber in a single atomic operation
await client.updateMixin(templateId, documents.class.Document, template.space, documents.mixin.DocumentTemplate, {
$inc: { sequence: 1 }
sequence: nextSeqNumber
})
// FIXME: not concurrency safe
const seqNumber = template.sequence + 1
const prefix = template.docPrefix
return { seqNumber, prefix, content: template.content, category: template.category as Ref<DocumentCategory> }
return {
seqNumber: nextSeqNumber,
prefix,
content: template.content,
category: template.category as Ref<DocumentCategory>
}
}
export async function createControlledDocMetadata (
@@ -203,6 +266,22 @@ export async function createControlledDocMetadata (
const success = await ops.commit()
if (!success.result) {
console.warn('createControlledDocMetadata: ops.commit() failed', {
templateId,
documentId,
space,
project,
parent,
prefix,
seqNumber,
specCode,
specTitle,
documentMetaId,
projectDocumentId
})
}
return { success: success.result, seqNumber, documentMetaId, projectDocumentId }
}
@@ -261,6 +340,20 @@ export async function createDocumentTemplate (
})
const commit = await ops.commit()
if (!commit.result) {
console.warn('createDocumentTemplate: ops.commit() failed', {
_class,
space,
_mixin,
project,
parent,
templateId,
prefix,
category,
author
})
}
return { seqNumber, success: commit.result }
}
@@ -355,6 +448,24 @@ export async function createDocumentTemplateMetadata (
const success = await ops.commit()
if (!success.result) {
console.warn('createDocumentTemplateMetadata: ops.commit() failed', {
_class,
space,
_mixin,
project,
parent,
templateId,
prefix,
specCode,
specTitle,
seqNumber,
code,
documentMetaId,
projectDocumentId
})
}
return { success: success.result, seqNumber, code, documentMetaId, projectDocumentId }
}
@@ -407,5 +518,16 @@ export async function createNewFolder (
const success = await ops.commit()
if (!success.result) {
console.warn('createNewFolder: ops.commit() failed', {
space,
project,
parent,
title,
documentMetaId,
projectDocumentId
})
}
return { success: success.result, documentMetaId, projectDocumentId }
}