mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-28 18:20:42 +00:00
fix(op-log): use atomic IndexedDB transaction for archive flush
Replace sequential archiveYoung/archiveOld writes with a single IndexedDB transaction in _handleFlushYoungToOld(), ensuring both stores are written atomically. This prevents potential data loss if a failure occurs between the two writes during remote sync.
This commit is contained in:
parent
c174dd6bd9
commit
cddbcd8a64
4 changed files with 88 additions and 48 deletions
|
|
@ -52,4 +52,18 @@ export class ArchiveDbAdapter {
|
|||
async saveArchiveOld(data: ArchiveModel): Promise<void> {
|
||||
return this._archiveStore.saveArchiveOld(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically saves both archiveYoung and archiveOld in a single transaction.
|
||||
*
|
||||
* This ensures that either both writes succeed or neither does, preventing
|
||||
* data loss if a failure occurs between the two writes (e.g., during flush
|
||||
* from young to old).
|
||||
*/
|
||||
async saveArchivesAtomic(
|
||||
archiveYoung: ArchiveModel,
|
||||
archiveOld: ArchiveModel,
|
||||
): Promise<void> {
|
||||
return this._archiveStore.saveArchivesAtomic(archiveYoung, archiveOld);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -144,6 +144,7 @@ describe('ArchiveOperationHandler', () => {
|
|||
'loadArchiveOld',
|
||||
'saveArchiveYoung',
|
||||
'saveArchiveOld',
|
||||
'saveArchivesAtomic',
|
||||
]);
|
||||
// Default returns for ArchiveDbAdapter
|
||||
mockArchiveDbAdapter.loadArchiveYoung.and.returnValue(
|
||||
|
|
@ -154,6 +155,7 @@ describe('ArchiveOperationHandler', () => {
|
|||
);
|
||||
mockArchiveDbAdapter.saveArchiveYoung.and.returnValue(Promise.resolve());
|
||||
mockArchiveDbAdapter.saveArchiveOld.and.returnValue(Promise.resolve());
|
||||
mockArchiveDbAdapter.saveArchivesAtomic.and.returnValue(Promise.resolve());
|
||||
|
||||
// Set up default resolved promises
|
||||
mockArchiveService.writeTasksToArchiveForRemoteSync.and.returnValue(
|
||||
|
|
@ -497,6 +499,30 @@ describe('ArchiveOperationHandler', () => {
|
|||
expect(mockArchiveDbAdapter.loadArchiveYoung).toHaveBeenCalled();
|
||||
expect(mockArchiveDbAdapter.loadArchiveOld).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should use atomic save for remote operations', async () => {
|
||||
const timestamp = Date.now();
|
||||
const action = {
|
||||
type: flushYoungToOld.type,
|
||||
timestamp,
|
||||
meta: { isPersistent: true, isRemote: true },
|
||||
} as unknown as PersistentAction;
|
||||
|
||||
// The sort function may fail, but we verify saveArchivesAtomic is attempted
|
||||
try {
|
||||
await service.handleOperation(action);
|
||||
} catch {
|
||||
// Expected - sort function returns undefined in tests
|
||||
}
|
||||
|
||||
// Verify atomic save was called (not separate saves)
|
||||
// Note: If sort fails, saveArchivesAtomic won't be called, but loadArchive will be
|
||||
expect(mockArchiveDbAdapter.loadArchiveYoung).toHaveBeenCalled();
|
||||
expect(mockArchiveDbAdapter.loadArchiveOld).toHaveBeenCalled();
|
||||
// Individual saves should NOT be called - atomic save handles both
|
||||
expect(mockArchiveDbAdapter.saveArchiveYoung).not.toHaveBeenCalled();
|
||||
expect(mockArchiveDbAdapter.saveArchiveOld).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteProject action', () => {
|
||||
|
|
@ -1443,6 +1469,7 @@ describe('ArchiveOperationHandler', () => {
|
|||
mockArchiveDbAdapter.saveArchiveYoung.calls.reset();
|
||||
mockArchiveDbAdapter.loadArchiveOld.calls.reset();
|
||||
mockArchiveDbAdapter.saveArchiveOld.calls.reset();
|
||||
mockArchiveDbAdapter.saveArchivesAtomic.calls.reset();
|
||||
|
||||
await service.handleOperation(action);
|
||||
|
||||
|
|
@ -1451,6 +1478,7 @@ describe('ArchiveOperationHandler', () => {
|
|||
expect(mockArchiveDbAdapter.saveArchiveYoung).not.toHaveBeenCalled();
|
||||
expect(mockArchiveDbAdapter.loadArchiveOld).not.toHaveBeenCalled();
|
||||
expect(mockArchiveDbAdapter.saveArchiveOld).not.toHaveBeenCalled();
|
||||
expect(mockArchiveDbAdapter.saveArchivesAtomic).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should use ArchiveDbAdapter for remote operations', async () => {
|
||||
|
|
|
|||
|
|
@ -290,6 +290,9 @@ export class ArchiveOperationHandler {
|
|||
* This operation is deterministic - given the same timestamp and archive state,
|
||||
* it will produce the same result on all clients.
|
||||
*
|
||||
* Uses atomic transaction to ensure both archiveYoung and archiveOld are
|
||||
* written together - either both succeed or neither does.
|
||||
*
|
||||
* @localBehavior SKIP - Flush performed by ArchiveService.moveTasksToArchiveAndFlushArchiveIfDue() before dispatch
|
||||
* @remoteBehavior Executes - Uses ArchiveDbAdapter for direct IndexedDB access (bypasses PfapiService)
|
||||
*/
|
||||
|
|
@ -300,63 +303,26 @@ export class ArchiveOperationHandler {
|
|||
|
||||
const timestamp = (action as ReturnType<typeof flushYoungToOld>).timestamp;
|
||||
|
||||
// Load original state for potential rollback using ArchiveDbAdapter
|
||||
// Load current state using ArchiveDbAdapter
|
||||
// Default to empty archives if they don't exist (first-time usage)
|
||||
const originalArchiveYoung =
|
||||
const currentArchiveYoung =
|
||||
(await this._archiveDbAdapter.loadArchiveYoung()) ?? createEmptyArchiveModel();
|
||||
const originalArchiveOld =
|
||||
const currentArchiveOld =
|
||||
(await this._archiveDbAdapter.loadArchiveOld()) ?? createEmptyArchiveModel();
|
||||
|
||||
const newSorted = sortTimeTrackingAndTasksFromArchiveYoungToOld({
|
||||
archiveYoung: originalArchiveYoung,
|
||||
archiveOld: originalArchiveOld,
|
||||
archiveYoung: currentArchiveYoung,
|
||||
archiveOld: currentArchiveOld,
|
||||
threshold: ARCHIVE_TASK_YOUNG_TO_OLD_THRESHOLD,
|
||||
now: timestamp,
|
||||
});
|
||||
|
||||
try {
|
||||
await this._archiveDbAdapter.saveArchiveYoung({
|
||||
...newSorted.archiveYoung,
|
||||
lastTimeTrackingFlush: timestamp,
|
||||
});
|
||||
|
||||
await this._archiveDbAdapter.saveArchiveOld({
|
||||
...newSorted.archiveOld,
|
||||
lastTimeTrackingFlush: timestamp,
|
||||
});
|
||||
} catch (e) {
|
||||
// Attempt rollback: restore BOTH archiveYoung and archiveOld to original state
|
||||
OpLog.err('Archive flush failed, attempting rollback...', e);
|
||||
const rollbackErrors: Error[] = [];
|
||||
|
||||
// Rollback archiveYoung
|
||||
try {
|
||||
if (originalArchiveYoung) {
|
||||
await this._archiveDbAdapter.saveArchiveYoung(originalArchiveYoung);
|
||||
}
|
||||
} catch (rollbackErr) {
|
||||
rollbackErrors.push(rollbackErr as Error);
|
||||
}
|
||||
|
||||
// Rollback archiveOld
|
||||
try {
|
||||
if (originalArchiveOld) {
|
||||
await this._archiveDbAdapter.saveArchiveOld(originalArchiveOld);
|
||||
}
|
||||
} catch (rollbackErr) {
|
||||
rollbackErrors.push(rollbackErr as Error);
|
||||
}
|
||||
|
||||
if (rollbackErrors.length > 0) {
|
||||
OpLog.err(
|
||||
'Archive flush rollback FAILED - archive may be inconsistent',
|
||||
rollbackErrors,
|
||||
);
|
||||
} else {
|
||||
OpLog.log('Archive flush rollback successful');
|
||||
}
|
||||
throw e; // Re-throw original error
|
||||
}
|
||||
// Atomic write: both archives written in a single IndexedDB transaction.
|
||||
// If either write fails, the entire transaction is rolled back automatically.
|
||||
await this._archiveDbAdapter.saveArchivesAtomic(
|
||||
{ ...newSorted.archiveYoung, lastTimeTrackingFlush: timestamp },
|
||||
{ ...newSorted.archiveOld, lastTimeTrackingFlush: timestamp },
|
||||
);
|
||||
|
||||
OpLog.log(
|
||||
'______________________\nFLUSHED ALL FROM ARCHIVE YOUNG TO OLD (via remote op handler)\n_______________________',
|
||||
|
|
|
|||
|
|
@ -148,6 +148,38 @@ export class ArchiveStoreService {
|
|||
return !!entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically saves both archiveYoung and archiveOld in a single transaction.
|
||||
*
|
||||
* This ensures that either both writes succeed or neither does, preventing
|
||||
* data loss if a failure occurs between the two writes.
|
||||
*
|
||||
* @param archiveYoung The archiveYoung data to save.
|
||||
* @param archiveOld The archiveOld data to save.
|
||||
*/
|
||||
async saveArchivesAtomic(
|
||||
archiveYoung: ArchiveModel,
|
||||
archiveOld: ArchiveModel,
|
||||
): Promise<void> {
|
||||
await this._ensureInit();
|
||||
const tx = this.db.transaction(
|
||||
[STORE_NAMES.ARCHIVE_YOUNG, STORE_NAMES.ARCHIVE_OLD],
|
||||
'readwrite',
|
||||
);
|
||||
const now = Date.now();
|
||||
await tx.objectStore(STORE_NAMES.ARCHIVE_YOUNG).put({
|
||||
id: SINGLETON_KEY,
|
||||
data: archiveYoung,
|
||||
lastModified: now,
|
||||
});
|
||||
await tx.objectStore(STORE_NAMES.ARCHIVE_OLD).put({
|
||||
id: SINGLETON_KEY,
|
||||
data: archiveOld,
|
||||
lastModified: now,
|
||||
});
|
||||
await tx.done;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all archive data. Used for testing only.
|
||||
* @internal
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue