Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place.
Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place.
Path: blob/master/src/packages/project/sync/sync-doc.ts
Views: 687
/*1* This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.2* License: MS-RSL – see LICENSE.md for details3*/45/*6Backend project support for using syncdocs.78This is mainly responsible for:910- loading and saving files to disk11- executing code1213*/1415import { SyncTable } from "@cocalc/sync/table";16import { SyncDB } from "@cocalc/sync/editor/db/sync";17import { SyncString } from "@cocalc/sync/editor/string/sync";18import type Client from "@cocalc/sync-client";19import { once } from "@cocalc/util/async-utils";20import { filename_extension, original_path } from "@cocalc/util/misc";21import { initJupyterRedux, removeJupyterRedux } from "@cocalc/jupyter/kernel";22import { EventEmitter } from "events";23import { COMPUTER_SERVER_DB_NAME } from "@cocalc/util/compute/manager";24import computeServerOpenFileTracking from "./compute-server-open-file-tracking";25import { getLogger } from "@cocalc/backend/logger";2627const logger = getLogger("project:sync:sync-doc");2829type SyncDoc = SyncDB | SyncString;3031const COCALC_EPHEMERAL_STATE: boolean =32process.env.COCALC_EPHEMERAL_STATE === "yes";3334export class SyncDocs extends EventEmitter {35private syncdocs: { [path: string]: SyncDoc } = {};36private closing: Set<string> = new Set();3738async close(path: string): Promise<void> {39const doc = this.get(path);40if (doc == null) {41logger.debug(`SyncDocs: close ${path} -- no need, as it is not opened`);42return;43}44try {45logger.debug(`SyncDocs: close ${path} -- starting close`);46this.closing.add(path);47// As soon as this close starts, doc is in an undefined state.48// Also, this can take an **unbounded** amount of time to finish,49// since it tries to save the patches table (among other things)50// to the database, and if there is no connection from the hub51// to this project, then it will simply wait however long it takes52// until we get a connection (and there is no timeout). That is53// perfectly fine! E.g., a user closes their browser connected54// to a project, then comes back 8 hours later and tries to open55// this document when they resume their browser. During those entire56// 8 hours, the project might have been waiting to reconnect, just57// so it could send the patches from patches_list to the database.58// It does that, then finishes this async doc.close(), releases59// the lock, and finally the user gets to open their file. See60// https://github.com/sagemathinc/cocalc/issues/5823 for how not being61// careful with locking like this resulted in a very difficult to62// track down heisenbug. See also63// https://github.com/sagemathinc/cocalc/issues/561764await doc.close();65logger.debug(`SyncDocs: close ${path} -- successfully closed`);66} finally {67// No matter what happens above when it finishes, we clear it68// and consider it closed.69// There is perhaps a chance closing fails above (no idea how),70// but we don't want it to be impossible to attempt to open71// the path again I.e., we don't want to leave around a lock.72logger.debug(`SyncDocs: close ${path} -- recording that close succeeded`);73delete this.syncdocs[path];74this.closing.delete(path);75// I think close-${path} is used only internally in this.create below76this.emit(`close-${path}`);77// This is used by computeServerOpenFileTracking78this.emit("close", path);79}80}8182get(path: string): SyncDoc | undefined {83return this.syncdocs[path];84}8586getOpenPaths = (): string[] => {87return Object.keys(this.syncdocs);88};8990isOpen = (path: string): boolean => {91return this.syncdocs[path] != null;92};9394async create(type, opts): Promise<SyncDoc> {95const path = opts.path;96if (this.closing.has(path)) {97logger.debug(98`SyncDocs: create ${path} -- waiting for previous version to completely finish closing...`,99);100await once(this, `close-${path}`);101logger.debug(`SyncDocs: create ${path} -- successfully closed.`);102}103let doc;104switch (type) {105case "string":106doc = new SyncString(opts);107break;108case "db":109doc = new SyncDB(opts);110break;111default:112throw Error(`unknown syncdoc type ${type}`);113}114this.syncdocs[path] = doc;115logger.debug(`SyncDocs: create ${path} -- successfully created`);116// This is used by computeServerOpenFileTracking:117this.emit("open", path);118if (path == COMPUTER_SERVER_DB_NAME) {119logger.debug(120"SyncDocs: also initializing open file tracking for ",121COMPUTER_SERVER_DB_NAME,122);123computeServerOpenFileTracking(this, doc);124}125return doc;126}127128async closeAll(filename: string): Promise<void> {129logger.debug(`SyncDocs: closeAll("${filename}")`);130for (const path in this.syncdocs) {131if (path == filename || path.startsWith(filename + "/")) {132await this.close(path);133}134}135}136}137138const syncDocs = new SyncDocs();139140// The "synctable" here is EXACTLY ONE ENTRY of the syncstrings table.141// That is the table in the postgresql database that tracks the path,142// save state, document type, etc., of a syncdoc. It's called syncstrings143// instead of syncdoc_metadata (say) because it was created when we only144// used strings for sync.145146export function init_syncdoc(client: Client, synctable: SyncTable): void {147if (synctable.get_table() !== "syncstrings") {148throw Error("table must be 'syncstrings'");149}150if (synctable.get_state() == "closed") {151throw Error("synctable must not be closed");152}153// It's the right type of table and not closed. Now do154// the real setup work (without blocking).155init_syncdoc_async(client, synctable);156}157158// If there is an already existing syncdoc for this path,159// return it; otherwise, return undefined. This is useful160// for getting a reference to a syncdoc, e.g., for prettier.161export function get_syncdoc(path: string): SyncDoc | undefined {162return syncDocs.get(path);163}164165export function getSyncDocFromSyncTable(synctable: SyncTable) {166const { opts } = get_type_and_opts(synctable);167return get_syncdoc(opts.path);168}169170async function init_syncdoc_async(171client: Client,172synctable: SyncTable,173): Promise<void> {174function log(...args): void {175logger.debug("init_syncdoc_async: ", ...args);176}177178log("waiting until synctable is ready");179await wait_until_synctable_ready(synctable);180log("synctable ready. Now getting type and opts");181const { type, opts } = get_type_and_opts(synctable);182const project_id = (opts.project_id = client.client_id());183// log("type = ", type);184// log("opts = ", JSON.stringify(opts));185opts.client = client;186log(`now creating syncdoc ${opts.path}...`);187let syncdoc;188try {189syncdoc = await syncDocs.create(type, opts);190} catch (err) {191log(`ERROR creating syncdoc -- ${err.toString()}`, err.stack);192// TODO: how to properly inform clients and deal with this?!193return;194}195synctable.on("closed", () => {196log("synctable closed, so closing syncdoc", opts.path);197syncDocs.close(opts.path);198});199200syncdoc.on("error", (err) => {201log(`syncdoc error -- ${err}`);202syncDocs.close(opts.path);203});204205// Extra backend support in some cases, e.g., Jupyter, Sage, etc.206const ext = filename_extension(opts.path);207log("ext = ", ext);208switch (ext) {209case "sage-jupyter2":210log("initializing Jupyter backend");211await initJupyterRedux(syncdoc, client);212const path = original_path(syncdoc.get_path());213synctable.on("closed", async () => {214log("removing Jupyter backend");215await removeJupyterRedux(path, project_id);216});217break;218}219}220221async function wait_until_synctable_ready(synctable: SyncTable): Promise<void> {222if (synctable.get_state() == "disconnected") {223logger.debug("wait_until_synctable_ready: wait for synctable be connected");224await once(synctable, "connected");225}226227const t = synctable.get_one();228if (t != null) {229logger.debug("wait_until_synctable_ready: currently", t.toJS());230}231logger.debug(232"wait_until_synctable_ready: wait for document info to get loaded into synctable...",233);234// Next wait until there's a document in the synctable, since that will235// have the path, patch type, etc. in it. That is set by the frontend.236function is_ready(): boolean {237const t = synctable.get_one();238if (t == null) {239logger.debug("wait_until_synctable_ready: is_ready: table is null still");240return false;241} else {242logger.debug("wait_until_synctable_ready: is_ready", JSON.stringify(t));243return t.has("path");244}245}246await synctable.wait(is_ready, 0);247logger.debug("wait_until_synctable_ready: document info is now in synctable");248}249250function get_type_and_opts(synctable: SyncTable): { type: string; opts: any } {251const s = synctable.get_one();252if (s == null) {253throw Error("synctable must not be empty");254}255const path = s.get("path");256if (typeof path != "string") {257throw Error("path must be a string");258}259const opts = { path, ephemeral: COCALC_EPHEMERAL_STATE };260let type: string = "";261262let doctype = s.get("doctype");263if (doctype != null) {264try {265doctype = JSON.parse(doctype);266} catch {267doctype = {};268}269if (doctype.opts != null) {270for (const k in doctype.opts) {271opts[k] = doctype.opts[k];272}273}274type = doctype.type;275}276if (type !== "db" && type !== "string") {277// fallback type278type = "string";279}280return { type, opts };281}282283export async function syncdoc_call(path: string, mesg: any): Promise<string> {284logger.debug("syncdoc_call", path, mesg);285const doc = syncDocs.get(path);286if (doc == null) {287logger.debug("syncdoc_call -- not open: ", path);288return "not open";289}290switch (mesg.cmd) {291case "close":292logger.debug("syncdoc_call -- now closing: ", path);293await syncDocs.close(path);294logger.debug("syncdoc_call -- closed: ", path);295return "successfully closed";296default:297throw Error(`unknown command ${mesg.cmd}`);298}299}300301// This is used when deleting a file/directory302// filename may be a directory or actual filename303export async function close_all_syncdocs_in_tree(304filename: string,305): Promise<void> {306logger.debug("close_all_syncdocs_in_tree", filename);307return await syncDocs.closeAll(filename);308}309310311