move global services to "global" directory
This commit is contained in:
parent
00b216c83c
commit
a55649e89a
192 changed files with 223 additions and 223 deletions
157
packages/backend/src/global/CacheManagementService.ts
Normal file
157
packages/backend/src/global/CacheManagementService.ts
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
|
||||
import * as Redis from 'ioredis';
|
||||
import {
|
||||
MemoryKVCache,
|
||||
MemorySingleCache,
|
||||
RedisKVCache,
|
||||
RedisSingleCache,
|
||||
type RedisKVCacheOpts,
|
||||
type RedisSingleCacheOpts,
|
||||
} from '@/misc/cache.js';
|
||||
import { QuantumKVCache, type QuantumKVOpts } from '@/misc/QuantumKVCache.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { TimeService, type TimerHandle } from '@/global/TimeService.js';
|
||||
import { InternalEventService } from '@/global/InternalEventService.js';
|
||||
|
||||
// This is the one place that's *supposed* to new() up caches.
|
||||
/* eslint-disable no-restricted-syntax */
|
||||
|
||||
export type ManagedMemoryKVCache<T> = Managed<MemoryKVCache<T>>;
|
||||
export type ManagedMemorySingleCache<T> = Managed<MemorySingleCache<T>>;
|
||||
export type ManagedRedisKVCache<T> = Managed<RedisKVCache<T>>;
|
||||
export type ManagedRedisSingleCache<T> = Managed<RedisSingleCache<T>>;
|
||||
export type ManagedQuantumKVCache<T> = Managed<QuantumKVCache<T>>;
|
||||
|
||||
export type Managed<T> = Omit<T, 'dispose' | 'onApplicationShutdown' | 'gc'>;
|
||||
export type Manager = { dispose(): void, clear(): void, gc(): void };
|
||||
|
||||
export const GC_INTERVAL = 1000 * 60 * 3; // 3m
|
||||
|
||||
/**
|
||||
* Creates and "manages" instances of any standard cache type.
|
||||
* Instances produced by this class are automatically tracked for disposal when the application shuts down.
|
||||
*/
|
||||
@Injectable()
|
||||
export class CacheManagementService implements OnApplicationShutdown {
|
||||
private readonly managedCaches = new Set<Manager>();
|
||||
private gcTimer?: TimerHandle | null;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.redis)
|
||||
private readonly redisClient: Redis.Redis,
|
||||
|
||||
private readonly timeService: TimeService,
|
||||
private readonly internalEventService: InternalEventService,
|
||||
) {}
|
||||
|
||||
private get cacheServices() {
|
||||
return {
|
||||
internalEventService: this.internalEventService,
|
||||
redisClient: this.redisClient,
|
||||
timeService: this.timeService,
|
||||
};
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public createMemoryKVCache<T>(lifetime: number): ManagedMemoryKVCache<T> {
|
||||
const cache = new MemoryKVCache<T>(lifetime, this.cacheServices);
|
||||
return this.manageCache(cache);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public createMemorySingleCache<T>(lifetime: number): ManagedMemorySingleCache<T> {
|
||||
const cache = new MemorySingleCache<T>(lifetime, this.cacheServices);
|
||||
return this.manageCache(cache);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public createRedisKVCache<T>(name: string, opts: RedisKVCacheOpts<T>): ManagedRedisKVCache<T> {
|
||||
const cache = new RedisKVCache<T>(name, this.cacheServices, opts);
|
||||
return this.manageCache(cache);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public createRedisSingleCache<T>(name: string, opts: RedisSingleCacheOpts<T>): ManagedRedisSingleCache<T> {
|
||||
const cache = new RedisSingleCache<T>(name, this.cacheServices, opts);
|
||||
return this.manageCache(cache);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public createQuantumKVCache<T>(name: string, opts: QuantumKVOpts<T>): ManagedQuantumKVCache<T> {
|
||||
const cache = new QuantumKVCache<T>(name, this.cacheServices, opts);
|
||||
return this.manageCache(cache);
|
||||
}
|
||||
|
||||
protected manageCache<T extends Manager>(cache: T): Managed<T> {
|
||||
this.managedCaches.add(cache);
|
||||
this.startGcTimer();
|
||||
return cache;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public gc(): void {
|
||||
this.resetGcTimer(() => {
|
||||
// TODO callAll()
|
||||
for (const manager of this.managedCaches) {
|
||||
manager.gc();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public clear(): void {
|
||||
this.resetGcTimer(() => {
|
||||
for (const manager of this.managedCaches) {
|
||||
manager.clear();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async dispose(): Promise<void> {
|
||||
this.stopGcTimer();
|
||||
for (const manager of this.managedCaches) {
|
||||
manager.dispose();
|
||||
}
|
||||
this.managedCaches.clear();
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async onApplicationShutdown(): Promise<void> {
|
||||
await this.dispose();
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private startGcTimer() {
|
||||
// Only start it once, and don't *re* start since this gets called repeatedly.
|
||||
this.gcTimer ??= this.timeService.startTimer(this.gc, GC_INTERVAL, { repeated: true });
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private stopGcTimer() {
|
||||
// Only stop it once, then clear the value so it can be restarted later.
|
||||
if (this.gcTimer != null) {
|
||||
this.timeService.stopTimer(this.gcTimer);
|
||||
this.gcTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private resetGcTimer(onBlank?: () => void): void {
|
||||
this.stopGcTimer();
|
||||
|
||||
try {
|
||||
if (onBlank) {
|
||||
onBlank();
|
||||
}
|
||||
} finally {
|
||||
this.startGcTimer();
|
||||
}
|
||||
}
|
||||
}
|
||||
52
packages/backend/src/global/EnvService.ts
Normal file
52
packages/backend/src/global/EnvService.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import nodePath from 'node:path';
|
||||
import nodeFs from 'node:fs';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { type ManagedMemoryKVCache, CacheManagementService } from '@/global/CacheManagementService.js';
|
||||
|
||||
/**
|
||||
* Provides structured, mockable access to runtime/environment details.
|
||||
*/
|
||||
@Injectable()
|
||||
export class EnvService {
|
||||
protected readonly dependencyVersionCache: ManagedMemoryKVCache<string | null>;
|
||||
|
||||
constructor(cacheManagementService: CacheManagementService) {
|
||||
this.dependencyVersionCache = cacheManagementService.createMemoryKVCache<string | null>(Infinity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the environment variables of the process.
|
||||
* Can be modified, but modifications are not reflected to the operating system environment.
|
||||
*/
|
||||
public get env(): Partial<Record<string, string>> {
|
||||
return process.env;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the installed version of a given dependency, or null if not installed.
|
||||
*/
|
||||
@bindThis
|
||||
public async getDependencyVersion(dependency: string): Promise<string | null> {
|
||||
return await this.dependencyVersionCache.fetch(dependency, async () => {
|
||||
const packageJsonPath = nodePath.join(import.meta.dirname, '../../package.json');
|
||||
const packageJsonText = nodeFs.readFileSync(packageJsonPath, 'utf8');
|
||||
|
||||
// No "dependencies" section -> infer not installed.
|
||||
const packageJson = JSON.parse(packageJsonText) as { dependencies?: Partial<Record<string, string>> };
|
||||
if (packageJson.dependencies == null) return null;
|
||||
|
||||
// Not listed -> not installed.
|
||||
const version = packageJson.dependencies['mfm-js'];
|
||||
if (version == null) return null;
|
||||
|
||||
// Just in case some other value is there
|
||||
return String(version);
|
||||
});
|
||||
}
|
||||
}
|
||||
110
packages/backend/src/global/InternalEventService.ts
Normal file
110
packages/backend/src/global/InternalEventService.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
|
||||
import Redis from 'ioredis';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { GlobalEvents, InternalEventTypes } from '@/core/GlobalEventService.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
|
||||
export type Listener<K extends keyof InternalEventTypes> = (value: InternalEventTypes[K], key: K, isLocal: boolean) => void | Promise<void>;
|
||||
|
||||
export interface ListenerProps {
|
||||
ignoreLocal?: boolean,
|
||||
ignoreRemote?: boolean,
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class InternalEventService implements OnApplicationShutdown {
|
||||
private readonly listeners = new Map<keyof InternalEventTypes, Map<Listener<keyof InternalEventTypes>, ListenerProps>>();
|
||||
|
||||
constructor(
|
||||
@Inject(DI.redisForSub)
|
||||
private readonly redisForSub: Redis.Redis,
|
||||
|
||||
@Inject(DI.redis)
|
||||
private readonly redisForPub: Redis.Redis,
|
||||
|
||||
@Inject(DI.config)
|
||||
private readonly config: Pick<Config, 'host'>,
|
||||
) {
|
||||
this.redisForSub.on('message', this.onMessage);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public on<K extends keyof InternalEventTypes>(type: K, listener: Listener<K>, props?: ListenerProps): void {
|
||||
let set = this.listeners.get(type);
|
||||
if (!set) {
|
||||
set = new Map();
|
||||
this.listeners.set(type, set);
|
||||
}
|
||||
|
||||
// Functionally, this is just a set with metadata on the values.
|
||||
set.set(listener as Listener<keyof InternalEventTypes>, props ?? {});
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public off<K extends keyof InternalEventTypes>(type: K, listener: Listener<K>): void {
|
||||
this.listeners.get(type)?.delete(listener as Listener<keyof InternalEventTypes>);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async emit<K extends keyof InternalEventTypes>(type: K, value: InternalEventTypes[K]): Promise<void> {
|
||||
await this.emitInternal(type, value, true);
|
||||
await this.redisForPub.publish(this.config.host, JSON.stringify({
|
||||
channel: 'internal',
|
||||
message: { type: type, body: value },
|
||||
}));
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async emitInternal<K extends keyof InternalEventTypes>(type: K, value: InternalEventTypes[K], isLocal: boolean): Promise<void> {
|
||||
const listeners = this.listeners.get(type);
|
||||
if (!listeners) {
|
||||
return;
|
||||
}
|
||||
|
||||
const promises: Promise<void>[] = [];
|
||||
for (const [listener, props] of listeners) {
|
||||
if ((isLocal && !props.ignoreLocal) || (!isLocal && !props.ignoreRemote)) {
|
||||
const promise = Promise.resolve(listener(value, type, isLocal));
|
||||
promises.push(promise);
|
||||
}
|
||||
}
|
||||
await Promise.all(promises);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async onMessage(_: string, data: string): Promise<void> {
|
||||
const obj = JSON.parse(data);
|
||||
|
||||
if (obj.channel === 'internal') {
|
||||
const { type, body } = obj.message as GlobalEvents['internal']['payload'];
|
||||
if (!isLocalInternalEvent(body) || body._pid !== process.pid) {
|
||||
await this.emitInternal(type, body as InternalEventTypes[keyof InternalEventTypes], false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public dispose(): void {
|
||||
this.redisForSub.off('message', this.onMessage);
|
||||
this.listeners.clear();
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public onApplicationShutdown(): void {
|
||||
this.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
interface LocalInternalEvent {
|
||||
_pid: number;
|
||||
}
|
||||
|
||||
function isLocalInternalEvent(body: object): body is LocalInternalEvent {
|
||||
return '_pid' in body && typeof(body._pid) === 'number';
|
||||
}
|
||||
182
packages/backend/src/global/TimeService.ts
Normal file
182
packages/backend/src/global/TimeService.ts
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Injectable, OnApplicationShutdown } from '@nestjs/common';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
|
||||
const timerTokenSymbol = Symbol('timerToken');
|
||||
|
||||
/**
|
||||
* Provides abstractions to access the current time.
|
||||
* Exists for unit testing purposes, so that tests can "simulate" any given time for consistency.
|
||||
*/
|
||||
@Injectable()
|
||||
export abstract class TimeService<TTimer extends Timer = Timer> implements OnApplicationShutdown {
|
||||
protected readonly timers = new Map<symbol, TTimer>();
|
||||
|
||||
protected constructor() {}
|
||||
|
||||
/**
|
||||
* Returns the current time, in milliseconds since the Unix epoch.
|
||||
*/
|
||||
public abstract get now(): number;
|
||||
|
||||
/**
|
||||
* Returns a new Date instance representing the current time.
|
||||
*/
|
||||
public get date(): Date {
|
||||
return new Date(this.now);
|
||||
}
|
||||
|
||||
public startTimer(callback: () => void, delay: number, opts?: TimerOpts): TimerHandle;
|
||||
public startTimer<T>(callback: (value: T) => void, delay: number, opts: TimerOpts | undefined, value: T): TimerHandle;
|
||||
@bindThis
|
||||
public startTimer<T = undefined>(callback: (value: T) => void, delay: number, opts?: TimerOpts, value?: T): TimerHandle {
|
||||
const timerId = Symbol();
|
||||
const repeating = opts?.repeated ?? false;
|
||||
|
||||
const timer = this.startNativeTimer(timerId, repeating, () => {
|
||||
callback(value as T); // overloads ensure it can't be null
|
||||
}, delay);
|
||||
this.timers.set(timerId, timer);
|
||||
|
||||
return timerId;
|
||||
}
|
||||
|
||||
public startPromiseTimer(delay: number): PromiseTimerHandle;
|
||||
public startPromiseTimer<T>(delay: number, value: T, opts?: PromiseTimerOpts): PromiseTimerHandle<T>;
|
||||
@bindThis
|
||||
public startPromiseTimer<T = undefined>(delay: number, value?: T, opts?: PromiseTimerOpts): PromiseTimerHandle<T> {
|
||||
const timerId = Symbol();
|
||||
const abortController = new AbortController();
|
||||
const abortSignal = opts?.signal ? AbortSignal.any([abortController.signal, opts.signal]) : abortController.signal;
|
||||
|
||||
const handlePromise = new Promise<T>((resolve, reject) => {
|
||||
// Connect AbortSignal
|
||||
abortSignal.throwIfAborted();
|
||||
abortSignal.addEventListener('abort', () => reject(abortSignal.reason));
|
||||
|
||||
// Start the underlying timer
|
||||
this.startTimer<T>(resolve, delay, undefined, value as T); // overloads ensure it can't be null
|
||||
});
|
||||
|
||||
// Make sure we dispose the real handle if promise rejects!
|
||||
handlePromise.catch(() => {
|
||||
this.stopTimer(timerId);
|
||||
});
|
||||
|
||||
// Populate and return the handle.
|
||||
return Object.assign(handlePromise, {
|
||||
[timerTokenSymbol]: timerId,
|
||||
|
||||
abort: (reason: Error) => {
|
||||
abortController.abort(reason);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
protected abstract startNativeTimer(timerId: symbol, repeating: boolean, callback: () => void, delay: number): TTimer;
|
||||
|
||||
/**
|
||||
* Clears a registered timeout or interval.
|
||||
* Returns true if the registration exists and was still active, false otherwise.
|
||||
* Safe to call with invalid or expired IDs.
|
||||
*/
|
||||
@bindThis
|
||||
public stopTimer(handle: TimerHandle | PromiseTimerHandle): boolean {
|
||||
const id = typeof(handle) === 'object' ? handle[timerTokenSymbol] : handle;
|
||||
const reg = this.timers.get(id);
|
||||
if (!reg) return false;
|
||||
|
||||
this.stopNativeTimer(reg);
|
||||
this.timers.delete(id);
|
||||
return true;
|
||||
}
|
||||
|
||||
protected abstract stopNativeTimer(reg: TTimer): void;
|
||||
|
||||
/**
|
||||
* Cleanup all handles and references.
|
||||
* Safe to call multiple times.
|
||||
*
|
||||
* **Must be called before shutting down the app!**
|
||||
*/
|
||||
@bindThis
|
||||
public dispose(): void {
|
||||
for (const reg of this.timers.values()) {
|
||||
this.stopNativeTimer(reg);
|
||||
}
|
||||
this.timers.clear();
|
||||
}
|
||||
|
||||
@bindThis
|
||||
onApplicationShutdown(): void {
|
||||
this.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
export interface Timer {
|
||||
timerId: symbol;
|
||||
repeating: boolean;
|
||||
delay: number;
|
||||
callback: () => void;
|
||||
}
|
||||
|
||||
export interface TimerOpts {
|
||||
repeated?: boolean;
|
||||
}
|
||||
|
||||
export type TimerHandle = symbol;
|
||||
|
||||
export interface PromiseTimerOpts {
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface PromiseTimerHandle<T = void> extends PromiseLike<T> {
|
||||
readonly [timerTokenSymbol]: symbol;
|
||||
abort(error?: Error): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default implementation of TimeService, uses Date.now() as time source and setTimeout/setInterval for timers.
|
||||
*/
|
||||
@Injectable()
|
||||
export class NativeTimeService extends TimeService<NativeTimer> implements OnApplicationShutdown {
|
||||
public get now(): number {
|
||||
// This is the one place that actually *should* have it
|
||||
// eslint-disable-next-line no-restricted-properties
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
public constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
protected startNativeTimer(timerId: symbol, repeating: boolean, callback: () => void, delay: number): NativeTimer {
|
||||
// Wrap the caller's callback to make sure we clean up the registration.
|
||||
const wrappedCallback = () => {
|
||||
this.timers.delete(timerId);
|
||||
callback();
|
||||
};
|
||||
|
||||
const timeout = repeating
|
||||
? global.setInterval(wrappedCallback, delay)
|
||||
: global.setTimeout(wrappedCallback, delay);
|
||||
|
||||
return { callback, timerId, repeating, delay, timeout };
|
||||
}
|
||||
|
||||
protected stopNativeTimer(reg: NativeTimer): void {
|
||||
if (reg.repeating) {
|
||||
global.clearInterval(reg.timeout);
|
||||
} else {
|
||||
global.clearTimeout(reg.timeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface NativeTimer extends Timer {
|
||||
timeout: NodeJS.Timeout;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue