Skip to content

Commit

Permalink
Initial Implementation of Multi-Node data syncing (#45)
Browse files Browse the repository at this point in the history
* single machine, basic data persistence

* multi node syncing

* basic undo integration
  • Loading branch information
carolinemodic authored Jan 15, 2025
1 parent 5a81050 commit 0194961
Show file tree
Hide file tree
Showing 12 changed files with 737 additions and 205 deletions.
20 changes: 20 additions & 0 deletions backend/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
const shared = require('../jest.config.shared');

/**
* @type {import('@jest/types').Config.InitialOptions}
*/
module.exports = {
...shared,
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>/src/setupTests.ts'],
coverageThreshold: {
global: {
lines: 0,
branches: 0,
},
},
prettierPath: null,
transformIgnorePatterns: [
'[/\\\\]node_modules[/\\\\].+\\.(js|jsx|mjs|cjs|ts|tsx)$',
],
};
16 changes: 12 additions & 4 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,9 @@
"pre-commit": "lint-staged",
"start": "node ./build/index.js",
"test": "is-ci test:ci test:watch",
"test:ci": "TZ=America/Anchorage vitest run --coverage",
"test:coverage": "TZ=America/Anchorage vitest --coverage",
"test:watch": "TZ=America/Anchorage vitest",
"test:ci": "TZ=America/Anchorage jest run --coverage",
"test:coverage": "TZ=America/Anchorage jest --coverage",
"test:watch": "TZ=America/Anchorage jest --watch",
"type-check": "tsc --build"
},
"dependencies": {
Expand Down Expand Up @@ -58,9 +58,11 @@
"devDependencies": {
"@jest/globals": "^29.6.2",
"@jest/types": "^29.6.1",
"@testing-library/react": "^15.0.7",
"@types/debug": "4.1.8",
"@types/express": "4.17.14",
"@types/fs-extra": "11.0.1",
"@types/jest": "^29.5.3",
"@types/node": "20.16.0",
"@types/react": "18.3.3",
"@types/styled-components": "^5.1.26",
Expand All @@ -70,10 +72,16 @@
"esbuild-runner": "2.2.2",
"eslint-plugin-vx": "workspace:*",
"is-ci-cli": "2.2.0",
"jest": "^29.6.2",
"jest-environment-jsdom": "^29.6.2",
"jest-junit": "^16.0.0",
"jest-styled-components": "^7.1.1",
"jest-watch-typeahead": "^2.2.2",
"lint-staged": "11.0.0",
"nodemon": "^3.1.7",
"sort-package-json": "^1.50.0",
"tmp": "^0.2.1"
"tmp": "^0.2.1",
"ts-jest": "29.1.1"
},
"engines": {
"node": ">= 12"
Expand Down
19 changes: 19 additions & 0 deletions backend/schema.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
CREATE TABLE voters (
voter_id TEXT PRIMARY KEY,
voter_data TEXT not null
);

CREATE TABLE elections (
election_id TEXT PRIMARY KEY,
election_data TEXT not null
);

CREATE TABLE event_log (
event_id INTEGER,
machine_id TEXT,
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
event_type TEXT, -- e.g., "check_in", "undo_check_in"
voter_id TEXT, -- voter_id of the voter involved in the event, if any
event_data TEXT not null, -- JSON data for additional details associated with the event (id type used for check in, etc.)
PRIMARY KEY (event_id, machine_id)
);
99 changes: 76 additions & 23 deletions backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,10 @@ import {
DeviceStatuses,
Election,
ElectionSchema,
MachineInformation,
PollbookConnectionStatus,
PollbookEvent,
PollbookPackage,
PollBookService,
Voter,
VoterIdentificationMethod,
VoterSearchParams,
Expand Down Expand Up @@ -198,40 +200,77 @@ async function setupMachineNetworking({
process.nextTick(async () => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
for await (const _ of setInterval(NETWORK_POLLING_INTERVAL)) {
const currentElection = workspace.store.getElection();
debug('Polling network for new machines');
const services = await AvahiService.discoverHttpServices();
const previouslyConnected = workspace.store.getPollbookServicesByName();
// If there are any services that were previously connected that no longer show up in avahi
// Mark them as shut down
for (const [name, service] of Object.entries(previouslyConnected)) {
if (!services.some((s) => s.name === name)) {
debug(
'Marking %s as shut down as it is no longer published on Avahi',
name
);
service.lastSeen = new Date();
workspace.store.setPollbookServiceForName(name, {
...service,
apiClient: undefined,
status: PollbookConnectionStatus.ShutDown,
});
}
}
for (const { name, host, port } of services) {
if (name === currentNodeServiceName) {
// current machine, do not need to connect
continue;
}
const currentPollbookService =
workspace.store.getPollbookServiceForName(name);
const apiClient = currentPollbookService
? currentPollbookService.apiClient
: createApiClientForAddress(`http://${host}:${port}`);
const currentPollbookService = previouslyConnected[name];
const apiClient =
currentPollbookService && currentPollbookService.apiClient
? currentPollbookService.apiClient
: createApiClientForAddress(`http://${host}:${port}`);

try {
const retrievedMachineId = await apiClient.getMachineId();
if (currentPollbookService) {
currentPollbookService.lastSeen = new Date();
workspace.store.setPollbookServiceForName(
name,
currentPollbookService
);
} else {
debug(
'Discovered new pollbook with machineId %s on the network',
retrievedMachineId
);
const machineInformation = await apiClient.getMachineInformation();
if (
!currentElection ||
currentElection.id !== machineInformation.configuredElectionId
) {
// Only connect if the two machines are configured for the same election.
workspace.store.setPollbookServiceForName(name, {
machineId: retrievedMachineId,
machineId: machineInformation.machineId,
apiClient,
lastSeen: new Date(),
status: PollbookConnectionStatus.WrongElection,
});
continue;
}
if (
!currentPollbookService ||
currentPollbookService.status !== PollbookConnectionStatus.Connected
) {
debug(
'Establishing connection with a new pollbook service with machineId %s',
machineInformation.machineId
);
}
// Sync events from this pollbook service.
const knownMachines = workspace.store.getKnownMachinesWithEventIds();
const events = await apiClient.getEvents({
knownMachines,
});
workspace.store.saveEvents(events);

// Mark as connected so future events automatically sync.
workspace.store.setPollbookServiceForName(name, {
machineId: machineInformation.machineId,
apiClient,
lastSeen: new Date(),
status: PollbookConnectionStatus.Connected,
});
} catch (error) {
debug(`Failed to get machineId from ${name}: ${error}`);
debug(`Failed to establish connection from ${name}: ${error}`);
}
}
// Clean up stale machines
Expand Down Expand Up @@ -351,7 +390,7 @@ function buildApi(context: AppContext) {
},

undoVoterCheckIn(input: { voterId: string }): void {
store.recordUndoVoterCheckIn(input.voterId)
store.recordUndoVoterCheckIn(input.voterId);
},

getCheckInCounts(): { thisMachine: number; allMachines: number } {
Expand All @@ -361,8 +400,22 @@ function buildApi(context: AppContext) {
};
},

getMachineId(): string {
return machineId;
getMachineInformation(): MachineInformation {
const election = store.getElection();
return {
machineId,
configuredElectionId: election ? election.id : undefined,
};
},

receiveEvent(input: { pollbookEvent: PollbookEvent }): boolean {
return store.saveEvent(input.pollbookEvent);
},

getEvents(input: {
knownMachines: Record<string, number>;
}): PollbookEvent[] {
return store.getNewEvents(input.knownMachines);
},
});
}
Expand Down
5 changes: 3 additions & 2 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ function main(): Promise<number> {
);
}
const workspacePath = resolve(WORKSPACE);
const workspace = createWorkspace(workspacePath, baseLogger);

const auth = new DippedSmartCardAuth({
card:
Expand All @@ -44,17 +43,19 @@ function main(): Promise<number> {
},
logger: baseLogger,
});
const machineId = process.env.VX_MACHINE_ID || 'dev';

const logger = Logger.from(baseLogger, () => Promise.resolve('system'));
const usbDrive = detectUsbDrive(logger);
const printer = detectPrinter(logger);
const workspace = createWorkspace(workspacePath, baseLogger, machineId);

server.start({
workspace,
auth,
usbDrive,
printer,
machineId: process.env.VX_MACHINE_ID || 'dev',
machineId,
});
backupWorker.start({ workspace, usbDrive });

Expand Down
Empty file added backend/src/setupTests.ts
Empty file.
95 changes: 95 additions & 0 deletions backend/src/store.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { assert } from '@votingworks/basics';
import { Store } from './store';
import { EventType, VoterCheckInEvent } from './types';

const myMachineId = 'machine-1';
const otherMachineId = 'machine-2';

function createTestStore(): Store {
return Store.memoryStore(myMachineId);
}

function createVoterCheckInEvent(
eventId: number,
machineId: string,
voterId: string
): VoterCheckInEvent {
return {
type: EventType.VoterCheckIn,
eventId,
machineId,
timestamp: new Date().toISOString(),
voterId,
checkInData: {
timestamp: new Date().toISOString(),
identificationMethod: {
type: 'photoId',
state: 'nh',
},
machineId,
},
};
}

test('getNewEvents returns events for unknown machines', () => {
const store = createTestStore();
const event1 = createVoterCheckInEvent(1, myMachineId, 'voter-1');
const event2 = createVoterCheckInEvent(2, otherMachineId, 'voter-2');

store.saveEvent(event1);
store.saveEvent(event2);

const knownMachines: Record<string, number> = {};
const events = store.getNewEvents(knownMachines);

assert(events.length === 2);
expect(events).toEqual([event1, event2]);
});

test('getNewEvents returns events for known machines with new events', () => {
const store = createTestStore();
const event1 = createVoterCheckInEvent(1, myMachineId, 'voter-1');
const event2 = createVoterCheckInEvent(2, otherMachineId, 'voter-2');
const event3 = createVoterCheckInEvent(1, myMachineId, 'voter-3');

store.saveEvent(event1);
store.saveEvent(event2);
store.saveEvent(event3);

const knownMachines: Record<string, number> = {
[myMachineId]: 1,
[otherMachineId]: 1,
};
const events = store.getNewEvents(knownMachines);

assert(events.length === 1);
expect(events).toEqual([event2]);
});

test('getNewEvents returns no events for known machines and unknown machines', () => {
const store = createTestStore();
const event1 = createVoterCheckInEvent(1, myMachineId, 'voter-1');
const event2 = createVoterCheckInEvent(2, myMachineId, 'voter-2');
const event3 = createVoterCheckInEvent(3, myMachineId, 'voter-3');
const event4 = createVoterCheckInEvent(4, myMachineId, 'voter-4');
const event5 = createVoterCheckInEvent(5, myMachineId, 'voter-5');
const event6 = createVoterCheckInEvent(1, otherMachineId, 'voter-6');
const event7 = createVoterCheckInEvent(2, otherMachineId, 'voter-7');

store.saveEvent(event1);
store.saveEvent(event2);
store.saveEvent(event3);
store.saveEvent(event4);
store.saveEvent(event5);
store.saveEvent(event6);
store.saveEvent(event7);

const knownMachines: Record<string, number> = {
[myMachineId]: 3,
'not-a-machine': 1,
};
const events = store.getNewEvents(knownMachines);

assert(events.length === 4);
expect(events).toEqual([event6, event7, event4, event5]);
});
Loading

0 comments on commit 0194961

Please sign in to comment.