mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
fix(archive): write compressed archives in a single atomic transaction (#8862)
compressArchive() persisted archiveYoung and archiveOld via two independent writes (Promise.all). A crash between them left a half-compressed archive, and because compression is op-replayed on other clients, a torn local result diverged from replicas. Route both writes through the existing saveArchivesAtomic API (one IndexedDB transaction over both stores), matching the flush-young-to-old path in archive-operation-handler.service.ts. Adds a regression spec. #8843
This commit is contained in:
parent
6236dc7458
commit
5edb659a65
2 changed files with 68 additions and 4 deletions
63
src/app/features/archive/archive-compression.service.spec.ts
Normal file
63
src/app/features/archive/archive-compression.service.spec.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { TestBed } from '@angular/core/testing';
|
||||
import { ArchiveCompressionService } from './archive-compression.service';
|
||||
import { ArchiveDbAdapter } from '../../core/persistence/archive-db-adapter.service';
|
||||
import { ArchiveModel } from './archive.model';
|
||||
|
||||
const emptyArchive = (): ArchiveModel => ({
|
||||
task: { ids: [], entities: {} },
|
||||
timeTracking: { project: {}, tag: {} },
|
||||
lastTimeTrackingFlush: 0,
|
||||
});
|
||||
|
||||
describe('ArchiveCompressionService', () => {
|
||||
let service: ArchiveCompressionService;
|
||||
let archiveDbAdapterMock: jasmine.SpyObj<ArchiveDbAdapter>;
|
||||
|
||||
beforeEach(() => {
|
||||
archiveDbAdapterMock = jasmine.createSpyObj<ArchiveDbAdapter>('ArchiveDbAdapter', [
|
||||
'loadArchiveYoung',
|
||||
'loadArchiveOld',
|
||||
'saveArchiveYoung',
|
||||
'saveArchiveOld',
|
||||
'saveArchivesAtomic',
|
||||
]);
|
||||
archiveDbAdapterMock.loadArchiveYoung.and.resolveTo(emptyArchive());
|
||||
archiveDbAdapterMock.loadArchiveOld.and.resolveTo(emptyArchive());
|
||||
archiveDbAdapterMock.saveArchiveYoung.and.resolveTo(undefined);
|
||||
archiveDbAdapterMock.saveArchiveOld.and.resolveTo(undefined);
|
||||
archiveDbAdapterMock.saveArchivesAtomic.and.resolveTo(undefined);
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
ArchiveCompressionService,
|
||||
{ provide: ArchiveDbAdapter, useValue: archiveDbAdapterMock },
|
||||
],
|
||||
});
|
||||
|
||||
service = TestBed.inject(ArchiveCompressionService);
|
||||
});
|
||||
|
||||
// Issue #8843: compressArchive wrote archiveYoung and archiveOld as two
|
||||
// independent transactions. A crash between them left a half-compressed
|
||||
// archive, and since compression is op-replayed on other clients, a torn
|
||||
// local result diverged from replicas. Both archives must be written in a
|
||||
// single atomic transaction via the existing saveArchivesAtomic API.
|
||||
describe('compressArchive atomic write (#8843)', () => {
|
||||
it('persists both archives via saveArchivesAtomic', async () => {
|
||||
await service.compressArchive(Date.now());
|
||||
|
||||
expect(archiveDbAdapterMock.saveArchivesAtomic).toHaveBeenCalledTimes(1);
|
||||
const [youngArg, oldArg] =
|
||||
archiveDbAdapterMock.saveArchivesAtomic.calls.mostRecent().args;
|
||||
expect(youngArg.task).toEqual({ ids: [], entities: {} });
|
||||
expect(oldArg.task).toEqual({ ids: [], entities: {} });
|
||||
});
|
||||
|
||||
it('does not write the archives as two independent transactions', async () => {
|
||||
await service.compressArchive(Date.now());
|
||||
|
||||
expect(archiveDbAdapterMock.saveArchiveYoung).not.toHaveBeenCalled();
|
||||
expect(archiveDbAdapterMock.saveArchiveOld).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -82,10 +82,11 @@ export class ArchiveCompressionService {
|
|||
const newArchiveYoung = this._compressArchiveData(archiveYoung, oneYearAgoTimestamp);
|
||||
const newArchiveOld = this._compressArchiveData(archiveOld, oneYearAgoTimestamp);
|
||||
|
||||
await Promise.all([
|
||||
this._archiveDbAdapter.saveArchiveYoung(newArchiveYoung),
|
||||
this._archiveDbAdapter.saveArchiveOld(newArchiveOld),
|
||||
]);
|
||||
// Atomic write: both archives written in a single IndexedDB transaction.
|
||||
// Two independent writes could tear on a crash between them, and since
|
||||
// compression is op-replayed on other clients a half-compressed local
|
||||
// result would diverge from replicas (#8843).
|
||||
await this._archiveDbAdapter.saveArchivesAtomic(newArchiveYoung, newArchiveOld);
|
||||
}
|
||||
|
||||
private _compressArchiveData(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue