Compare commits

...

7 Commits

Author SHA1 Message Date
izzy
9698326702 chore: log restart event 2025-12-19 15:43:13 +00:00
izzy
6da85dd12b fix: send correct state in one shot 2025-12-19 15:43:07 +00:00
izzy
23bd27eb30 refactor: move one shot into app.repository 2025-12-19 15:23:48 +00:00
izzy
a2d4439e48 fix: ack may not exist depending on caller 2025-12-19 14:16:30 +00:00
izzy
00bafb899d chore: mock the app restart callback 2025-12-19 14:08:31 +00:00
izzy
ec63098020 chore(maintenance): validate app restart responses 2025-12-19 13:56:23 +00:00
izzy
8ca692bfb0 fix(maintenance): prevent CLI hanging on occassion
fix(maintenance): always ack messages
fix(maintenance): ensure Redis is connected first
2025-12-19 13:52:57 +00:00
6 changed files with 46 additions and 55 deletions

View File

@@ -37,7 +37,13 @@ export class MaintenanceWebsocketRepository implements OnGatewayConnection, OnGa
afterInit(websocketServer: Server) { afterInit(websocketServer: Server) {
this.logger.log('Initialized websocket server'); this.logger.log('Initialized websocket server');
websocketServer.on('AppRestart', () => this.appRepository.exitApp());
websocketServer.on('AppRestart', (event: ArgsOf<'AppRestart'>, ack?: (ok: 'ok') => void) => {
this.logger.log(`Restarting due to event... ${JSON.stringify(event)}`);
ack?.('ok');
this.appRepository.exitApp();
});
} }
clientBroadcast<T extends keyof ClientEventMap>(event: T, ...data: ClientEventMap[T]) { clientBroadcast<T extends keyof ClientEventMap>(event: T, ...data: ClientEventMap[T]) {

View File

@@ -1,5 +1,10 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { createAdapter } from '@socket.io/redis-adapter';
import Redis from 'ioredis';
import { Server as SocketIO } from 'socket.io';
import { ExitCode } from 'src/enum'; import { ExitCode } from 'src/enum';
import { ConfigRepository } from 'src/repositories/config.repository';
import { AppRestartEvent } from 'src/repositories/event.repository';
@Injectable() @Injectable()
export class AppRepository { export class AppRepository {
@@ -17,4 +22,26 @@ export class AppRepository {
setCloseFn(fn: () => Promise<void>) { setCloseFn(fn: () => Promise<void>) {
this.closeFn = fn; this.closeFn = fn;
} }
async sendOneShotAppRestart(state: AppRestartEvent): Promise<void> {
const server = new SocketIO();
const { redis } = new ConfigRepository().getEnv();
const pubClient = new Redis({ ...redis, lazyConnect: true });
const subClient = pubClient.duplicate();
await Promise.all([pubClient.connect(), subClient.connect()]);
server.adapter(createAdapter(pubClient, subClient));
// => corresponds to notification.service.ts#onAppRestart
server.emit('AppRestartV1', state, async () => {
const responses = await server.serverSideEmitWithAck('AppRestart', state);
if (responses.some((response) => response !== 'ok')) {
throw new Error("One or more node(s) returned a non-'ok' response to our restart request!");
}
pubClient.disconnect();
subClient.disconnect();
});
}
} }

View File

@@ -89,6 +89,7 @@ describe(CliService.name, () => {
alreadyDisabled: true, alreadyDisabled: true,
}); });
expect(mocks.app.sendOneShotAppRestart).toHaveBeenCalledTimes(0);
expect(mocks.systemMetadata.set).toHaveBeenCalledTimes(0); expect(mocks.systemMetadata.set).toHaveBeenCalledTimes(0);
expect(mocks.event.emit).toHaveBeenCalledTimes(0); expect(mocks.event.emit).toHaveBeenCalledTimes(0);
}); });
@@ -99,6 +100,7 @@ describe(CliService.name, () => {
alreadyDisabled: false, alreadyDisabled: false,
}); });
expect(mocks.app.sendOneShotAppRestart).toHaveBeenCalled();
expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.MaintenanceMode, { expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.MaintenanceMode, {
isMaintenanceMode: false, isMaintenanceMode: false,
}); });
@@ -114,6 +116,7 @@ describe(CliService.name, () => {
}), }),
); );
expect(mocks.app.sendOneShotAppRestart).toHaveBeenCalledTimes(0);
expect(mocks.systemMetadata.set).toHaveBeenCalledTimes(0); expect(mocks.systemMetadata.set).toHaveBeenCalledTimes(0);
expect(mocks.event.emit).toHaveBeenCalledTimes(0); expect(mocks.event.emit).toHaveBeenCalledTimes(0);
}); });
@@ -126,6 +129,7 @@ describe(CliService.name, () => {
}), }),
); );
expect(mocks.app.sendOneShotAppRestart).toHaveBeenCalled();
expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.MaintenanceMode, { expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.MaintenanceMode, {
isMaintenanceMode: true, isMaintenanceMode: true,
secret: expect.stringMatching(/^\w{128}$/), secret: expect.stringMatching(/^\w{128}$/),

View File

@@ -5,7 +5,7 @@ import { MaintenanceAuthDto } from 'src/dtos/maintenance.dto';
import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto'; import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto';
import { SystemMetadataKey } from 'src/enum'; import { SystemMetadataKey } from 'src/enum';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
import { createMaintenanceLoginUrl, generateMaintenanceSecret, sendOneShotAppRestart } from 'src/utils/maintenance'; import { createMaintenanceLoginUrl, generateMaintenanceSecret } from 'src/utils/maintenance';
import { getExternalDomain } from 'src/utils/misc'; import { getExternalDomain } from 'src/utils/misc';
@Injectable() @Injectable()
@@ -55,8 +55,7 @@ export class CliService extends BaseService {
const state = { isMaintenanceMode: false as const }; const state = { isMaintenanceMode: false as const };
await this.systemMetadataRepository.set(SystemMetadataKey.MaintenanceMode, state); await this.systemMetadataRepository.set(SystemMetadataKey.MaintenanceMode, state);
await this.appRepository.sendOneShotAppRestart(state);
sendOneShotAppRestart(state);
return { return {
alreadyDisabled: false, alreadyDisabled: false,
@@ -89,7 +88,7 @@ export class CliService extends BaseService {
secret, secret,
}); });
sendOneShotAppRestart({ await this.appRepository.sendOneShotAppRestart({
isMaintenanceMode: true, isMaintenanceMode: true,
}); });

View File

@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
import { OnEvent } from 'src/decorators'; import { OnEvent } from 'src/decorators';
import { MaintenanceAuthDto } from 'src/dtos/maintenance.dto'; import { MaintenanceAuthDto } from 'src/dtos/maintenance.dto';
import { SystemMetadataKey } from 'src/enum'; import { SystemMetadataKey } from 'src/enum';
import { ArgOf } from 'src/repositories/event.repository';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
import { MaintenanceModeState } from 'src/types'; import { MaintenanceModeState } from 'src/types';
import { createMaintenanceLoginUrl, generateMaintenanceSecret, signMaintenanceJwt } from 'src/utils/maintenance'; import { createMaintenanceLoginUrl, generateMaintenanceSecret, signMaintenanceJwt } from 'src/utils/maintenance';
@@ -31,7 +32,10 @@ export class MaintenanceService extends BaseService {
} }
@OnEvent({ name: 'AppRestart', server: true }) @OnEvent({ name: 'AppRestart', server: true })
onRestart(): void { onRestart(event: ArgOf<'AppRestart'>, ack?: (ok: 'ok') => void): void {
this.logger.log(`Restarting due to event... ${JSON.stringify(event)}`);
ack?.('ok');
this.appRepository.exitApp(); this.appRepository.exitApp();
} }

View File

@@ -1,55 +1,6 @@
import { createAdapter } from '@socket.io/redis-adapter';
import Redis from 'ioredis';
import { SignJWT } from 'jose'; import { SignJWT } from 'jose';
import { randomBytes } from 'node:crypto'; import { randomBytes } from 'node:crypto';
import { Server as SocketIO } from 'socket.io';
import { MaintenanceAuthDto } from 'src/dtos/maintenance.dto'; import { MaintenanceAuthDto } from 'src/dtos/maintenance.dto';
import { ConfigRepository } from 'src/repositories/config.repository';
import { AppRestartEvent } from 'src/repositories/event.repository';
export function sendOneShotAppRestart(state: AppRestartEvent): void {
const server = new SocketIO();
const { redis } = new ConfigRepository().getEnv();
const pubClient = new Redis(redis);
const subClient = pubClient.duplicate();
server.adapter(createAdapter(pubClient, subClient));
/**
* Keep trying until we manage to stop Immich
*
* Sometimes there appear to be communication
* issues between to the other servers.
*
* This issue only occurs with this method.
*/
async function tryTerminate() {
while (true) {
try {
const responses = await server.serverSideEmitWithAck('AppRestart', state);
if (responses.length > 0) {
return;
}
} catch (error) {
console.error(error);
console.error('Encountered an error while telling Immich to stop.');
}
console.info(
"\nIt doesn't appear that Immich stopped, trying again in a moment.\nIf Immich is already not running, you can ignore this error.",
);
await new Promise((r) => setTimeout(r, 1e3));
}
}
// => corresponds to notification.service.ts#onAppRestart
server.emit('AppRestartV1', state, () => {
void tryTerminate().finally(() => {
pubClient.disconnect();
subClient.disconnect();
});
});
}
export async function createMaintenanceLoginUrl( export async function createMaintenanceLoginUrl(
baseUrl: string, baseUrl: string,