🩹 backend,oo: fixed begin editing session process (#525)

This commit is contained in:
Eric Doughty-Papassideris
2024-09-19 00:55:55 +02:00
parent 9db0fddc98
commit c23984355d
5 changed files with 140 additions and 74 deletions
@@ -43,6 +43,11 @@ export type FindOptions = {
sort?: SortOption;
};
export type AtomicCompareAndSetResult<FieldValueType> = {
didSet: boolean;
currentValue: FieldValueType | null;
};
/**
* Repository to work with entities. Each entity type has its own repository instance.
*/
@@ -129,10 +134,7 @@ export default class Repository<EntityType> {
fieldName: keyof EntityType,
previousValue: FieldValueType | null,
newValue: FieldValueType | null,
): Promise<{
didSet: boolean;
currentValue: FieldValueType | null;
}> {
): Promise<AtomicCompareAndSetResult<FieldValueType>> {
if (previousValue === newValue)
throw new Error(`Previous and new values are identical: ${JSON.stringify(previousValue)}`);
return this.connector.atomicCompareAndSet(entity, fieldName, previousValue, newValue);
@@ -9,6 +9,17 @@ import { logger } from "../../core/platform/framework/logger";
import { EditingSessionKeyFormat } from "../documents/entities/drive-file";
import jwt from "jsonwebtoken";
export enum ApplicationEditingKeyStatus {
/** the key isn't known and maybe used for a new session */
unknown = "unknown",
/** the key needed updating but is now invalid */
updated = "updated",
/** the key was already used in a finished session and can't be used again */
expired = "expired",
/** the key is valid and current and should be used again for the same file */
live = "live",
}
@Prefix("/api")
export default class ApplicationsApiService extends TdriveService<undefined> {
version = "1";
@@ -128,17 +139,22 @@ export default class ApplicationsApiService extends TdriveService<undefined> {
/**
* Check status of `editing_session_key` in the corresponding application.
* @param editingSessionKey {@see DriveFile.editing_session_key} to check
* @returns a URL string if there is a pending version to add, `null`
* if the key is unknown.
* @returns status of the provided key as far as the application knows
*/
async checkPendingEditingStatus(editingSessionKey: string): Promise<string | null> {
async checkPendingEditingStatus(editingSessionKey: string): Promise<ApplicationEditingKeyStatus> {
const parsedKey = EditingSessionKeyFormat.parse(editingSessionKey);
const response = await this.requestFromApplication(
"POST",
"tdriveApi/1/session/" + encodeURIComponent(editingSessionKey) + "/check",
parsedKey.applicationId,
);
return (response.data.url as string) || null;
if (response.status != 200 || response.data.error)
throw new Error(
`Application check key ${editingSessionKey} failed with HTTP ${
response.status
}: ${JSON.stringify(response.data)}`,
);
return (response.data.status as ApplicationEditingKeyStatus) || null;
}
/**
@@ -6,6 +6,7 @@ import {
Pagination,
} from "../../../core/platform/framework/api/crud-service";
import Repository, {
AtomicCompareAndSetResult,
comparisonType,
inType,
} from "../../../core/platform/services/database/services/orm/repository/repository";
@@ -61,7 +62,7 @@ import config from "config";
import { MultipartFile } from "@fastify/multipart";
import { UploadOptions } from "src/services/files/types";
import { SortType } from "src/core/platform/services/search/api";
import ApplicationsApiService from "../../applications-api";
import ApplicationsApiService, { ApplicationEditingKeyStatus } from "../../applications-api";
export class DocumentsService {
version: "1";
@@ -987,18 +988,45 @@ export class DocumentsService {
new CrudException("Missing or invalid application ID", 400),
);
}
let newKey: string;
try {
newKey = EditingSessionKeyFormat.generate(
editorApplicationId,
appInstanceId,
context.company.id,
context.user.id,
);
} catch (e) {
logger.error(`Error generating new editing_session_key: ${e}`, { error: e });
CrudException.throwMe(e, new CrudException("Error generating new editing_session_key", 500));
}
const spinLoopUntilEditable = async (
provider: {
generateKey: () => string;
atomicSet: (
key: string | null,
previous: string | null,
) => Promise<AtomicCompareAndSetResult<string>>;
getPluginKeyStatus: (key: string) => Promise<ApplicationEditingKeyStatus>;
},
attemptCount = 8,
tarpitS = 1,
tarpitWorsenCoeff = 1.2,
) => {
while (attemptCount-- > 0) {
const newKey = provider.generateKey();
const swapResult = await provider.atomicSet(newKey, null);
logger.debug(`Begin edit try ${newKey}, got: ${JSON.stringify(swapResult)}`);
if (swapResult.didSet) return newKey;
if (!swapResult.currentValue) continue; // glitch in the matrix but ok because atomicCompareAndSet is not actually completely atomic
const existingStatus = await provider.getPluginKeyStatus(swapResult.currentValue);
logger.debug(`Begin edit get status of ${newKey}: ${JSON.stringify(existingStatus)}`);
switch (existingStatus) {
case ApplicationEditingKeyStatus.unknown:
case ApplicationEditingKeyStatus.live:
return swapResult.currentValue;
case ApplicationEditingKeyStatus.updated:
case ApplicationEditingKeyStatus.expired:
logger.debug(`Begin edit emptying previous ${swapResult.currentValue}`);
await provider.atomicSet(null, swapResult.currentValue);
break;
default:
throw new Error(
`Unexpected ApplicationEditingKeyStatus: ${JSON.stringify(existingStatus)}`,
);
}
await new Promise(resolve => setTimeout(resolve, tarpitS * 1000));
tarpitS *= tarpitWorsenCoeff;
}
};
const hasAccess = await checkAccess(id, null, "write", this.repository, context);
if (!hasAccess) {
@@ -1018,13 +1046,20 @@ export class DocumentsService {
{},
context,
);
const result = await this.repository.atomicCompareAndSet(
driveFile,
"editing_session_key",
null,
newKey,
);
return { editingSessionKey: result.currentValue };
const editingSessionKey = await spinLoopUntilEditable({
atomicSet: (key, previous) =>
this.repository.atomicCompareAndSet(driveFile, "editing_session_key", previous, key),
generateKey: () =>
EditingSessionKeyFormat.generate(
editorApplicationId,
appInstanceId,
context.company.id,
context.user.id,
),
getPluginKeyStatus: key =>
ApplicationsApiService.getDefault().checkPendingEditingStatus(key),
});
return { editingSessionKey };
} catch (error) {
logger.error({ error: `${error}` }, "Failed to begin editing Drive item");
CrudException.throwMe(error, new CrudException("Failed to begin editing Drive item", 500));