refactor(core): Add support for memoized evaluation on getters (no-changelog) (#12185)

Co-authored-by: Tomi Turtiainen <10324676+tomi@users.noreply.github.com>
This commit is contained in:
कारतोफ्फेलस्क्रिप्ट™
2024-12-13 12:52:36 +01:00
committed by GitHub
parent 70b0d81604
commit 785549cbec
7 changed files with 325 additions and 77 deletions

View File

@@ -1,10 +1,11 @@
import { createHash, randomBytes } from 'crypto';
import { chmodSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'fs';
import { ApplicationError, jsonParse, ALPHABET, toResult } from 'n8n-workflow';
import { customAlphabet } from 'nanoid';
import { chmodSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
import path from 'path';
import { Service } from 'typedi';
import { Memoized } from './decorators';
import { InstanceSettingsConfig } from './InstanceSettingsConfig';
const nanoid = customAlphabet(ALPHABET, 16);
@@ -133,6 +134,22 @@ export class InstanceSettings {
return this.settings.tunnelSubdomain;
}
/**
* Whether this instance is running inside a Docker container.
*
* Based on: https://github.com/sindresorhus/is-docker
*/
@Memoized
get isDocker() {
try {
return (
existsSync('/.dockerenv') || readFileSync('/proc/self/cgroup', 'utf8').includes('docker')
);
} catch {
return false;
}
}
update(newSettings: WritableSettings) {
this.save({ ...this.settings, ...newSettings });
}

View File

@@ -0,0 +1,153 @@
import { AssertionError, ok } from 'node:assert';
import { setFlagsFromString } from 'node:v8';
import { runInNewContext } from 'node:vm';
import { Memoized } from '../memoized';
describe('Memoized Decorator', () => {
class TestClass {
private computeCount = 0;
constructor(private readonly value: number = 42) {}
@Memoized
get expensiveComputation() {
this.computeCount++;
return this.value * 2;
}
getComputeCount() {
return this.computeCount;
}
}
it('should only compute the value once', () => {
const instance = new TestClass();
// First access should compute
expect(instance.expensiveComputation).toBe(84);
expect(instance.getComputeCount()).toBe(1);
// Second access should use cached value
expect(instance.expensiveComputation).toBe(84);
expect(instance.getComputeCount()).toBe(1);
// Third access should still use cached value
expect(instance.expensiveComputation).toBe(84);
expect(instance.getComputeCount()).toBe(1);
});
it('should cache values independently for different instances', () => {
const instance1 = new TestClass(10);
const instance2 = new TestClass(20);
expect(instance1.expensiveComputation).toBe(20);
expect(instance2.expensiveComputation).toBe(40);
expect(instance1.getComputeCount()).toBe(1);
expect(instance2.getComputeCount()).toBe(1);
});
it('should throw error when used on non-getter', () => {
expect(() => {
class InvalidClass {
// @ts-expect-error this code will fail at compile time and at runtime
@Memoized
public normalProperty = 42;
}
new InvalidClass();
}).toThrow(AssertionError);
});
it('should make cached value non-enumerable', () => {
const instance = new TestClass();
instance.expensiveComputation; // Access to trigger caching
const propertyNames = Object.keys(instance);
expect(propertyNames).not.toContain('expensiveComputation');
});
it('should not allow reconfiguring the cached value', () => {
const instance = new TestClass();
instance.expensiveComputation; // Access to trigger caching
expect(() => {
Object.defineProperty(instance, 'expensiveComputation', {
value: 999,
configurable: true,
});
}).toThrow();
});
it('should work when child class references memoized getter in parent class', () => {
class ParentClass {
protected computeCount = 0;
@Memoized
get parentValue() {
this.computeCount++;
return 42;
}
getComputeCount() {
return this.computeCount;
}
}
class ChildClass extends ParentClass {
get childValue() {
return this.parentValue * 2;
}
}
const child = new ChildClass();
expect(child.childValue).toBe(84);
expect(child.getComputeCount()).toBe(1);
expect(child.childValue).toBe(84);
expect(child.getComputeCount()).toBe(1);
});
it('should have correct property descriptor after memoization', () => {
const instance = new TestClass();
// Before accessing (original getter descriptor)
const beforeDescriptor = Object.getOwnPropertyDescriptor(
TestClass.prototype,
'expensiveComputation',
);
expect(beforeDescriptor?.configurable).toBe(true);
expect(beforeDescriptor?.enumerable).toBe(false);
expect(typeof beforeDescriptor?.get).toBe('function');
expect(beforeDescriptor?.set).toBeUndefined();
// After accessing (memoized value descriptor)
instance.expensiveComputation; // Trigger memoization
const afterDescriptor = Object.getOwnPropertyDescriptor(instance, 'expensiveComputation');
expect(afterDescriptor?.configurable).toBe(false);
expect(afterDescriptor?.enumerable).toBe(false);
expect(afterDescriptor?.writable).toBe(false);
expect(afterDescriptor?.value).toBe(84);
expect(afterDescriptor?.get).toBeUndefined();
});
it('should not prevent garbage collection of instances', async () => {
setFlagsFromString('--expose_gc');
const gc = runInNewContext('gc') as unknown as () => void;
let instance: TestClass | undefined = new TestClass();
const weakRef = new WeakRef(instance);
instance.expensiveComputation;
// Remove the strong reference
instance = undefined;
// Wait for garbage collection, forcing it if needed
await new Promise((resolve) => setTimeout(resolve, 10));
gc();
const ref = weakRef.deref();
ok(!ref, 'GC did not collect the instance ref');
});
});

View File

@@ -0,0 +1 @@
export { Memoized } from './memoized';

View File

@@ -0,0 +1,41 @@
import assert from 'node:assert';
/**
* A decorator that implements memoization for class property getters.
*
* The decorated getter will only be executed once and its value cached for subsequent access
*
* @example
* class Example {
* @Memoized
* get computedValue() {
* // This will only run once and the result will be cached
* return heavyComputation();
* }
* }
*
* @throws If decorator is used on something other than a getter
*/
export function Memoized<T = unknown>(
target: object,
propertyKey: string | symbol,
descriptor?: TypedPropertyDescriptor<T>,
): TypedPropertyDescriptor<T> {
const originalGetter = descriptor?.get;
assert(originalGetter, '@Memoized can only be used on getters');
// Replace the original getter for the first call
descriptor.get = function (this: typeof target.constructor): T {
const value = originalGetter.call(this);
// Add a property on the class instance to stop reading from the getter on class prototype
Object.defineProperty(this, propertyKey, {
value,
configurable: false,
enumerable: false,
writable: false,
});
return value;
};
return descriptor;
}

View File

@@ -1,5 +1,6 @@
import * as NodeExecuteFunctions from './NodeExecuteFunctions';
export * from './decorators';
export * from './errors';
export * from './ActiveWorkflows';
export * from './BinaryData/BinaryData.service';