auu
This commit is contained in:
parent
d995726462
commit
0a343ecbc5
326 changed files with 15584 additions and 0 deletions
235
test/concurrency-smoke-test.js
Normal file
235
test/concurrency-smoke-test.js
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
'use strict';
|
||||
const {vm,app,assert,functionSource}=require('./helpers/app-source');
|
||||
|
||||
const persistSource=functionSource('persistDirtyToDb');
|
||||
const startupSource=functionSource('loadSnapshotFromDb');
|
||||
const startupV2Source=functionSource('loadV2SnapshotFromDb');
|
||||
const initialSource=functionSource('readInitialDataAsync');
|
||||
const replaceSource=functionSource('beginWorldReplacement');
|
||||
const clearSource=functionSource('clearDatabaseWorld');
|
||||
|
||||
assert(startupSource.includes('loadV2SnapshotFromDb')&&startupV2Source.includes("db.transaction(['worlds','boardIndex','outboxV2','recoveryV2','tombstonesV2'],'readonly')"),
|
||||
'Startup does not read the snapshot, recovery coverage, and tombstones atomically');
|
||||
assert(startupV2Source.includes("objectStore('recoveryV2').getAll")&&startupV2Source.includes("objectStore('tombstonesV2').getAll"),
|
||||
'Startup omits database recovery journals or tombstones');
|
||||
assert(!initialSource.includes('loadRecoveryCoverage('),
|
||||
'Startup still performs the recovery coverage read in a second transaction');
|
||||
assert(replaceSource.includes('await preserveRecoveryDurably('),
|
||||
'World replacement does not await a verified recovery backup');
|
||||
assert(functionSource('retryRecovery').includes('readRecoveryEnvelope')&&functionSource('retryRecovery').includes('stageRecoverySnapshotV2')&&functionSource('retryRecovery').includes("kind:'recovery'"),
|
||||
'Recovery backup is not restored through the validated staged-world path');
|
||||
assert(clearSource.includes("activateReadyWorldV2({epoch:newEpoch,expected,world},{kind:'reset'})"),
|
||||
'Fresh-world reset does not use atomic epoch activation');
|
||||
assert(!app.includes('worldMutationLockDepth')&&functionSource('withWorldMutationLock').includes("mode:'exclusive'"),
|
||||
'World mutation locking still bypasses unrelated asynchronous callers');
|
||||
assert(functionSource('persistNow').includes('if(options.lockHeld===true)return run()')&&functionSource('expandMetaNow').includes('lockHeld:true'),
|
||||
'Nested expansion persistence can deadlock behind a queued lock waiter');
|
||||
assert(app.includes('if(!worldInitReady){deferredWorldSignals.push(signal)'),
|
||||
'Cross-tab messages are not buffered until initialization is complete');
|
||||
assert(functionSource('initCloudSync').includes('if(cloudApiEnabled&&!cloudOutboxReady)'),
|
||||
'Cloud synchronization is not fail-closed when IndexedDB health is uncertain');
|
||||
|
||||
const request=result=>({result});
|
||||
function memoryStore(initial=[],keyOf=row=>row.id){
|
||||
const normalizeKey=key=>Array.isArray(key)?key.join(':'):key,rows=new Map(initial.map(row=>[normalizeKey(keyOf(row)),structuredClone(row)]));
|
||||
return{
|
||||
rows,
|
||||
get:key=>request(rows.has(normalizeKey(key))?structuredClone(rows.get(normalizeKey(key))):null),
|
||||
put:row=>{rows.set(normalizeKey(keyOf(row)),structuredClone(row));return request(keyOf(row))},
|
||||
delete:key=>{rows.delete(normalizeKey(key));return request(undefined)},
|
||||
clear:()=>{rows.clear();return request(undefined)}
|
||||
};
|
||||
}
|
||||
function persistenceContext({localMeta=null,localState=null,existingMeta=null,existingState=null,deletion=null,identityEpoch='world:test'}={}){
|
||||
const existingId=existingMeta?.id||'B0',existingIndex=existingMeta?{epoch:'world:test',id:existingId,metaRev:existingMeta.rev||0,stateRev:existingState?.rev||0}:null;
|
||||
const remoteGlobal={worldEpoch:'world:test',globalRev:20,globalRevAuthor:'remote',nextId:9,cursorStyle:'remote'};
|
||||
const stores={
|
||||
control:memoryStore([{key:'active',activeFormat:2,activeEpoch:identityEpoch}],row=>row.key),
|
||||
worlds:memoryStore([{epoch:'world:test',status:'active',createdAt:1,source:{kind:'fresh'},global:remoteGlobal}],row=>row.epoch),
|
||||
boardIndex:memoryStore(existingIndex?[existingIndex]:[],row=>`${row.epoch}:${row.id}`),
|
||||
boardPuzzles:memoryStore(existingMeta?[{epoch:'world:test',id:existingId,metaRev:existingMeta.rev||0,meta:existingMeta}]:[],row=>`${row.epoch}:${row.id}`),
|
||||
boardStates:memoryStore(existingState?[{epoch:'world:test',id:existingId,stateRev:existingState.rev||0,value:existingState}]:[],row=>`${row.epoch}:${row.id}`),
|
||||
outboxV2:memoryStore([],row=>`${row.epoch}:${row.key}`),recoveryV2:memoryStore([],row=>`${row.epoch}:${row.key}`),tombstonesV2:memoryStore([],row=>`${row.epoch}:${row.id}`)
|
||||
};
|
||||
const id=localMeta?.id||existingMeta?.id||'B0';
|
||||
const data={
|
||||
worldEpoch:'world:test',globalRev:10,globalRevAuthor:'local',nextId:2,cursorStyle:'local',
|
||||
metas:localMeta?{[id]:structuredClone(localMeta)}:{},
|
||||
states:localState?{[id]:structuredClone(localState)}:{},
|
||||
updatedAt:0,clockFloor:0
|
||||
};
|
||||
const context={
|
||||
console,structuredClone,sessionId:'local',lastRevision:100,data,
|
||||
dirtyMetaIds:new Set(localMeta?[id]:[]),dirtyStateIds:new Set(localState?[id]:[]),
|
||||
deletedBoardIds:new Set(deletion?[id]:[]),
|
||||
deletedBoardRevisions:new Map(deletion?[[id,deletion.rev]]:[]),
|
||||
deletedBoardAuthors:new Map(deletion?[[id,deletion.revAuthor]]:[]),
|
||||
recoveryJournalsToCover:[],recoveryJournalSeq:-1,recoveryWalPromise:Promise.resolve(),
|
||||
cloudOutboxDeleteKeys:new Set(),cloudJournalMetaIds:new Set(),cloudJournalStateIds:new Set(),
|
||||
cloudJournalDeletedIds:new Set(deletion?[id]:[]),cloudApiEnabled:false,
|
||||
globalDirty:true,globalChangeSeq:1,cloudJournalGlobalChanged:true,worldSignalSeq:0,
|
||||
idbAvailable:true,activeStorageFormat:2,FIELD_STORAGE_FORMAT:2,storageAccessError:null,normalizedStateObjects:new WeakSet(),
|
||||
boardIndexSummaries:new Map(),
|
||||
statsDirty:false,resetHistory:[],
|
||||
pruneAndCount:()=>{},
|
||||
hasPendingPersistence:()=>context.globalDirty||context.dirtyMetaIds.size>0||context.dirtyStateIds.size>0||context.deletedBoardIds.size>0,
|
||||
validWorldEpoch:value=>typeof value==='string'&&value.startsWith('world:'),
|
||||
storedWorldEpoch:()=>null,createWorldEpoch:()=> 'world:test',rememberWorldEpoch:()=>true,
|
||||
revisionVersion:value=>({rev:value?.rev||0,revAuthor:value?.revAuthor||value?.author||''}),
|
||||
compareRevisionVersions:(a,b)=>(a?.rev||0)-(b?.rev||0)||String(a?.revAuthor||a?.author||'').localeCompare(String(b?.revAuthor||b?.author||'')),
|
||||
newerRevisionValue:(a,b)=>context.compareRevisionVersions(a,b)>=0?a:b,
|
||||
trustedNow:()=>1234,
|
||||
globalForStorage:(source,updatedAt)=>({...source,metas:undefined,states:undefined,updatedAt,clockFloor:updatedAt}),
|
||||
mergeGlobalRecords:(stored,incoming)=>context.compareRevisionVersions(
|
||||
{rev:incoming.globalRev,revAuthor:incoming.globalRevAuthor},
|
||||
{rev:stored?.globalRev,revAuthor:stored?.globalRevAuthor}
|
||||
)>=0?incoming:stored,
|
||||
applyGlobalRecordToData:record=>Object.assign(data,structuredClone(record)),
|
||||
staleWorldEpochError:()=>Object.assign(new Error('stale world'),{code:'STALE_WORLD_EPOCH'}),
|
||||
metaRowsForStorage:ids=>ids.map(rowId=>data.metas[rowId]&&structuredClone(data.metas[rowId])).filter(Boolean),
|
||||
stateRowsForStorage:ids=>ids.map(rowId=>data.states[rowId]&&({id:rowId,value:structuredClone(data.states[rowId])})).filter(Boolean),
|
||||
snapshotForStorage:()=>({}),writeCompactMirror:()=>true,
|
||||
openWorldDb:async()=>({transaction:()=>({objectStore:name=>stores[name],abort(){this.aborted=true}})}),
|
||||
transactionDone:async()=>{},requestValue:async req=>req.result,
|
||||
updateStorageRevision:()=>{},scheduleMirrorCheckpoint:()=>{},
|
||||
normalizeMeta:(_id,value)=>structuredClone(value),normalizeState:value=>structuredClone(value||{paths:[],rev:0,revAuthor:''}),
|
||||
metaFromV2Records:(_index,puzzle)=>puzzle?.meta?structuredClone(puzzle.meta):null,
|
||||
stateFromV2Record:(_index,state)=>state?.value?structuredClone(state.value):null,
|
||||
puzzleRecordV2:(meta,epoch)=>({epoch,id:meta.id,metaRev:meta.rev||0,meta:structuredClone(meta)}),
|
||||
stateRecordV2:(rowId,state,epoch)=>({epoch,id:rowId,stateRev:state?.rev||0,value:structuredClone(state)}),
|
||||
summarizeBoardV2:(meta,state,epoch)=>({epoch,id:meta.id,metaRev:meta.rev||0,stateRev:state?.rev||0}),
|
||||
fieldBoundsFromMetas:()=>({minX:0,minY:0,maxX:1,maxY:1}),SAVE_SCHEMA:31,WORLD_GENERATION:'current-only-test',
|
||||
clearRecoveryJournalIfCovered:()=>{},broadcastWorldSignal:()=>{},scheduleCloudPush:()=>{},
|
||||
refreshWorldView:()=>{context.refreshes++},refreshes:0,
|
||||
perfStart:()=>0,perfEnd:()=>{},perfGauge:()=>{}
|
||||
};
|
||||
vm.createContext(context);
|
||||
vm.runInContext(`${persistSource}\nthis.persistDirtyToDb=persistDirtyToDb;`,context);
|
||||
return{context,stores,id};
|
||||
}
|
||||
|
||||
async function verifyPersistenceConflicts(){
|
||||
{
|
||||
const localMeta={id:'B0',x:1,rev:5,revAuthor:'local'},localState={paths:[],rev:5,revAuthor:'local'};
|
||||
const existingMeta={id:'B0',x:7,rev:10,revAuthor:'remote'},existingState={paths:[[1]],rev:11,revAuthor:'remote'};
|
||||
const {context,stores}=persistenceContext({localMeta,localState,existingMeta,existingState});
|
||||
await context.persistDirtyToDb();
|
||||
assert(stores.boardPuzzles.rows.get('world:test:B0').meta.x===7&&stores.boardStates.rows.get('world:test:B0').value.rev===11,
|
||||
'A stale tab overwrote a newer database row');
|
||||
assert(context.data.metas.B0.x===7&&context.data.states.B0.rev===11&&context.refreshes===1,
|
||||
'A stale in-memory board was not reconciled to the committed winner');
|
||||
assert(context.data.cursorStyle==='remote'&&context.data.nextId===9,
|
||||
'A stale global record overwrote the newer global winner');
|
||||
}
|
||||
{
|
||||
const existingMeta={id:'B0',x:4,rev:20,revAuthor:'remote'},existingState={paths:[],rev:21,revAuthor:'remote'};
|
||||
const {context,stores}=persistenceContext({
|
||||
existingMeta,existingState,deletion:{rev:15,revAuthor:'local'}
|
||||
});
|
||||
await context.persistDirtyToDb();
|
||||
assert(stores.boardPuzzles.rows.has('world:test:B0')&&stores.boardStates.rows.has('world:test:B0')&&!stores.tombstonesV2.rows.has('world:test:B0'),
|
||||
'A stale deletion removed a newer database board');
|
||||
assert(context.data.metas.B0?.rev===20&&context.data.states.B0?.rev===21&&context.refreshes===1,
|
||||
'A rejected stale deletion was not restored in the losing tab');
|
||||
}
|
||||
{
|
||||
const existingMeta={id:'B0',rev:20,revAuthor:'remote'},existingState={paths:[],rev:20,revAuthor:'remote'};
|
||||
const {context,stores}=persistenceContext({
|
||||
existingMeta,existingState,deletion:{rev:25,revAuthor:'local'}
|
||||
});
|
||||
await context.persistDirtyToDb();
|
||||
assert(!stores.boardPuzzles.rows.has('world:test:B0')&&!stores.boardStates.rows.has('world:test:B0'),
|
||||
'A newer deletion did not remove the older board rows');
|
||||
assert(stores.tombstonesV2.rows.get('world:test:B0')?.rev===25,
|
||||
'A committed deletion did not leave a durable tombstone');
|
||||
}
|
||||
{
|
||||
const localMeta={id:'B0',rev:5,revAuthor:'local'};
|
||||
const {context}=persistenceContext({localMeta,identityEpoch:'world:replacement'});
|
||||
let error=null;try{await context.persistDirtyToDb()}catch(caught){error=caught}
|
||||
assert(error?.code==='STALE_WORLD_EPOCH'&&context.dirtyMetaIds.has('B0'),
|
||||
'A stale tab was allowed to write into a replaced world epoch');
|
||||
}
|
||||
}
|
||||
|
||||
function verifyGlobalMerge(){
|
||||
const context={
|
||||
deepClone:value=>structuredClone(value),structuredClone,
|
||||
validWorldEpoch:value=>typeof value==='string'&&value.startsWith('world:'),
|
||||
compareRevisionVersions:(a,b)=>(a?.rev||0)-(b?.rev||0)||String(a?.revAuthor||'').localeCompare(String(b?.revAuthor||'')),
|
||||
bonusEventTotal:events=>Object.values(events||{}).reduce((sum,value)=>sum+(value||0),0)
|
||||
};
|
||||
vm.createContext(context);
|
||||
vm.runInContext(`${functionSource('mergeGlobalRecords')}\nthis.mergeGlobalRecords=mergeGlobalRecords;`,context);
|
||||
const merged=context.mergeGlobalRecords(
|
||||
{worldEpoch:'world:test',globalRev:30,globalRevAuthor:'remote',cursorStyle:'remote',nextId:8,clockFloor:11,cloudRevision:3,bonusEvents:{A:4},quarantine:{B:{failedAt:5}},lastSolveAt:10,specialMechanicsSeen:['warp'],combo:2},
|
||||
{worldEpoch:'world:test',globalRev:20,globalRevAuthor:'local',cursorStyle:'local',nextId:12,clockFloor:15,cloudRevision:9,bonusEvents:{A:2,C:7},quarantine:{B:{failedAt:8}},lastSolveAt:14,specialMechanicsSeen:['lock'],combo:4}
|
||||
);
|
||||
assert(merged.cursorStyle==='remote'&&merged.nextId===12&&merged.clockFloor===15&&merged.cloudRevision===9,
|
||||
'Global LWW and monotonic fields were not merged independently');
|
||||
assert(merged.bonusEvents.A===4&&merged.bonusEvents.C===7&&merged.bonusScore===11,
|
||||
'Global additive bonus records were lost during a concurrent merge');
|
||||
assert(merged.quarantine.B.failedAt===8&&merged.lastSolveAt===14&&!Object.prototype.hasOwnProperty.call(merged,'combo')&&merged.specialMechanicsSeen.join(',')==='lock,warp',
|
||||
'Global timestamped records, encounter memory, or retired combo data were merged incorrectly');
|
||||
}
|
||||
|
||||
async function verifySignalRetry(){
|
||||
const timers=[];let attempts=0,remembered=0;
|
||||
const context={
|
||||
console:{warn:()=>{}},sessionId:'self',worldInitReady:false,deferredWorldSignals:[],
|
||||
seenWorldCommitIds:new Set(),pendingWorldCommitIds:new Set(),syncQueue:Promise.resolve(),
|
||||
worldCommitId:signal=>signal.commitId||'',applyWorldSignal:async()=>{attempts++;if(attempts===1)throw new Error('transient')},
|
||||
rememberWorldCommit:()=>remembered++,
|
||||
setTimeout:callback=>{timers.push(callback);return timers.length}
|
||||
};
|
||||
vm.createContext(context);
|
||||
vm.runInContext(`${functionSource('queueWorldSignal')}\n${functionSource('drainWorldSignals')}\nthis.queueWorldSignal=queueWorldSignal;this.drainWorldSignals=drainWorldSignals;`,context);
|
||||
const signal={commitId:'remote:1',sessionId:'remote'};
|
||||
context.queueWorldSignal(signal);
|
||||
assert(context.deferredWorldSignals.length===1&&attempts===0,'A startup signal ran before initialization');
|
||||
context.drainWorldSignals();context.queueWorldSignal(signal);
|
||||
await context.syncQueue;
|
||||
assert(attempts===1&&remembered===0&&timers.length===1,
|
||||
'A failed signal was deduplicated as if it had succeeded');
|
||||
timers.shift()();await Promise.resolve();await context.syncQueue;
|
||||
assert(attempts===2&&remembered===1,'A transient signal failure was not retried and committed once');
|
||||
}
|
||||
|
||||
async function verifyDurableBackup(){
|
||||
const source=functionSource('preserveRecoveryDurably');
|
||||
const failing={
|
||||
Date,safeLocalSet:()=>false,sessionStorage:{setItem(){throw new Error('blocked')}},
|
||||
persistRecoveryEnvelope:async()=>false,volatileRecovery:null,recoveryStorageKey:'recovery'
|
||||
};
|
||||
vm.createContext(failing);vm.runInContext(`${source}\nthis.preserveRecoveryDurably=preserveRecoveryDurably;`,failing);
|
||||
let rejected=false;try{await failing.preserveRecoveryDurably('save','reset')}catch(_){rejected=true}
|
||||
assert(rejected,'Destructive replacement proceeded without any durable backup');
|
||||
const database={
|
||||
Date,safeLocalSet:()=>false,sessionStorage:{setItem(){throw new Error('blocked')}},
|
||||
persistRecoveryEnvelope:async()=>true,volatileRecovery:null,recoveryStorageKey:'recovery'
|
||||
};
|
||||
vm.createContext(database);vm.runInContext(`${source}\nthis.preserveRecoveryDurably=preserveRecoveryDurably;`,database);
|
||||
const result=await database.preserveRecoveryDurably('save','reset');
|
||||
assert(!result.browserCopy&&result.databaseCopy,'A verified database-only backup was not accepted');
|
||||
}
|
||||
|
||||
function verifyRevisionSeed(){
|
||||
const context={
|
||||
lastRevision:3,data:{},recoveredDeletionTombstones:new Map([['B9',{rev:81}]])
|
||||
};
|
||||
vm.createContext(context);
|
||||
vm.runInContext(`${functionSource('seedRevisionClock')}\nthis.seedRevisionClock=seedRevisionClock;`,context);
|
||||
const maximum=context.seedRevisionClock({
|
||||
globalRev:50,clockFloor:0,metas:{B0:{rev:60}},states:{B0:{rev:70}}
|
||||
});
|
||||
assert(maximum===81&&context.lastRevision===81,'The revision clock was not seeded from all persisted row types');
|
||||
}
|
||||
|
||||
(async()=>{
|
||||
await verifyPersistenceConflicts();
|
||||
verifyGlobalMerge();
|
||||
await verifySignalRetry();
|
||||
await verifyDurableBackup();
|
||||
verifyRevisionSeed();
|
||||
console.log('Concurrency and replacement safety test passed');
|
||||
})().catch(error=>{console.error(error);process.exitCode=1});
|
||||
Loading…
Add table
Add a link
Reference in a new issue