// // Copyright 2024 DXOS.org // import { invariant } from '@dxos/invariant'; import { PublicKey } from '@dxos/keys'; import { log } from '@dxos/log'; import { type Credential } from '@dxos/protocols/proto/dxos/halo/credentials'; import { type AsyncCallback, Callback, ComplexMap, ComplexSet } from '@dxos/util'; export class CredentialGraph { /** * Local ids are used during traversals. */ private _vertexIdGenerator = 1; /** * All credentials without parent references are connected to the root. */ private _root = { id: -1, parents: [], children: [] } as any as ChainVertex; /** * A credential which is not a parent of any other credential has the sentinel as a child. * Sentinel is a virtual merge-point of all credentials. */ private _sentinel = { id: -2, parents: [], children: [] } as any as ChainVertex; /** * Vertex references are used for fast credential inserts into the graph. */ private _vertexByCredentialId = new ComplexMap>(PublicKey.hash); /** * The current state of the graph. */ private _subjectToVertex = new ComplexMap>(PublicKey.hash); private _subjectToState = new ComplexMap(PublicKey.hash); public onSubjectStateChanged = new Callback>(); constructor(private readonly _stateHandler: CredentialGraphStateHandler) {} public getSubjectState(subjectId: PublicKey): State | undefined { return this._subjectToState.get(subjectId); } public getState(): ReadonlyMap { return this._subjectToState; } public getLeafIds(): PublicKey[] { return this._sentinel.parents.map((v) => v.credential!.id!); } public getGlobalStateScope(): StateScope { return { state: this._subjectToVertex }; } public addVertex(credential: Credential, assertion: A): Promise { const newVertex: ChainVertex = { id: this._vertexIdGenerator++, credential, assertion, parents: [], children: [], }; this._vertexByCredentialId.set(credential.id!, newVertex); const parentIds = credential.parentCredentialIds ?? []; if (parentIds.length === 0) { this._root.children.push(newVertex); newVertex.parents.push(this._root); } else { for (const parentId of parentIds) { const parentVertex = this._vertexByCredentialId.get(parentId); if (parentVertex == null) { log.error('credential skipped because of the unknown parent', { credential, parentId }); continue; } parentVertex.children.push(newVertex); newVertex.parents.push(parentVertex); this._removeSentinelConnection(parentVertex); } } newVertex.children.push(this._sentinel); this._sentinel.parents.push(newVertex); return this._onVertexInserted(newVertex); } private _removeSentinelConnection(vertex: ChainVertex): void { const sentinelIdx = vertex.children.indexOf(this._sentinel); if (sentinelIdx >= 0) { vertex.children.splice(sentinelIdx, 1); const vertexInSentinelIdx = this._sentinel.parents.indexOf(vertex); invariant(vertexInSentinelIdx >= 0); this._sentinel.parents.splice(vertexInSentinelIdx, 1); } } private async _onVertexInserted(newVertex: ChainVertex): Promise { const { credential, assertion } = newVertex; invariant(credential); let changedSubjects: State[] = []; const isUpdateAppliedOnTopOfThePreviousState = this._sentinel.parents.length === 1; if (isUpdateAppliedOnTopOfThePreviousState) { const subjectId = credential.subject.id; if (this._stateHandler.isUpdateAllowed(this.getGlobalStateScope(), credential, assertion)) { const newSubjectState = this._stateHandler.createState(credential, newVertex.assertion); const prevSubjectState = this._subjectToState.get(subjectId); this._subjectToState.set(subjectId, newSubjectState); this._subjectToVertex.set(subjectId, newVertex); if (this._stateHandler.hasStateChanged(newSubjectState, prevSubjectState)) { changedSubjects.push(newSubjectState); } } } else { changedSubjects = this._recomputeState(); } if (changedSubjects.length > 0) { await this.onSubjectStateChanged.callIfSet(changedSubjects); } } /** * DFS the graph from root to sentinel pausing on merge points (nodes with multiple parents). * Continue after all paths leading to a merge point converge by merging their states. * In case of a concurrent update paths are replayed taking into account the state set * by the winning branch. */ private _recomputeState(): State[] { // ID of a merge point to the list of paths that reached the point. const pendingPaths = new Map[]>(); const paths: PathState[] = [this._createRootPath()]; let lastPath: PathState | null = null; while (lastPath == null) { const path = paths.pop()!; log('visit vertex', { id: path.head.id }); this._updatePathState(path); const convergedPaths = this._handleMergePoint(paths, pendingPaths, path); if (convergedPaths == null) { log('waiting for other paths'); continue; } const mergeResult = this._mergePaths(convergedPaths); if (mergeResult.type === 'replay_required') { this._replayFailedPaths(paths, pendingPaths, mergeResult, convergedPaths); continue; } const merged = mergeResult.path; if (merged.head.children.length === 0) { lastPath = merged; } else if (merged.head.children.length === 1) { merged.head = merged.head.children[0]; paths.push(merged); } else { this._forkTraversal(paths, merged); } } if (paths.length > 0) { log.error('traversal finished while there were active paths', { paths: paths.map((p) => ({ path: toChosenPath(p), head: p.head.id })), }); } return this._setCurrentState(lastPath); } private _replayFailedPaths( paths: PathState[], pendingPaths: Map[]>, mergeResult: ReplayRequiredMergeResult, convergedPaths: PathState[], ): void { paths.push( ...mergeResult.replay.map((path) => { const stateOverrides = path.stateOverrides ?? new ComplexMap>(PublicKey.hash); mergeResult.stateOverrides.forEach((value, key) => stateOverrides.set(key, value)); return { ...mergeResult.from, chosenPath: path.chosenPath, stateOverrides }; }), ); log('replay paths', () => ({ count: paths.length, paths: paths.map((path) => ({ from: mergeResult.from.head.id, path: toChosenPath(path), overrides: path?.stateOverrides?.mapValues((v) => this._stateHandler.toLogString(v.assertion)), })), })); const clearedPending = convergedPaths.filter((l) => !mergeResult.replay.includes(l)); pendingPaths.set(convergedPaths[0].head.id, clearedPending); } private _handleMergePoint( paths: PathState[], pendingPaths: Map[]>, path: PathState, ): PathState[] | null { const pendingList = pendingPaths.get(path.head.id) ?? []; pendingPaths.set(path.head.id, pendingList); pendingList.push(path); if (pendingList.length < path.head.parents.length) { return null; } if (path.head.id === this._sentinel.id && paths.length > 0) { log('waiting for all the active paths to converge on the sentinel'); return null; } pendingPaths.delete(path.head.id); return pendingList; } private _updatePathState(path: PathState): void { const headCredential = path.head.credential; if (headCredential == null) { return; } const updatedSubject = headCredential.subject.id; path.credentials.add(headCredential.id!); let isUpdateAllowed = this._stateHandler.isUpdateAllowed(path, headCredential, path.head.assertion); // Compatibility with old credentials where parent references were not specified. if (!isUpdateAllowed && path.head.parents[0]?.id === this._root.id) { const globalState = this.getGlobalStateScope(); isUpdateAllowed = this._stateHandler.isUpdateAllowed(globalState, headCredential, path.head.assertion); } if (isUpdateAllowed) { path.forkChangedSubjects.add(updatedSubject); path.forkIssuers.add(headCredential.issuer); path.state.set(updatedSubject, path.head); log('path state updated', () => ({ subject: updatedSubject, newState: this._stateHandler.toLogString(path.head.assertion), })); } } private _forkTraversal(paths: PathState[], path: PathState): void { const replayChoice = path.chosenPath?.[path.head.id]; const choices = replayChoice ?? path.head.children; for (const choice of choices) { log('edge traversal', { from: path.head.id, to: choice.id }); const fork: PathState = { forkPoint: path, chosenPath: { ...path.chosenPath, [path.head.id]: [choice] }, head: choice, credentials: new ComplexSet(PublicKey.hash, path.credentials), state: new ComplexMap(PublicKey.hash, [...path.state.entries()]), forkIssuers: new ComplexSet(PublicKey.hash), forkChangedSubjects: new ComplexSet(PublicKey.hash), stateOverrides: path.stateOverrides, }; paths.push(fork); } } /** * Updates the current graph state. * @returns changed states. */ private _setCurrentState(path: PathState): State[] { const changedSubjects: State[] = []; const newStateMap = new ComplexMap(PublicKey.hash); const newVertexMap = new ComplexMap>(PublicKey.hash); for (const [subjectKey, subjectVertex] of path.state.entries()) { const newState = this._stateHandler.createState(subjectVertex.credential!, subjectVertex.assertion); const prevState = this._subjectToState.get(subjectKey); newStateMap.set(subjectKey, newState); newVertexMap.set(subjectKey, subjectVertex); if (this._stateHandler.hasStateChanged(newState, prevState)) { changedSubjects.push(newState); } } this._subjectToState = newStateMap; this._subjectToVertex = newVertexMap; return changedSubjects; } /* * Walk up all the fork points and return the first one present in all the paths. * We use local id to determine vertex position in the graph, because nodes can't * be inserted in the middle (between a parent and a child) and ids are monotonically increasing. */ private _leastCommonAncestor(paths: PathState[]): PathState { const uniqueForkPoints = paths.reduce((acc, path) => { let it = path.forkPoint; while (it) { acc.set(it.head.id, it); it = it.forkPoint; } return acc; }, new Map>()); let maxId = this._root.id; let maxState: PathState | null = null; for (const [id, state] of uniqueForkPoints.entries()) { const headCredential = state.head.credential; if (headCredential != null) { const isPointInEveryPath = paths.every((p) => p.credentials.has(headCredential.id!)); if (isPointInEveryPath && id > maxId) { maxId = id; maxState = state; } } } return maxState ?? this._createRootPath(); } /** * We might be merging paths where some of them had fork points after the initial forking. * We need all the paths to point to the least common fork point and contain all the changes * that happened after it. */ private _moveUpToForkPoint(forkPoint: PathState, path: PathState): PathState { const isForkPointInPath = path.chosenPath[forkPoint.head.id] == null || path.forkPoint == null; if (isForkPointInPath) { return path; } if (forkPoint.head.id === path.forkPoint?.head.id) { return path; } let it = path.forkPoint!; while (it.head.id !== forkPoint.head.id) { it.forkIssuers.forEach((iss) => path.forkIssuers.add(iss)); it.forkChangedSubjects.forEach((m) => path.forkChangedSubjects.add(m)); it = it!.forkPoint!; path.forkPoint = it; } return path; } private _mergePaths(convergedPaths: PathState[]): PathMergeResult { invariant(convergedPaths.length >= 1); if (convergedPaths.length === 1) { return { type: 'merged', path: convergedPaths[0] }; } const forkPoint = this._leastCommonAncestor(convergedPaths); log('merging paths', () => ({ forkPointId: forkPoint.head.id, pathCount: convergedPaths.length, forkPoints: convergedPaths.map((fp) => fp.forkPoint?.head.id), })); const paths = convergedPaths.map((p) => this._moveUpToForkPoint(forkPoint, p)); invariant(forkPoint); const result: PathState = { forkPoint: forkPoint.forkPoint, chosenPath: { ...forkPoint.chosenPath, [forkPoint.head.id]: [] }, stateOverrides: forkPoint.stateOverrides, credentials: new ComplexSet(PublicKey.hash, forkPoint.credentials), forkIssuers: new ComplexSet(PublicKey.hash, forkPoint.forkIssuers), forkChangedSubjects: new ComplexSet(PublicKey.hash, forkPoint.forkChangedSubjects), state: forkPoint.state.mapValues((v) => v), head: paths[0].head, }; const subjectToBranch = new ComplexMap>(PublicKey.hash); for (const path of paths) { log('processing a path', () => ({ choices: toChosenPath(path), modified: path.forkChangedSubjects, forkIssuers: path.forkIssuers, state: path.state.mapValues((v) => this._stateHandler.toLogString(v.assertion)), })); path.forkIssuers.forEach((iss) => result.forkIssuers.add(iss)); path.credentials.forEach((cred) => result.credentials.add(cred)); result.chosenPath![forkPoint.head.id].push(...(path.chosenPath![forkPoint.head.id] ?? [])); for (const modifiedSubject of path.forkChangedSubjects) { const existingBranch = subjectToBranch.get(modifiedSubject); if (existingBranch == null || this._shouldOverrideCredential(existingBranch, path, modifiedSubject)) { subjectToBranch.set(modifiedSubject, path); } } } const replayPaths = new Set>(); const addReplayPath = replayPaths.add.bind(replayPaths); for (const [subject, branch] of subjectToBranch.entries()) { result.forkChangedSubjects.add(subject); const vertex = branch.state.get(subject)!; result.state.set(subject, vertex); log('set subject state', () => ({ subject, state: this._stateHandler.toLogString(vertex.assertion) })); const otherPaths = paths.filter((p) => p !== branch); this._stateHandler.getConflictingPaths(otherPaths, vertex).forEach(addReplayPath); } if (replayPaths.size > 0) { return { type: 'replay_required', replay: [...replayPaths.values()], from: forkPoint, stateOverrides: subjectToBranch.mapValues((v, key) => v.state.get(key)!), }; } return { type: 'merged', path: result }; } /** * A candidate credential is preferred over the existing credential if: * 1. It is the merge-point, because it's the last credential that was issued in awareness of all * the previously existing ones. * 2. A path where candidate was set contains existing credential in it, which means that the candidate * was issued after the existing credential by a legitimate issuer. * 3. A state-specific logic (_stateHandler) is able to justify using the candidate credential. * 4. The path where candidate was set has more issuers than the existing path (longer branch). * 5. The issuance time of the candidate is after the issuance time of the existing credential (LWW). */ private _shouldOverrideCredential( existing: PathState, candidate: PathState, modifiedSubject: PublicKey, ): boolean { const candidateVertex = candidate.state.get(modifiedSubject)!; const currentVertex = existing.state.get(modifiedSubject)!; if (candidateVertex.id === currentVertex.id) { return false; } // During merge all paths are pointing to the same head, which is the merge point. const mergePointId = existing.head.id; if (candidateVertex.id === mergePointId || currentVertex.id === mergePointId) { log('merge point chosen to break the tie', { mergePointId: existing.head.id }); return mergePointId === candidateVertex.id; } const candidateCredential = candidateVertex.credential!; const currentCredential = currentVertex.credential!; // A credential is contained in a branch where another credential for this subject was issued. if (existing.credentials.has(candidateCredential.id!) !== candidate.credentials.has(currentCredential.id!)) { log('one of the credentials was overridden in another branch', { current: currentVertex.id, candidate: candidateVertex.id, }); return candidate.credentials.has(currentCredential.id!); } // Give a chance to state-specific conflict resolution logic. const winningCredential = this._stateHandler.tryPickWinningUpdate( existing, currentCredential, candidate, candidateCredential, ); if (winningCredential != null) { return winningCredential === candidateCredential; } if (candidate.forkIssuers.size !== existing.forkIssuers.size) { log('longer issuers branch used to break the tie', { issuerCount: [existing.forkIssuers.size, candidate.forkIssuers.size], }); return candidate.forkIssuers.size > existing.forkIssuers.size; } log('issuance date used to break the tie'); return candidateCredential.issuanceDate.getTime() > currentCredential.issuanceDate.getTime(); } private _createRootPath(): PathState { return { head: this._root, chosenPath: {}, forkIssuers: new ComplexSet(PublicKey.hash), forkChangedSubjects: new ComplexSet(PublicKey.hash), state: new ComplexMap>(PublicKey.hash), credentials: new ComplexSet(PublicKey.hash), }; } } export interface StateScope { head?: { id: number }; state: ReadonlyMap>; stateOverrides?: ReadonlyMap>; } export interface CredentialGraphStateHandler { hasStateChanged(s1?: State, s2?: State): boolean; createState(credential: Credential, assertion: Assertion): State; isUpdateAllowed: (scope: StateScope, update: Credential, assertion: Assertion) => boolean; getConflictingPaths(paths: PathState[], update: ChainVertex): PathState[]; tryPickWinningUpdate( scope1: StateScope, update1: Credential, scope2: StateScope, update2: Credential, ): Credential | null; toLogString(assertion: Assertion): string; } export interface PathState { /** * The current vertex position in path, always advances. */ head: ChainVertex; /** * Subject info local to the current path. */ state: ComplexMap>; /** * Used during path replay to throw away cascading concurrent modifications. * Overrides pathState. */ stateOverrides?: ComplexMap>; /** * Used to faster search of conflicting branches. Is different from pathState.keys() * because pathState is not reset on forks. */ forkChangedSubjects: ComplexSet; /** * Used to find winning branches. A branch wins if it had more participants. * Ties are broken using credential issuance date. */ forkIssuers: ComplexSet; /** * All the credentials processed during this path traversal. */ credentials: ComplexSet; /** * PathState where we had multiple children in the current vertex. * Will be merged with child branches when they converge. */ forkPoint?: PathState; /** * Used for a particular path replay with stateOverrides for conflict resolution * forkVertexId is mapped to child vertex selection. * Contains choices that lead to the current state. Value is an array when * some branches converged before converging with the remaining branches. */ chosenPath: { [forkVertexId: number]: ChainVertex[] }; } export interface ChainVertex { /** * The field is missing on root and sentinel vertices. Assertion is not undefined to avoid * always asserting two fields. */ credential?: Credential; assertion: Assertion; /** * Local incrementing counter used to form paths. Is used only for causality resolution. */ id: number; /** * Parents references are used to handle divergent branch merge-points. */ parents: ChainVertex[]; /** * Child references are traversed when computing the current state. */ children: ChainVertex[]; } type PathMergeResult = SuccessfulMergeResult | ReplayRequiredMergeResult; interface SuccessfulMergeResult { type: 'merged'; path: PathState; } interface ReplayRequiredMergeResult { type: 'replay_required'; from: PathState; replay: PathState[]; stateOverrides: ComplexMap>; } const toChosenPath = (path: PathState) => { return Object.fromEntries(Object.entries(path.chosenPath!).map(([k, vs]) => [k, vs.map((v) => v.id)])); };