Przeglądaj źródła

refactor: batch request model permissions instead of doing them one at a time

Kristian Vos 1 rok temu
rodzic
commit
6251b47f43

+ 87 - 15
backend/src/modules/DataModule/models/users/jobs/GetModelPermissions.ts

@@ -31,33 +31,99 @@ export default class GetModelPermissions extends DataModuleJob {
 	protected override async _authorize() {}
 
 	protected async _execute(): Promise<GetModelPermissionsResult> {
-		const { modelName, modelId } = this._payload;
+		const { modelName, modelId, modelIds } = this._payload;
 
 		const user = await this._context.getUser().catch(() => null);
 		const permissions = (await this._context.executeJob(
 			GetPermissions
 		)) as GetPermissionsResult;
 
-		let cacheKey = `model-permissions.${modelName}`;
-
-		if (modelId) cacheKey += `.${modelId}`;
+		const Model = await DataModule.getModel(modelName);
+		if (!Model) throw new Error("Model not found");
 
-		if (user) cacheKey += `.user.${user._id}`;
-		else cacheKey += `.guest`;
+		if (!modelId && (!modelIds || modelIds.length === 0)) {
+			const cacheKey = this._getCacheKey(user, modelName);
+			const cached = await CacheModule.get(cacheKey);
+			if (cached) return cached;
+
+			const modelPermissions = await this._getPermissionsForModel(
+				user,
+				permissions,
+				modelName,
+				modelId
+			);
+
+			await CacheModule.set(cacheKey, modelPermissions, 360);
+
+			return modelPermissions;
+		}
+
+		if (modelId) {
+			const cacheKey = this._getCacheKey(user, modelName, modelId);
+			const cached = await CacheModule.get(cacheKey);
+			if (cached) return cached;
+
+			const model = await Model.findById(modelId);
+			if (!model) throw new Error("Model not found");
+
+			const modelPermissions = await this._getPermissionsForModel(
+				user,
+				permissions,
+				modelName,
+				modelId,
+				model
+			);
+
+			await CacheModule.set(cacheKey, modelPermissions, 360);
+
+			return modelPermissions;
+		}
+
+		const result: any = {};
+		const uncachedModelIds: any = [];
+
+		await forEachIn(modelIds, async modelId => {
+			const cacheKey = this._getCacheKey(user, modelName, modelId);
+			const cached = await CacheModule.get(cacheKey);
+			if (cached) {
+				result[modelId] = cached;
+				return;
+			}
+			uncachedModelIds.push(modelId);
+		});
 
-		const cached = await CacheModule.get(cacheKey);
+		const uncachedModels = await Model.find({ _id: uncachedModelIds });
 
-		if (cached) return cached;
+		await forEachIn(uncachedModelIds, async modelId => {
+			const model = uncachedModels.find(
+				model => model._id.toString() === modelId.toString()
+			);
+			if (!model) throw new Error(`No model found for ${modelId}.`);
 
-		const Model = await DataModule.getModel(modelName);
+			const modelPermissions = await this._getPermissionsForModel(
+				user,
+				permissions,
+				modelName,
+				modelId,
+				model
+			);
 
-		if (!Model) throw new Error("Model not found");
+			const cacheKey = this._getCacheKey(user, modelName, modelId);
+			await CacheModule.set(cacheKey, modelPermissions, 360);
 
-		// TODO when we have a findManyById or other bulk permission, we don't want to call this individually for each modelId
-		const model = modelId ? await Model.findById(modelId) : null;
+			result[modelId] = modelPermissions;
+		});
 
-		if (modelId && !model) throw new Error("Model not found");
+		return result;
+	}
 
+	protected async _getPermissionsForModel(
+		user: any,
+		permissions: GetPermissionsResult,
+		modelName: string,
+		modelId: string,
+		model?: any
+	) {
 		const modelPermissions = Object.fromEntries(
 			Object.entries(permissions).filter(
 				([permission]) =>
@@ -98,8 +164,14 @@ export default class GetModelPermissions extends DataModuleJob {
 			}
 		);
 
-		await CacheModule.set(cacheKey, modelPermissions, 360);
-
 		return modelPermissions;
 	}
+
+	protected _getCacheKey(user: any, modelName: string, modelId?: string) {
+		let cacheKey = `model-permissions.${modelName}`;
+		if (modelId) cacheKey += `.${modelId}`;
+		if (user) cacheKey += `.user.${user._id}`;
+		else cacheKey += `.guest`;
+		return cacheKey;
+	}
 }

+ 110 - 6
frontend/src/Model.ts

@@ -2,6 +2,112 @@ import { forEachIn } from "@common/utils/forEachIn";
 import { useModelStore } from "./stores/model";
 import { useWebsocketStore } from "./stores/websocket";
 
+class DeferredPromise {
+	promise: Promise<any>;
+	reject;
+	resolve;
+
+	// eslint-disable-next-line require-jsdoc
+	constructor() {
+		this.promise = new Promise((resolve, reject) => {
+			this.reject = reject;
+			this.resolve = resolve;
+		});
+	}
+}
+
+interface ModelPermissionFetcherRequest {
+	promise: DeferredPromise;
+	payload: {
+		modelName: string;
+		modelId: string;
+	};
+}
+
+/**
+ * Class used for fetching model permissions in bulk, every 25ms max
+ * So if there's 200 models loaded, it would do only 1 request to fetch model permissions, not 200 separate ones
+ */
+class ModelPermissionFetcher {
+	private static requestsQueued: ModelPermissionFetcherRequest[] = [];
+	private static timeoutActive = false;
+
+	private static fetch() {
+		// If there is no other timeout running, indicate we will run one. Otherwise, return, as a timeout is already running
+		if (!this.timeoutActive) this.timeoutActive = true;
+		else return;
+
+		setTimeout(() => {
+			// Reset timeout active, so another one can run
+			this.timeoutActive = false;
+			// Make a copy of all requests currently queued, and then take those requests out of the queue so we can request them
+			const requests = this.requestsQueued;
+			this.requestsQueued = [];
+
+			// Splits the requests per model
+			const requestsPerModel = {};
+			requests.forEach(request => {
+				const { modelName } = request.payload;
+				if (!Array.isArray(requestsPerModel[modelName]))
+					requestsPerModel[modelName] = [];
+				requestsPerModel[modelName].push(request);
+			});
+
+			const modelNames = Object.keys(requestsPerModel);
+
+			const { runJob } = useWebsocketStore();
+
+			// Runs the requests per model
+			forEachIn(modelNames, async modelName => {
+				// Gets a unique list of all model ids for the current model that we want to request permissions for
+				const modelIds = Array.from(
+					new Set(
+						requestsPerModel[modelName].map(
+							request => request.payload.modelId
+						)
+					)
+				);
+
+				const result = await runJob("data.users.getModelPermissions", {
+					modelName,
+					modelIds
+				});
+
+				const requests = requestsPerModel[modelName];
+				// For all requests, resolve the deferred promise with the returned permissions for the model that request requested
+				requests.forEach(request => {
+					const { payload, promise } = request;
+					const { modelId } = payload;
+					promise.resolve(result[modelId]);
+				});
+			});
+		}, 25);
+	}
+
+	public static fetchModelPermissions(modelName, modelId) {
+		return new Promise(resolve => {
+			const promise = new DeferredPromise();
+
+			// Listens for the deferred promise response, before we actually push and fetch
+			promise.promise.then(result => {
+				resolve(result);
+			});
+
+			// Pushes the request to the queue
+			this.requestsQueued.push({
+				payload: {
+					modelName,
+					modelId
+				},
+				promise
+			});
+
+			// Calls the fetch function, which will start a timeout if one isn't already running, which will actually request the permissions
+			this.fetch();
+		});
+	}
+}
+
 export default class Model {
 	private _permissions?: object;
 
@@ -174,12 +280,10 @@ export default class Model {
 	public async getPermissions(refresh = false): Promise<object> {
 		if (refresh === false && this._permissions) return this._permissions;
 
-		const { runJob } = useWebsocketStore();
-
-		this._permissions = await runJob("data.users.getModelPermissions", {
-			modelName: this._name,
-			modelId: this._id
-		});
+		this._permissions = await ModelPermissionFetcher.fetchModelPermissions(
+			this._name,
+			this._id
+		);
 
 		return this._permissions;
 	}