Skip to content

Commit

Permalink
kv store on steroids
Browse files Browse the repository at this point in the history
  • Loading branch information
SamuelPull committed Nov 22, 2024
1 parent 9919c04 commit 5b28d60
Show file tree
Hide file tree
Showing 6 changed files with 134 additions and 34 deletions.
128 changes: 104 additions & 24 deletions api/src/lib/keyValueStore.ts
Original file line number Diff line number Diff line change
@@ -1,36 +1,116 @@
/**
* @description Simple data key/value store
* @description Typed key-value store with expiration support using Map
*/
const store = {};

export const saveValue = (key: string, value: {}, exp: Date | number): void => {
if (!key || !value) {
throw Error("key and value are required");
interface StoreEntry<T> {
value: T;
exp: number;
}

class KeyValueStore {
private store: Map<string, StoreEntry<any>>;
private cleanupInterval: ReturnType<typeof setInterval>;

constructor(cleanupIntervalMs: number = 60 * 1000) {
this.store = new Map();
this.cleanupInterval = setInterval(() => this.clearExpiredKeys(), cleanupIntervalMs);
}

const expMs = exp instanceof Date ? exp.getTime() : exp;
store[key] = { ...value, exp: expMs };
};
/**
* Saves a value in the store with expiration
* @throws {ValidationError} If key or value is invalid
*/
public save<T>(key: string, value: T, exp: Date | number): void {
if (!key?.trim()) {
throw new Error("Key must be a non-empty string");
}

export const getValue = (key: string): unknown => store[key];
if (value === undefined || value === null) {
throw new Error("Value cannot be null or undefined");
}

const expMs = exp instanceof Date ? exp.getTime() : exp;

if (typeof expMs !== "number" || expMs <= Date.now()) {
throw new Error("Expiration must be a future timestamp or Date");
}

export const clearValue = (key: string): void => {
if (store[key]) {
delete store[key];
this.store.set(key, { value, exp: expMs });
}
};

export const clearExpiredKeys = (): void => {
const now = new Date();
const nowMs = now.getTime();
Object.keys(store).forEach((key) => {
// key is expired
if (store?.[key]?.exp && nowMs > store?.[key]?.exp) {
clearValue(key);
/**
* Retrieves a value from the store
* @returns The value if found and not expired, undefined otherwise
*/
public get<T>(key: string): T | undefined {
const entry = this.store.get(key);
if (!entry) return undefined;

if (Date.now() > entry.exp) {
this.clear(key);
return undefined;
}
});
};

setInterval(clearExpiredKeys, 1000 * 60);
return entry.value as T;
}

/**
* Removes a value from the store
* @returns boolean indicating if the value was cleared
*/
public clear(key: string): boolean {
return this.store.delete(key);
}

/**
* Clears all expired keys from the store
* @returns number of cleared keys
*/
public clearExpiredKeys(): number {
const now = Date.now();
let cleared = 0;

for (const [key, entry] of this.store) {
if (entry.exp <= now) {
this.store.delete(key);
cleared++;
}
}

return cleared;
}

/**
* Returns all non-expired values in the store
*/
public getAll<T>(): Map<string, T> {
const now = Date.now();
const result = new Map<string, T>();

for (const [key, entry] of this.store) {
if (entry.exp > now) {
result.set(key, entry.value);
}
}

return result;
}

/**
* Returns the number of entries in the store
*/
public size(): number {
return this.store.size;
}

/**
* Stops the cleanup interval and clears all entries
*/
public dispose(): void {
clearInterval(this.cleanupInterval);
this.store.clear();
}
}

export const getAllValues = (): any => store;
// Export a singleton instance
export const kvStore = new KeyValueStore();
21 changes: 20 additions & 1 deletion api/src/plugins/activity.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { FastifyInstance, FastifyPluginOptions } from "fastify";
import fp from "fastify-plugin";

import { config } from "../config";
import { AuthenticatedRequest } from "../httpd/lib";
import { kvStore } from "../lib/keyValueStore";

// todo should not run in refreshtoken route
async function activityTrackingPlugin(
fastify: FastifyInstance,
options: FastifyPluginOptions,
Expand All @@ -12,6 +15,12 @@ async function activityTrackingPlugin(
if (request.user) {
try {
const userId = request.user?.userId;
if (config.refreshTokenStorage === "memory") {
// TODO save, update last activity
kvStore.save(`lastActivity.${userId}`, Date.now(), Date.now() + 1000 * 60 * 10);
} else if (config.refreshTokenStorage === "db") {
// create or update last access for userId
}
} catch (err) {
//logger.error({ err }, `preHandler failed to get groups for user ${request.user?.userId}`);
}
Expand All @@ -20,7 +29,17 @@ async function activityTrackingPlugin(

// background job to check idle users
const checkIdleUsers = async (): Promise<void> => {
for (let i = 0; i < 1; i++) {}
try {
if (config.refreshTokenStorage === "memory") {
// go through all stored lastAccesses
// invalidate refreshTokens of users with last activity older than X
} else if (config.refreshTokenStorage === "db") {
// get all last accesses
// invalidate refreshTokens of users with last activity older than X
}
} catch (err) {
//logger.error({ err }, `preHandler failed to get groups for user ${request.user?.userId}`);
}
};

// run job every X minutes - setInterval
Expand Down
7 changes: 4 additions & 3 deletions api/src/service/user_refresh_token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { globalIntents } from "../authz/intents";
import { config } from "../config";
import { Ctx } from "../lib/ctx";
import DbConnector from "../lib/db";
import { getValue, getAllValues } from "../lib/keyValueStore";
import { kvStore } from "../lib/keyValueStore";
import logger from "../lib/logger";
import * as SymmetricCrypto from "../lib/symmetricCrypto";
import { verifyToken } from "../lib/token";
Expand Down Expand Up @@ -48,9 +48,10 @@ export async function validateRefreshToken(
let storedRefreshToken: { userId: string; validUntil: number } | undefined;

if (config.refreshTokenStorage === "memory") {
const storeValues = getAllValues();
// todo delete next 2 lines
const storeValues = kvStore.getAll();
logger.error(JSON.stringify(storeValues, null, 2));
storedRefreshToken = getValue(`refreshToken.${refreshToken}`) as
storedRefreshToken = kvStore.get(`refreshToken.${refreshToken}`) as
| { userId: string; validUntil: number }
| undefined;
} else if (config.refreshTokenStorage === "db") {
Expand Down
4 changes: 2 additions & 2 deletions api/src/user_authenticate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { toHttpError } from "./http_errors";
import { assertUnreachable } from "./lib/assertUnreachable";
import { Ctx } from "./lib/ctx";
import { safeIdSchema, safeStringSchema } from "./lib/joiValidation";
import { saveValue } from "./lib/keyValueStore";
import { kvStore } from "./lib/keyValueStore";
import * as Result from "./result";
import { AuthToken } from "./service/domain/organization/auth_token";
import { Group } from "./service/domain/organization/group";
Expand Down Expand Up @@ -261,7 +261,7 @@ export function addHttpHandler(
);

if (config.refreshTokenStorage === "memory") {
saveValue(
kvStore.save(
`refreshToken.${refreshToken}`,
{
userId: token.userId,
Expand Down
4 changes: 2 additions & 2 deletions api/src/user_authenticateAd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { JwtConfig, config } from "./config";
import { toHttpError } from "./http_errors";
import { assertUnreachable } from "./lib/assertUnreachable";
import { Ctx } from "./lib/ctx";
import { saveValue } from "./lib/keyValueStore";
import { kvStore } from "./lib/keyValueStore";
import * as Result from "./result";
import { AuthToken } from "./service/domain/organization/auth_token";
import { Group } from "./service/domain/organization/group";
Expand Down Expand Up @@ -256,7 +256,7 @@ export function addHttpHandler(
);

if (config.refreshTokenStorage === "memory") {
saveValue(
kvStore.save(
`refreshToken.${refreshToken}`,
{
userId: token.userId,
Expand Down
4 changes: 2 additions & 2 deletions api/src/user_logout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { VError } from "verror";
import { config } from "./config";
import { toHttpError } from "./http_errors";
import { Ctx } from "./lib/ctx";
import { clearValue } from "./lib/keyValueStore";
import { kvStore } from "./lib/keyValueStore";
import * as Result from "./result";

import { UserLogoutAPIService } from "./index";
Expand Down Expand Up @@ -132,7 +132,7 @@ export function addHttpHandler(

// delete refresh token from storage
if (currentRefreshToken && config.refreshTokenStorage === "memory") {
clearValue(`refreshToken.${currentRefreshToken}`);
kvStore.clear(`refreshToken.${currentRefreshToken}`);
} else if (currentRefreshToken && config.refreshTokenStorage === "db") {
await service.clearRefreshToken(currentRefreshToken);
}
Expand Down

0 comments on commit 5b28d60

Please sign in to comment.