use "insertSet" instead of computing state vectors in transactions

This commit is contained in:
Kevin Jahns
2025-04-08 14:37:08 +02:00
parent 72ee66a739
commit 6d05ce3820
8 changed files with 121 additions and 46 deletions

View File

@@ -6,6 +6,7 @@ import {
readYXmlFragment,
readYXmlHook,
readYXmlText,
isDeleted,
UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, StructStore, Transaction, Item, YEvent, AbstractType // eslint-disable-line
} from '../internals.js'
@@ -107,7 +108,7 @@ export class ContentType {
while (item !== null) {
if (!item.deleted) {
item.delete(transaction)
} else if (item.id.clock < (transaction.beforeState.get(item.id.client) || 0)) {
} else if (!isDeleted(transaction.insertSet, item.id)) {
// This will be gc'd later and we want to merge it if possible
// We try to merge all deleted items after each transaction,
// but we have no knowledge about that this needs to be merged
@@ -119,7 +120,7 @@ export class ContentType {
this.type._map.forEach(item => {
if (!item.deleted) {
item.delete(transaction)
} else if (item.id.clock < (transaction.beforeState.get(item.id.client) || 0)) {
} else if (!isDeleted(transaction.insertSet, item.id)) {
// same as above
transaction._mergeStructs.push(item)
}

View File

@@ -1,7 +1,8 @@
import {
AbstractStruct,
addStruct,
UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, StructStore, Transaction, ID // eslint-disable-line
UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, StructStore, Transaction, ID, // eslint-disable-line
addItemToInsertSet
} from '../internals.js'
export const structGCRefNumber = 0
@@ -37,6 +38,7 @@ export class GC extends AbstractStruct {
this.id.clock += offset
this.length -= offset
}
addItemToInsertSet(transaction, this)
addStruct(transaction.doc.store, this)
}

View File

@@ -22,7 +22,8 @@ import {
readContentType,
addChangedTypeToTransaction,
isDeleted,
StackItem, DeleteSet, UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, ContentType, ContentDeleted, StructStore, ID, AbstractType, Transaction // eslint-disable-line
StackItem, DeleteSet, UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, ContentType, ContentDeleted, StructStore, ID, AbstractType, Transaction, // eslint-disable-line
addItemToInsertSet
} from '../internals.js'
import * as error from 'lib0/error'
@@ -514,6 +515,7 @@ export class Item extends AbstractStruct {
if (this.parentSub === null && this.countable && !this.deleted) {
/** @type {AbstractType<any>} */ (this.parent)._length += this.length
}
addItemToInsertSet(transaction, this)
addStruct(transaction.doc.store, this)
this.content.integrate(transaction, this)
// add parent to transaction.changed

View File

@@ -493,19 +493,13 @@ export const cleanupYTextAfterTransaction = transaction => {
const needFullCleanup = new Set()
// check if another formatting item was inserted
const doc = transaction.doc
for (const [client, afterClock] of transaction.afterState.entries()) {
const clock = transaction.beforeState.get(client) || 0
if (afterClock === clock) {
continue
iterateDeletedStructs(transaction, transaction.insertSet, (item) => {
if (
!item.deleted && /** @type {Item} */ (item).content.constructor === ContentFormat && item.constructor !== GC
) {
needFullCleanup.add(/** @type {any} */ (item).parent)
}
iterateStructs(transaction, /** @type {Array<Item|GC>} */ (doc.store.clients.get(client)), clock, afterClock, item => {
if (
!item.deleted && /** @type {Item} */ (item).content.constructor === ContentFormat && item.constructor !== GC
) {
needFullCleanup.add(/** @type {any} */ (item).parent)
}
})
}
})
// cleanup in a new transaction
transact(doc, (t) => {
iterateDeletedStructs(transaction, transaction.deleteSet, item => {

View File

@@ -11,9 +11,12 @@ import {
generateNewClientId,
createID,
cleanupYTextAfterTransaction,
UpdateEncoderV1, UpdateEncoderV2, GC, StructStore, AbstractType, AbstractStruct, YEvent, Doc // eslint-disable-line
isDeleted,
UpdateEncoderV1, UpdateEncoderV2, GC, StructStore, AbstractType, AbstractStruct, YEvent, Doc, // eslint-disable-line
DeleteItem
} from '../internals.js'
import * as error from 'lib0/error'
import * as map from 'lib0/map'
import * as math from 'lib0/math'
import * as set from 'lib0/set'
@@ -63,15 +66,20 @@ export class Transaction {
*/
this.deleteSet = new DeleteSet()
/**
* Holds the state before the transaction started.
* @type {Map<Number,Number>}
* Describes the set of inserted items by ids
* @type {DeleteSet}
*/
this.beforeState = getStateVector(doc.store)
this.insertSet = new DeleteSet()
/**
* Holds the state before the transaction started.
* @type {Map<Number,Number>?}
*/
this._beforeState = null
/**
* Holds the state after the transaction.
* @type {Map<Number,Number>}
* @type {Map<Number,Number>?}
*/
this.afterState = new Map()
this._afterState = null
/**
* All types that were directly modified (property added or child
* inserted/deleted). New types are not included in this Set.
@@ -119,6 +127,43 @@ export class Transaction {
* @type {boolean}
*/
this._needFormattingCleanup = false
this._done = false
}
/**
* Holds the state before the transaction started.
*
* @deprecated
* @type {Map<Number,Number>}
*/
get beforeState () {
if (this._beforeState == null) {
const sv = getStateVector(this.doc.store)
this.insertSet.clients.forEach((ranges, client) => {
sv.set(client, ranges[0].clock)
})
this._beforeState = sv
}
return this._beforeState
}
/**
* Holds the state after the transaction.
*
* @deprecated
* @type {Map<Number,Number>}
*/
get afterState () {
if (!this._done) error.unexpectedCase()
if (this._afterState == null) {
const sv = getStateVector(this.doc.store)
this.insertSet.clients.forEach((ranges, client) => {
const d = ranges[ranges.length - 1]
sv.set(client, d.clock + d.len)
})
this._afterState = sv
}
return this._afterState
}
}
@@ -128,7 +173,7 @@ export class Transaction {
* @return {boolean} Whether data was written.
*/
export const writeUpdateMessageFromTransaction = (encoder, transaction) => {
if (transaction.deleteSet.clients.size === 0 && !map.any(transaction.afterState, (clock, client) => transaction.beforeState.get(client) !== clock)) {
if (transaction.deleteSet.clients.size === 0 && transaction.insertSet.clients.size === 0) {
return false
}
sortAndMergeDeleteSet(transaction.deleteSet)
@@ -158,11 +203,28 @@ export const nextID = transaction => {
*/
export const addChangedTypeToTransaction = (transaction, type, parentSub) => {
const item = type._item
if (item === null || (item.id.clock < (transaction.beforeState.get(item.id.client) || 0) && !item.deleted)) {
if (item === null || (!item.deleted && !isDeleted(transaction.insertSet, item.id))) {
map.setIfUndefined(transaction.changed, type, set.create).add(parentSub)
}
}
/**
* @param {Transaction} tr
* @param {AbstractStruct} item
*/
export const addItemToInsertSet = (tr, item) => {
const ranges = map.setIfUndefined(tr.insertSet.clients, item.id.client, () => /** @type {Array<import('./DeleteSet.js').DeleteItem>} */ ([]))
if (ranges.length > 0) {
const r = ranges[ranges.length - 1]
if (r.clock + r.len === item.id.clock) {
// @ts-ignore
r.len += item.length
return
}
}
ranges.push(new DeleteItem(item.id.clock, item.length))
}
/**
* @param {Array<AbstractStruct>} structs
* @param {number} pos
@@ -260,13 +322,15 @@ export const tryGc = (ds, store, gcFilter) => {
const cleanupTransactions = (transactionCleanups, i) => {
if (i < transactionCleanups.length) {
const transaction = transactionCleanups[i]
transaction._done = true
const doc = transaction.doc
const store = doc.store
const ds = transaction.deleteSet
const insertSet = transaction.insertSet
const mergeStructs = transaction._mergeStructs
try {
sortAndMergeDeleteSet(ds)
transaction.afterState = getStateVector(transaction.doc.store)
sortAndMergeDeleteSet(insertSet)
doc.emit('beforeObserverCalls', [transaction, doc])
/**
* An array of event callbacks.
@@ -323,15 +387,13 @@ const cleanupTransactions = (transactionCleanups, i) => {
tryMergeDeleteSet(ds, store)
// on all affected store.clients props, try to merge
transaction.afterState.forEach((clock, client) => {
const beforeClock = transaction.beforeState.get(client) || 0
if (beforeClock !== clock) {
const structs = /** @type {Array<GC|Item>} */ (store.clients.get(client))
// we iterate from right to left so we can safely remove entries
const firstChangePos = math.max(findIndexSS(structs, beforeClock), 1)
for (let i = structs.length - 1; i >= firstChangePos;) {
i -= 1 + tryToMergeWithLefts(structs, i)
}
transaction.insertSet.clients.forEach((ids, client) => {
const firstClock = ids[0].clock
const structs = /** @type {Array<GC|Item>} */ (store.clients.get(client))
// we iterate from right to left so we can safely remove entries
const firstChangePos = math.max(findIndexSS(structs, firstClock), 1)
for (let i = structs.length - 1; i >= firstChangePos;) {
i -= 1 + tryToMergeWithLefts(structs, i)
}
})
// try to merge mergeStructs
@@ -350,7 +412,7 @@ const cleanupTransactions = (transactionCleanups, i) => {
tryToMergeWithLefts(structs, replacedStructPos)
}
}
if (!transaction.local && transaction.afterState.get(doc.clientID) !== transaction.beforeState.get(doc.clientID)) {
if (!transaction.local && transaction.insertSet.clients.has(doc.clientID)) {
logging.print(logging.ORANGE, logging.BOLD, '[yjs] ', logging.UNBOLD, logging.RED, 'Changed the client-id because another client seems to be using it.')
doc.clientID = generateNewClientId()
}

View File

@@ -226,14 +226,7 @@ export class UndoManager extends ObservableV2 {
// neither undoing nor redoing: delete redoStack
this.clear(false, true)
}
const insertions = new DeleteSet()
transaction.afterState.forEach((endClock, client) => {
const startClock = transaction.beforeState.get(client) || 0
const len = endClock - startClock
if (len > 0) {
addToDeleteSet(insertions, client, startClock, len)
}
})
const insertions = transaction.insertSet
const now = time.getUnixTime()
let didAdd = false
if (this.lastChange > 0 && now - this.lastChange < this.captureTimeout && stack.length > 0 && !undoing && !redoing) {

View File

@@ -158,7 +158,7 @@ export class YEvent {
* @return {boolean}
*/
adds (struct) {
return struct.id.clock >= (this.transaction.beforeState.get(struct.id.client) || 0)
return isDeleted(this.transaction.insertSet, struct.id)
}
/**

View File

@@ -36,7 +36,8 @@ import {
Skip,
diffUpdateV2,
convertUpdateFormatV2ToV1,
DSDecoderV2, Doc, Transaction, GC, Item, StructStore // eslint-disable-line
DeleteSet, DSDecoderV2, Doc, Transaction, GC, Item, StructStore, // eslint-disable-line
iterateDeletedStructs
} from '../internals.js'
import * as encoding from 'lib0/encoding'
@@ -101,6 +102,26 @@ export const writeClientsStructs = (encoder, store, _sm) => {
})
}
/**
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
* @param {StructStore} store
* @param {DeleteSet} idset
*
* @todo at the moment this writes the full deleteset range
*
* @private
* @function
*/
export const writeStructsFromIdSet = (encoder, store, idset) => {
// write # states that were updated
encoding.writeVarUint(encoder.restEncoder, idset.clients.size)
// Write items with higher client ids first
// This heavily improves the conflict algorithm.
array.from(idset.clients.entries()).sort((a, b) => b[0] - a[0]).forEach(([client, ids]) => {
writeStructs(encoder, /** @type {Array<GC|Item>} */ (store.clients.get(client)), client, ids[0].clock)
})
}
/**
* @param {UpdateDecoderV1 | UpdateDecoderV2} decoder The decoder object to read data from.
* @param {Doc} doc
@@ -365,7 +386,7 @@ const integrateStructs = (transaction, store, clientsStructRefs) => {
* @private
* @function
*/
export const writeStructsFromTransaction = (encoder, transaction) => writeClientsStructs(encoder, transaction.doc.store, transaction.beforeState)
export const writeStructsFromTransaction = (encoder, transaction) => writeStructsFromIdSet(encoder, transaction.doc.store, transaction.insertSet)
/**
* Read and apply a document update.