-
Notifications
You must be signed in to change notification settings - Fork 7
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Deprecate VxScan's existing backup functionality in favor of "extended" CVR exports #4011
Changes from 1 commit
f01ae9a
0e8d42c
da36836
4bc4f60
fb2ea1c
c30f068
a74a6ef
455d5fc
f4537fa
7dcf49a
64f051f
fb4deb7
3856417
8f7d6e1
9615ad3
4e2090f
6731e39
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -26,14 +26,16 @@ import { | |
safeParseSystemSettings, | ||
AdjudicationReason, | ||
} from '@votingworks/types'; | ||
import { assertDefined, Optional } from '@votingworks/basics'; | ||
import { assert, assertDefined, Optional } from '@votingworks/basics'; | ||
import * as fs from 'fs-extra'; | ||
import { sha256 } from 'js-sha256'; | ||
import { DateTime } from 'luxon'; | ||
import { join } from 'path'; | ||
import { v4 as uuid } from 'uuid'; | ||
import { | ||
ResultSheet, | ||
AcceptedSheet, | ||
RejectedSheet, | ||
Sheet, | ||
UiStringsStore, | ||
createUiStringStore, | ||
} from '@votingworks/backend'; | ||
|
@@ -49,41 +51,39 @@ const debug = rootDebug.extend('store'); | |
|
||
const SchemaPath = join(__dirname, '../schema.sql'); | ||
|
||
function dateTimeFromNoOffsetSqliteDate(noOffsetSqliteDate: string): DateTime { | ||
return DateTime.fromFormat(noOffsetSqliteDate, 'yyyy-MM-dd HH:mm:ss', { | ||
zone: 'GMT', | ||
}); | ||
} | ||
|
||
const getResultSheetsBaseQuery = ` | ||
const getSheetsBaseQuery = ` | ||
select | ||
sheets.id as id, | ||
batches.id as batchId, | ||
batches.label as batchLabel, | ||
front_interpretation_json as frontInterpretationJson, | ||
back_interpretation_json as backInterpretationJson, | ||
front_image_path as frontImagePath, | ||
back_image_path as backImagePath | ||
back_image_path as backImagePath, | ||
requires_adjudication as requiresAdjudication, | ||
finished_adjudication_at as finishedAdjudicationAt, | ||
sheets.deleted_at as deletedAt | ||
from sheets left join batches on | ||
sheets.batch_id = batches.id | ||
where | ||
(requires_adjudication = 0 or finished_adjudication_at is not null) | ||
and sheets.deleted_at is null | ||
and batches.deleted_at is null | ||
`; | ||
|
||
interface ResultSheetRow { | ||
interface SheetRow { | ||
id: string; | ||
batchId: string; | ||
batchLabel: string | null; | ||
frontInterpretationJson: string; | ||
backInterpretationJson: string; | ||
frontImagePath: string; | ||
backImagePath: string; | ||
requiresAdjudication: 0 | 1; | ||
finishedAdjudicationAt: Iso8601Timestamp | null; | ||
deletedAt: Iso8601Timestamp | null; | ||
} | ||
|
||
function resultSheetRowToResultSheet(row: ResultSheetRow): ResultSheet { | ||
function sheetRowToAcceptedSheet(row: SheetRow): AcceptedSheet { | ||
assert(row.deletedAt === null); | ||
return { | ||
type: 'accepted', | ||
id: row.id, | ||
batchId: row.batchId, | ||
batchLabel: row.batchLabel ?? undefined, | ||
|
@@ -96,6 +96,28 @@ function resultSheetRowToResultSheet(row: ResultSheetRow): ResultSheet { | |
}; | ||
} | ||
|
||
function sheetRowToRejectedSheet(row: SheetRow): RejectedSheet { | ||
assert(row.deletedAt !== null); | ||
return { | ||
type: 'rejected', | ||
id: row.id, | ||
frontImagePath: row.frontImagePath, | ||
backImagePath: row.backImagePath, | ||
}; | ||
} | ||
|
||
function sheetRowToSheet(row: SheetRow): Sheet { | ||
assert( | ||
row.requiresAdjudication === 0 || | ||
row.finishedAdjudicationAt !== null || | ||
row.deletedAt !== null, | ||
'Every sheet requiring review should have been either accepted or rejected' | ||
); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This condition is guaranteed to hold given transactions in a subsequent commit |
||
return row.deletedAt === null | ||
? sheetRowToAcceptedSheet(row) | ||
: sheetRowToRejectedSheet(row); | ||
} | ||
|
||
/** | ||
* Manages a data store for imported ballot image batches and cast vote records | ||
* interpreted by reading the sheets. | ||
|
@@ -466,68 +488,6 @@ export class Store { | |
return ongoingBatchRow?.id; | ||
} | ||
|
||
/** | ||
* Records that batches have been backed up. | ||
*/ | ||
setScannerBackedUp(backedUp = true): void { | ||
if (!this.hasElection()) { | ||
throw new Error('Unconfigured scanner cannot be backed up.'); | ||
} | ||
|
||
if (backedUp) { | ||
this.client.run( | ||
'update election set scanner_backed_up_at = current_timestamp' | ||
); | ||
} else { | ||
this.client.run('update election set scanner_backed_up_at = null'); | ||
} | ||
} | ||
|
||
/** | ||
* Records that CVRs have been backed up. | ||
*/ | ||
setCvrsBackedUp(backedUp = true): void { | ||
if (!this.hasElection()) { | ||
throw new Error('Unconfigured scanner cannot export CVRs.'); | ||
} | ||
|
||
if (backedUp) { | ||
this.client.run( | ||
'update election set cvrs_backed_up_at = current_timestamp' | ||
); | ||
} else { | ||
this.client.run('update election set cvrs_backed_up_at = null'); | ||
} | ||
} | ||
|
||
/** | ||
* Gets the timestamp for the last scanner backup | ||
*/ | ||
getScannerBackupTimestamp(): DateTime | undefined { | ||
const row = this.client.one( | ||
'select scanner_backed_up_at as scannerBackedUpAt from election' | ||
) as { scannerBackedUpAt: string } | undefined; | ||
if (!row?.scannerBackedUpAt) { | ||
return undefined; | ||
} | ||
|
||
return dateTimeFromNoOffsetSqliteDate(row?.scannerBackedUpAt); | ||
} | ||
|
||
/** | ||
* Gets the timestamp for the last cvr export | ||
*/ | ||
getCvrsBackupTimestamp(): DateTime | undefined { | ||
const row = this.client.one( | ||
'select cvrs_backed_up_at as cvrsBackedUpAt from election' | ||
) as { cvrsBackedUpAt: string } | undefined; | ||
if (!row?.cvrsBackedUpAt) { | ||
return undefined; | ||
} | ||
|
||
return dateTimeFromNoOffsetSqliteDate(row?.cvrsBackedUpAt); | ||
} | ||
|
||
getBallotsCounted(): number { | ||
const row = this.client.one(` | ||
select | ||
|
@@ -545,64 +505,6 @@ export class Store { | |
return row?.ballotsCounted ?? 0; | ||
} | ||
|
||
/** | ||
* Returns whether the appropriate backups have been completed and it is safe | ||
* to unconfigure a machine / reset the election session. Always returns | ||
* true in test mode. | ||
*/ | ||
getCanUnconfigure(): boolean { | ||
Comment on lines
-548
to
-553
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. OK, so now that we have continuous backup you can always reconfigure because it's always backed up. Yes? When the USB is out of sync and needs to be updated it shows a warning and you click a button to sync. But IIRC it shows the prompt via the voter screen, and the election manager screens are still accessible normally with authentication. So does this mean that you could have the "out-of-sync" warning, insert an Election Manager card, and unconfigure anyway? I haven't confirmed this is actually possible so may just be missing something. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This line of reasoning is all correct - it is possible to access the PW, EM, and SA screens when the sync prompt is up on the voter screen. I'd been thinking about how worth it it was to prevent unconfiguring while in this fairly rare state. Things are complicated by the fact that the USB likely won't be in the machine come time to unconfigure, since it'll have been taken to VxAdmin for tabulation. And we don't display the sync prompt when there's no USB in the machine at all. Which brings us back to the need for some separate piece of store state if we want to fully track this. I'm leaning toward disabling the PW polls-close button and the EM delete-election-data button when in the sync-required state, as the simplest possible safeguard. Yes, an EM could still remove the USB and then delete election data (since the EM screen is still accessible when there's no USB). But a PW couldn't remove the USB and then close polls (since the PW screen is not accessible when there's no USB). So if a machine has had its polls closed, it's guaranteed to be backed up. I think that this covers our bases well but can double check with Roe, as well! There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think it would be good to have @mattroe think this through too, but your explanation makes sense to me. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 👍 Posted to Slack There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. And got confirmation that we're good to go with the edits that I've proposed in this thread ✅ There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This thread made me realize that there are quite a few operations that we need to block when a CVR sync is detected as necessary:
This video demonstrates all of the above: checks-everywhere.movWhile I was at it, I also had to tweak the flow for switching from official mode to test mode when ballots have been counted, which used to direct users to the "Save Backup" button. I've swapped that modal out with a simple confirmation modal: switch-mode.mov |
||
// Always allow in test mode | ||
if (this.getTestMode()) { | ||
return true; | ||
} | ||
|
||
// Allow if no ballots have been counted | ||
if (!this.getBallotsCounted()) { | ||
return true; | ||
} | ||
|
||
const scannerBackedUpAt = this.getScannerBackupTimestamp(); | ||
|
||
// Require that a scanner backup has taken place | ||
if (!scannerBackedUpAt) { | ||
return false; | ||
} | ||
|
||
// Adding or deleting sheets would have updated the CVR count | ||
const { maxSheetsCreatedAt, maxSheetsDeletedAt } = this.client.one(` | ||
select | ||
max(created_at) as maxSheetsCreatedAt, | ||
max(deleted_at) as maxSheetsDeletedAt | ||
from sheets | ||
`) as { | ||
maxSheetsCreatedAt: string; | ||
maxSheetsDeletedAt: string; | ||
}; | ||
|
||
// Deleting non-empty batches would have updated the CVR count | ||
const { maxBatchesDeletedAt } = this.client.one(` | ||
select | ||
max(batches.deleted_at) as maxBatchesDeletedAt | ||
from batches inner join sheets | ||
on sheets.batch_id = batches.id | ||
where sheets.deleted_at is null | ||
`) as { | ||
maxBatchesDeletedAt: string; | ||
}; | ||
|
||
const cvrsLastUpdatedDates = [ | ||
maxBatchesDeletedAt, | ||
maxSheetsCreatedAt, | ||
maxSheetsDeletedAt, | ||
] | ||
.filter(Boolean) | ||
.map((noOffsetSqliteDate) => | ||
dateTimeFromNoOffsetSqliteDate(noOffsetSqliteDate) | ||
); | ||
|
||
return scannerBackedUpAt >= DateTime.max(...cvrsLastUpdatedDates); | ||
} | ||
|
||
/** | ||
* Adds a sheet to an existing batch. | ||
*/ | ||
|
@@ -681,8 +583,6 @@ export class Store { | |
this.client.transaction(() => { | ||
this.setPollsState('polls_closed_initial'); | ||
this.setBallotCountWhenBallotBagLastReplaced(0); | ||
this.setCvrsBackedUp(false); | ||
this.setScannerBackedUp(false); | ||
}); | ||
} | ||
// delete batches, which will cascade delete sheets | ||
|
@@ -741,35 +641,6 @@ export class Store { | |
debug('no review sheets requiring adjudication'); | ||
} | ||
|
||
*getSheets(): Generator<{ | ||
id: string; | ||
frontImagePath: string; | ||
backImagePath: string; | ||
exportedAsCvrAt: Iso8601Timestamp; | ||
}> { | ||
for (const { id, frontImagePath, backImagePath, exportedAsCvrAt } of this | ||
.client.each(` | ||
select | ||
id, | ||
front_image_path as frontImagePath, | ||
back_image_path as backImagePath | ||
from sheets | ||
order by created_at asc | ||
`) as Iterable<{ | ||
id: string; | ||
frontImagePath: string; | ||
backImagePath: string; | ||
exportedAsCvrAt: Iso8601Timestamp; | ||
}>) { | ||
yield { | ||
id, | ||
frontImagePath, | ||
backImagePath, | ||
exportedAsCvrAt, | ||
}; | ||
} | ||
} | ||
|
||
adjudicateSheet(sheetId: string): boolean { | ||
debug('finishing adjudication for sheet %s', sheetId); | ||
|
||
|
@@ -888,23 +759,42 @@ export class Store { | |
} | ||
|
||
/** | ||
* Yields all sheets in the database that would be included in a CVR export. | ||
* Yields all scanned sheets that were accepted and should be tabulated | ||
*/ | ||
*forEachAcceptedSheet(): Generator<AcceptedSheet> { | ||
const sql = `${getSheetsBaseQuery} | ||
where | ||
batches.deleted_at is null and | ||
sheets.deleted_at is null and | ||
(requires_adjudication = 0 or finished_adjudication_at is not null) | ||
order by sheets.id | ||
`; | ||
for (const row of this.client.each(sql) as Iterable<SheetRow>) { | ||
yield sheetRowToAcceptedSheet(row); | ||
} | ||
} | ||
|
||
/** | ||
* Yields all scanned sheets | ||
*/ | ||
*forEachResultSheet(): Generator<ResultSheet> { | ||
const sql = `${getResultSheetsBaseQuery} order by sheets.id`; | ||
for (const row of this.client.each(sql) as Iterable<ResultSheetRow>) { | ||
yield resultSheetRowToResultSheet(row); | ||
*forEachSheet(): Generator<Sheet> { | ||
const sql = `${getSheetsBaseQuery} | ||
where | ||
batches.deleted_at is null | ||
order by sheets.id | ||
`; | ||
for (const row of this.client.each(sql) as Iterable<SheetRow>) { | ||
yield sheetRowToSheet(row); | ||
} | ||
} | ||
|
||
/** | ||
* Gets a single sheet given a sheet ID. Returns undefined if the sheet doesn't exist or wouldn't | ||
* be included in a CVR export. | ||
* Gets a sheet given a sheet ID. Returns undefined if the sheet doesn't exist. | ||
*/ | ||
getResultSheet(sheetId: string): ResultSheet | undefined { | ||
const sql = `${getResultSheetsBaseQuery} and sheets.id = ?`; | ||
const row = this.client.one(sql, sheetId) as ResultSheetRow | undefined; | ||
return row ? resultSheetRowToResultSheet(row) : undefined; | ||
getSheet(sheetId: string): Sheet | undefined { | ||
const sql = `${getSheetsBaseQuery} where sheets.id = ?`; | ||
const row = this.client.one(sql, sheetId) as SheetRow | undefined; | ||
return row ? sheetRowToSheet(row) : undefined; | ||
} | ||
|
||
/** | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not only can we delete logic for the old-style of backup; we can also delete logic for the check that prevents unconfiguring a machine until you've backed. Continuous export makes it such that you're essentially always backed up