CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutSign UpSign In
sagemathinc

Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place.

GitHub Repository: sagemathinc/cocalc
Path: blob/master/src/packages/project/sync/sync-doc.ts
Views: 687
1
/*
2
* This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.
3
* License: MS-RSL – see LICENSE.md for details
4
*/
5
6
/*
7
Backend project support for using syncdocs.
8
9
This is mainly responsible for:
10
11
- loading and saving files to disk
12
- executing code
13
14
*/
15
16
import { SyncTable } from "@cocalc/sync/table";
17
import { SyncDB } from "@cocalc/sync/editor/db/sync";
18
import { SyncString } from "@cocalc/sync/editor/string/sync";
19
import type Client from "@cocalc/sync-client";
20
import { once } from "@cocalc/util/async-utils";
21
import { filename_extension, original_path } from "@cocalc/util/misc";
22
import { initJupyterRedux, removeJupyterRedux } from "@cocalc/jupyter/kernel";
23
import { EventEmitter } from "events";
24
import { COMPUTER_SERVER_DB_NAME } from "@cocalc/util/compute/manager";
25
import computeServerOpenFileTracking from "./compute-server-open-file-tracking";
26
import { getLogger } from "@cocalc/backend/logger";
27
28
const logger = getLogger("project:sync:sync-doc");
29
30
type SyncDoc = SyncDB | SyncString;
31
32
const COCALC_EPHEMERAL_STATE: boolean =
33
process.env.COCALC_EPHEMERAL_STATE === "yes";
34
35
export class SyncDocs extends EventEmitter {
36
private syncdocs: { [path: string]: SyncDoc } = {};
37
private closing: Set<string> = new Set();
38
39
async close(path: string): Promise<void> {
40
const doc = this.get(path);
41
if (doc == null) {
42
logger.debug(`SyncDocs: close ${path} -- no need, as it is not opened`);
43
return;
44
}
45
try {
46
logger.debug(`SyncDocs: close ${path} -- starting close`);
47
this.closing.add(path);
48
// As soon as this close starts, doc is in an undefined state.
49
// Also, this can take an **unbounded** amount of time to finish,
50
// since it tries to save the patches table (among other things)
51
// to the database, and if there is no connection from the hub
52
// to this project, then it will simply wait however long it takes
53
// until we get a connection (and there is no timeout). That is
54
// perfectly fine! E.g., a user closes their browser connected
55
// to a project, then comes back 8 hours later and tries to open
56
// this document when they resume their browser. During those entire
57
// 8 hours, the project might have been waiting to reconnect, just
58
// so it could send the patches from patches_list to the database.
59
// It does that, then finishes this async doc.close(), releases
60
// the lock, and finally the user gets to open their file. See
61
// https://github.com/sagemathinc/cocalc/issues/5823 for how not being
62
// careful with locking like this resulted in a very difficult to
63
// track down heisenbug. See also
64
// https://github.com/sagemathinc/cocalc/issues/5617
65
await doc.close();
66
logger.debug(`SyncDocs: close ${path} -- successfully closed`);
67
} finally {
68
// No matter what happens above when it finishes, we clear it
69
// and consider it closed.
70
// There is perhaps a chance closing fails above (no idea how),
71
// but we don't want it to be impossible to attempt to open
72
// the path again I.e., we don't want to leave around a lock.
73
logger.debug(`SyncDocs: close ${path} -- recording that close succeeded`);
74
delete this.syncdocs[path];
75
this.closing.delete(path);
76
// I think close-${path} is used only internally in this.create below
77
this.emit(`close-${path}`);
78
// This is used by computeServerOpenFileTracking
79
this.emit("close", path);
80
}
81
}
82
83
get(path: string): SyncDoc | undefined {
84
return this.syncdocs[path];
85
}
86
87
getOpenPaths = (): string[] => {
88
return Object.keys(this.syncdocs);
89
};
90
91
isOpen = (path: string): boolean => {
92
return this.syncdocs[path] != null;
93
};
94
95
async create(type, opts): Promise<SyncDoc> {
96
const path = opts.path;
97
if (this.closing.has(path)) {
98
logger.debug(
99
`SyncDocs: create ${path} -- waiting for previous version to completely finish closing...`,
100
);
101
await once(this, `close-${path}`);
102
logger.debug(`SyncDocs: create ${path} -- successfully closed.`);
103
}
104
let doc;
105
switch (type) {
106
case "string":
107
doc = new SyncString(opts);
108
break;
109
case "db":
110
doc = new SyncDB(opts);
111
break;
112
default:
113
throw Error(`unknown syncdoc type ${type}`);
114
}
115
this.syncdocs[path] = doc;
116
logger.debug(`SyncDocs: create ${path} -- successfully created`);
117
// This is used by computeServerOpenFileTracking:
118
this.emit("open", path);
119
if (path == COMPUTER_SERVER_DB_NAME) {
120
logger.debug(
121
"SyncDocs: also initializing open file tracking for ",
122
COMPUTER_SERVER_DB_NAME,
123
);
124
computeServerOpenFileTracking(this, doc);
125
}
126
return doc;
127
}
128
129
async closeAll(filename: string): Promise<void> {
130
logger.debug(`SyncDocs: closeAll("${filename}")`);
131
for (const path in this.syncdocs) {
132
if (path == filename || path.startsWith(filename + "/")) {
133
await this.close(path);
134
}
135
}
136
}
137
}
138
139
const syncDocs = new SyncDocs();
140
141
// The "synctable" here is EXACTLY ONE ENTRY of the syncstrings table.
142
// That is the table in the postgresql database that tracks the path,
143
// save state, document type, etc., of a syncdoc. It's called syncstrings
144
// instead of syncdoc_metadata (say) because it was created when we only
145
// used strings for sync.
146
147
export function init_syncdoc(client: Client, synctable: SyncTable): void {
148
if (synctable.get_table() !== "syncstrings") {
149
throw Error("table must be 'syncstrings'");
150
}
151
if (synctable.get_state() == "closed") {
152
throw Error("synctable must not be closed");
153
}
154
// It's the right type of table and not closed. Now do
155
// the real setup work (without blocking).
156
init_syncdoc_async(client, synctable);
157
}
158
159
// If there is an already existing syncdoc for this path,
160
// return it; otherwise, return undefined. This is useful
161
// for getting a reference to a syncdoc, e.g., for prettier.
162
export function get_syncdoc(path: string): SyncDoc | undefined {
163
return syncDocs.get(path);
164
}
165
166
export function getSyncDocFromSyncTable(synctable: SyncTable) {
167
const { opts } = get_type_and_opts(synctable);
168
return get_syncdoc(opts.path);
169
}
170
171
async function init_syncdoc_async(
172
client: Client,
173
synctable: SyncTable,
174
): Promise<void> {
175
function log(...args): void {
176
logger.debug("init_syncdoc_async: ", ...args);
177
}
178
179
log("waiting until synctable is ready");
180
await wait_until_synctable_ready(synctable);
181
log("synctable ready. Now getting type and opts");
182
const { type, opts } = get_type_and_opts(synctable);
183
const project_id = (opts.project_id = client.client_id());
184
// log("type = ", type);
185
// log("opts = ", JSON.stringify(opts));
186
opts.client = client;
187
log(`now creating syncdoc ${opts.path}...`);
188
let syncdoc;
189
try {
190
syncdoc = await syncDocs.create(type, opts);
191
} catch (err) {
192
log(`ERROR creating syncdoc -- ${err.toString()}`, err.stack);
193
// TODO: how to properly inform clients and deal with this?!
194
return;
195
}
196
synctable.on("closed", () => {
197
log("synctable closed, so closing syncdoc", opts.path);
198
syncDocs.close(opts.path);
199
});
200
201
syncdoc.on("error", (err) => {
202
log(`syncdoc error -- ${err}`);
203
syncDocs.close(opts.path);
204
});
205
206
// Extra backend support in some cases, e.g., Jupyter, Sage, etc.
207
const ext = filename_extension(opts.path);
208
log("ext = ", ext);
209
switch (ext) {
210
case "sage-jupyter2":
211
log("initializing Jupyter backend");
212
await initJupyterRedux(syncdoc, client);
213
const path = original_path(syncdoc.get_path());
214
synctable.on("closed", async () => {
215
log("removing Jupyter backend");
216
await removeJupyterRedux(path, project_id);
217
});
218
break;
219
}
220
}
221
222
async function wait_until_synctable_ready(synctable: SyncTable): Promise<void> {
223
if (synctable.get_state() == "disconnected") {
224
logger.debug("wait_until_synctable_ready: wait for synctable be connected");
225
await once(synctable, "connected");
226
}
227
228
const t = synctable.get_one();
229
if (t != null) {
230
logger.debug("wait_until_synctable_ready: currently", t.toJS());
231
}
232
logger.debug(
233
"wait_until_synctable_ready: wait for document info to get loaded into synctable...",
234
);
235
// Next wait until there's a document in the synctable, since that will
236
// have the path, patch type, etc. in it. That is set by the frontend.
237
function is_ready(): boolean {
238
const t = synctable.get_one();
239
if (t == null) {
240
logger.debug("wait_until_synctable_ready: is_ready: table is null still");
241
return false;
242
} else {
243
logger.debug("wait_until_synctable_ready: is_ready", JSON.stringify(t));
244
return t.has("path");
245
}
246
}
247
await synctable.wait(is_ready, 0);
248
logger.debug("wait_until_synctable_ready: document info is now in synctable");
249
}
250
251
function get_type_and_opts(synctable: SyncTable): { type: string; opts: any } {
252
const s = synctable.get_one();
253
if (s == null) {
254
throw Error("synctable must not be empty");
255
}
256
const path = s.get("path");
257
if (typeof path != "string") {
258
throw Error("path must be a string");
259
}
260
const opts = { path, ephemeral: COCALC_EPHEMERAL_STATE };
261
let type: string = "";
262
263
let doctype = s.get("doctype");
264
if (doctype != null) {
265
try {
266
doctype = JSON.parse(doctype);
267
} catch {
268
doctype = {};
269
}
270
if (doctype.opts != null) {
271
for (const k in doctype.opts) {
272
opts[k] = doctype.opts[k];
273
}
274
}
275
type = doctype.type;
276
}
277
if (type !== "db" && type !== "string") {
278
// fallback type
279
type = "string";
280
}
281
return { type, opts };
282
}
283
284
export async function syncdoc_call(path: string, mesg: any): Promise<string> {
285
logger.debug("syncdoc_call", path, mesg);
286
const doc = syncDocs.get(path);
287
if (doc == null) {
288
logger.debug("syncdoc_call -- not open: ", path);
289
return "not open";
290
}
291
switch (mesg.cmd) {
292
case "close":
293
logger.debug("syncdoc_call -- now closing: ", path);
294
await syncDocs.close(path);
295
logger.debug("syncdoc_call -- closed: ", path);
296
return "successfully closed";
297
default:
298
throw Error(`unknown command ${mesg.cmd}`);
299
}
300
}
301
302
// This is used when deleting a file/directory
303
// filename may be a directory or actual filename
304
export async function close_all_syncdocs_in_tree(
305
filename: string,
306
): Promise<void> {
307
logger.debug("close_all_syncdocs_in_tree", filename);
308
return await syncDocs.closeAll(filename);
309
}
310
311