Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
"@rollup/pluginutils": "^5.1.0",
"@types/express": "^4.17.23",
"@types/lodash": "^4.17.10",
"@types/memjs": "^1.3.3",
"@types/node": "^20.19.0",
"cross-env": "^7.0.3",
"ctix": "^2.7.1",
Expand All @@ -51,7 +52,8 @@
"rollup-plugin-terser": "^7.0.2",
"rollup-plugin-typescript-paths": "^1.5.0",
"typedoc": "^0.28.5",
"typescript": "^5.4.5"
"typescript": "^5.4.5",
"vitest": "^3.2.3"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.56.0",
Expand Down Expand Up @@ -88,6 +90,7 @@
"js-yaml": "^4.1.0",
"jsonrepair": "^3.8.0",
"lodash": "^4.17.21",
"memjs": "^1.3.2",
"mime": "^4.0.3",
"mysql2": "^3.11.3",
"oauth-1.0a": "^2.2.6",
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ export * from './subsystems/LLMManager/ModelsProvider.service/connectors/JSONMod
export * from './subsystems/MemoryManager/Cache.service/connectors/LocalStorageCache.class';
export * from './subsystems/MemoryManager/Cache.service/connectors/RAMCache.class';
export * from './subsystems/MemoryManager/Cache.service/connectors/RedisCache.class';
export * from './subsystems/MemoryManager/Cache.service/connectors/MemcachedCache.class';
export * from './subsystems/MemoryManager/Cache.service/connectors/S3Cache.class';
export * from './subsystems/Security/Account.service/connectors/AWSAccount.class';
export * from './subsystems/Security/Account.service/connectors/DummyAccount.class';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
import { Logger } from '@sre/helpers/Log.helper';
import { IAccessCandidate, IACL, TAccessLevel } from '@sre/types/ACL.types';
import { CacheMetadata } from '@sre/types/Cache.types';
import { CacheConnector } from '../CacheConnector';
import { ACL } from '@sre/Security/AccessControl/ACL.class';
import { AccessRequest } from '@sre/Security/AccessControl/AccessRequest.class';
import { SecureConnector } from '@sre/Security/SecureConnector.class';

import memjs, { Client } from 'memjs';
import { MemcachedConfig } from '@sre/types/Memcached.types';


const console = Logger('MemcachedCache');


