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:
John Costa 2026-07-08 10:54:30 -07:00 committed by GitHub
parent 6236dc7458
commit 5edb659a65
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 68 additions and 4 deletions

View 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();
});
});
});

View file

@ -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(