export class MemcachedCache extends CacheConnector {
public name: string = 'MemcachedCache';
private memcached: Client;
private _prefix: string;
private _mdPrefix: string;

constructor(protected _settings: MemcachedConfig) {
super(_settings);

const hosts = Array.isArray(_settings.hosts)
? _settings.hosts
.map((h) => (typeof h === 'string' ? h : `${h.host}:${h.port}`))
.join(',')
: typeof _settings.hosts === 'string'
? _settings.hosts
: '';

this.memcached = memjs.Client.create(hosts, {
username: _settings.username,
password: _settings.password,
});

this._prefix = _settings.prefix || 'smyth:cache';
this._mdPrefix = _settings.metadataPrefix || 'smyth:metadata';

console.log(`Memcached connected: ${hosts}`);
}

public get client() {
return this.memcached;
}

public get prefix() {
return this._prefix;
}

public get mdPrefix() {
return this._mdPrefix;
}

@SecureConnector.AccessControl
public async get(acRequest: AccessRequest, key: string): Promise<string | null> {
const cacheKey = `${this._prefix}:${key}`;
const mdKey = `${this._mdPrefix}:${key}`;

const { value } = await this.memcached.get(cacheKey);
if (!value) return null;

const { value: mdValue } = await this.memcached.get(mdKey);
let metadata: CacheMetadata | undefined;
if (mdValue) {
metadata = this.deserializeMetadata(mdValue.toString());
}

if (metadata?.expiresAt && metadata.expiresAt < Date.now()) {
await Promise.all([
this.memcached.delete(cacheKey),
this.memcached.delete(mdKey),
]);
return null;
}

return value.toString();
}

@SecureConnector.AccessControl
public async set(
acRequest: AccessRequest,
key: string,
data: any,
acl?: IACL,
metadata?: CacheMetadata,
ttl?: number
): Promise<boolean> {
const accessCandidate = acRequest.candidate;
const newMetadata: CacheMetadata = metadata || {};
newMetadata.acl = ACL.from(acl).addAccess(
accessCandidate.role,
accessCandidate.id,
TAccessLevel.Owner
).ACL;

if (ttl) {
newMetadata.expiresAt = Date.now() + ttl * 1000;
}

await Promise.all([
this.memcached.set(`${this._prefix}:${key}`, Buffer.from(String(data)), { expires: ttl || 0 }),
this.memcached.set(`${this._mdPrefix}:${key}`, Buffer.from(this.serializeMetadata(newMetadata)), { expires: ttl || 0 }),
]);

return true;
}

@SecureConnector.AccessControl
public async delete(acRequest: AccessRequest, key: string): Promise<void> {
await Promise.all([
this.memcached.delete(`${this._prefix}:${key}`),
this.memcached.delete(`${this._mdPrefix}:${key}`),
]);
}

@SecureConnector.AccessControl
public async exists(acRequest: AccessRequest, key: string): Promise<boolean> {
const { value } = await this.memcached.get(`${this._prefix}:${key}`);
return !!value;
}

@SecureConnector.AccessControl
public async getMetadata(acRequest: AccessRequest, key: string): Promise<CacheMetadata | undefined> {
const { value } = await this.memcached.get(`${this._mdPrefix}:${key}`);
if (!value) return undefined;
return this.deserializeMetadata(value.toString());
}

@SecureConnector.AccessControl
public async setMetadata(acRequest: AccessRequest, key: string, metadata: CacheMetadata): Promise<void> {
await this.memcached.set(`${this._mdPrefix}:${key}`, Buffer.from(this.serializeMetadata(metadata)));
}

public async updateTTL(acRequest: AccessRequest, key: string, ttl?: number): Promise<void> {
if (!ttl) return;

const namespacedKey = `${this._prefix}:${key}`;
const namespacedMdKey = `${this._mdPrefix}:${key}`;

const { value } = await this.memcached.get(namespacedKey);
const { value: md } = await this.memcached.get(namespacedMdKey);

if (value) {
await this.memcached.set(namespacedKey, value, { expires: ttl });
}

if (md) {
const metadata = JSON.parse(this.serializeMetadata(md));
metadata.expiresAt = Date.now() + ttl * 1000;
await this.memcached.set(namespacedMdKey, JSON.stringify(metadata), { expires: ttl });
}
}


@SecureConnector.AccessControl
public async getTTL(acRequest: AccessRequest, key: string): Promise<number> {
// Memcached does not support direct TTL querying like Redis
// so we store expiresAt inside metadata
const metadata = await this.getMetadata(acRequest, key);
if (!metadata?.expiresAt) return -1;
return Math.max(0, Math.floor((metadata.expiresAt - Date.now()) / 1000));
}

public async getResourceACL(resourceId: string, candidate: IAccessCandidate): Promise<ACL> {
const { value } = await this.memcached.get(`${this._mdPrefix}:${resourceId}`);
if (!value) {
return new ACL().addAccess(candidate.role, candidate.id, TAccessLevel.Owner);
}
const metadata = this.deserializeMetadata(value.toString());
return ACL.from(metadata?.acl as IACL);
}

@SecureConnector.AccessControl
async getACL(acRequest: AccessRequest, key: string): Promise<IACL | undefined> {
const metadata = await this.getMetadata(acRequest, key);
return metadata?.acl as IACL;
}

@SecureConnector.AccessControl
async setACL(acRequest: AccessRequest, key: string, acl: IACL) {
let metadata = (await this.getMetadata(acRequest, key)) || {};
metadata.acl = ACL.from(acl).addAccess(
acRequest.candidate.role,
acRequest.candidate.id,
TAccessLevel.Owner
).ACL;
await this.setMetadata(acRequest, key, metadata);
}

private serializeMetadata(metadata: CacheMetadata): string {
const cloned = { ...metadata };
if (cloned.acl) cloned.acl = ACL.from(cloned.acl).serializedACL;
if (metadata.expiresAt) cloned.expiresAt = metadata.expiresAt;
return JSON.stringify(cloned);
}

private deserializeMetadata(str: string): CacheMetadata {
try {
const obj = JSON.parse(str);
if (obj.acl) obj.acl = ACL.from(obj.acl).ACL;
return obj;
} catch (e) {
console.warn('Error deserializing metadata', str);
return {};
}
}

public async stop() {
super.stop();
await this.memcached.quit();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@ import { RedisCache } from './connectors/RedisCache.class';
import { S3Cache } from './connectors/S3Cache.class';
import { LocalStorageCache } from './connectors/LocalStorageCache.class';
import { RAMCache } from './connectors/RAMCache.class';
import { MemcachedCache } from './connectors/MemcachedCache.class';
export class CacheService extends ConnectorServiceProvider {
public register() {
ConnectorService.register(TConnectorService.Cache, 'Redis', RedisCache);
ConnectorService.register(TConnectorService.Cache, 'S3', S3Cache);
ConnectorService.register(TConnectorService.Cache, 'LocalStorage', LocalStorageCache);
ConnectorService.register(TConnectorService.Cache, 'RAM', RAMCache);
ConnectorService.register(TConnectorService.Cache, 'Memcached', MemcachedCache);
}
}
14 changes: 14 additions & 0 deletions packages/core/src/types/Memcached.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
//==[ SRE: Memcached Types ]======================


export type MemcachedConfig = {
name: string;
hosts: string | string[] | {
host: string;
port: number;
}[];
username?: string;
password?: string;
prefix?: string;
metadataPrefix?: string;
};
88 changes: 88 additions & 0 deletions packages/core/tests/integration/connectors/Cache/Memcached.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { MemcachedCache } from '@sre/MemoryManager/Cache.service/connectors/MemcachedCache.class';
import { AccessRequest } from '@sre/Security/AccessControl/AccessRequest.class';
import { TAccessLevel } from '@sre/types/ACL.types';
import { describe, beforeAll, afterAll, it, expect } from 'vitest';

import { vi } from "vitest";

vi.mock("@sre/Security/SecureConnector.class", async () => {
const actual = await vi.importActual<any>("@sre/Security/SecureConnector.class");

class MockSecureConnector extends actual.SecureConnector {
static AccessControl() {
return function (
_target: any,
_propertyKey: string,
descriptor: PropertyDescriptor
) {
return descriptor;
};
}
}

return { ...actual, SecureConnector: MockSecureConnector };
});

describe('MemcachedCache (integration)', () => {
let cache: MemcachedCache;
let acRequest: AccessRequest;

beforeAll(() => {
cache = new MemcachedCache({
hosts: 'localhost:11211',
prefix: 'test:cache',
metadataPrefix: 'test:meta',
} as any);

acRequest = new AccessRequest({ id: 'user1', role: 'user' } as any)
.resource("memcached:test"); ;
});

afterAll(async () => {
await cache.stop();
});

it('should set and get a simple value', async () => {
await cache.set(acRequest, 'foo', 'bar', undefined, undefined, 5);
const val = await cache.get(acRequest, 'foo');
expect(val).toBe('bar');
});

it('should check if a key exists', async () => {
await cache.set(acRequest, 'existsKey', '123');
const exists = await cache.exists(acRequest, 'existsKey');
expect(exists).toBe(true);
});

it('should delete a key', async () => {
await cache.set(acRequest, 'toDelete', 'value');
await cache.delete(acRequest, 'toDelete');
const val = await cache.get(acRequest, 'toDelete');
expect(val).toBeNull();
});

it('should store and retrieve metadata', async () => {
const metadata = { custom: 'meta', expiresAt: Date.now() + 5000 };
await cache.setMetadata(acRequest, 'metaKey', metadata);

const result = await cache.getMetadata(acRequest, 'metaKey');
expect(result).toMatchObject(metadata);
});

it('should update TTL', async () => {
await cache.set(acRequest, 'ttlKey', 'withTTL', undefined, undefined, 2);
let ttl = await cache.getTTL(acRequest, 'ttlKey');
expect(ttl).toBeGreaterThan(0);

await cache.updateTTL(acRequest, 'ttlKey', 10);
ttl = await cache.getTTL(acRequest, 'ttlKey');
expect(ttl).toBeGreaterThanOrEqual(10);
});

it('should return expired key as null', async () => {
await cache.set(acRequest, 'expireKey', 'will-expire', undefined, undefined, 1);
await new Promise((res) => setTimeout(res, 1500));
const val = await cache.get(acRequest, 'expireKey');
expect(val).toBeNull();
});
});
Loading