smc-hub
Version:
CoCalc: Backend webserver component
499 lines (446 loc) • 16.1 kB
text/typescript
/*
* This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.
* License: AGPLv3 s.t. "Commons Clause" – see LICENSE.md for details
*/
/*
* decaffeinate suggestions:
* DS001: Remove Babel/TypeScript constructor workaround
* DS102: Remove unnecessary code created because of implicit returns
* DS103: Rewrite code to no longer use __guard__
* DS205: Consider reworking code to avoid use of IIFEs
* DS207: Consider shorter variations of null checks
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
import { EventEmitter } from "events";
import { callback } from "awaiting";
import { callback2 } from "smc-util/async-utils";
import { close, len } from "smc-util/misc";
import { PostgreSQL, QueryOptions, QueryResult } from "./types";
import { ChangeEvent, Changes } from "./changefeed";
const { all_results } = require("../postgres-base");
type SetOfAccounts = { [account_id: string]: boolean };
type SetOfProjects = { [project_id: string]: boolean };
type State = "init" | "ready" | "closed";
export class ProjectAndUserTracker extends EventEmitter {
private state: State = "init";
private db: PostgreSQL;
private feed: Changes;
// by a "set" we mean map to boolean...
// set of accounts we care about
private accounts: SetOfAccounts = {};
// map from from project_id to set of users of a given project
private users: { [project_id: string]: SetOfAccounts } = {};
// map from account_id to set of projects of a given user
private projects: { [account_id: string]: SetOfProjects } = {};
// map from account_id to map from account_ids to *number* of
// projects the two users have in common.
private collabs: {
[account_id: string]: { [account_id: string]: number };
} = {};
private register_todo: { [account_id: string]: Function[] } = {};
// used for a runtime sanity check
private do_register_lock: boolean = false;
constructor(db: PostgreSQL) {
super();
this.db = db;
}
private assert_state(state: State, f: string): void {
if (this.state != state) {
throw Error(`${f}: state must be ${state} but it is ${this.state}`);
}
}
async init(): Promise<void> {
this.assert_state("init", "init");
const dbg = this.dbg("init");
dbg("Initializing Project and user tracker...");
// every changefeed for a user will result in a listener
// on an event on this one object.
this.setMaxListeners(1000);
try {
// create changefeed listening on changes to projects table
this.feed = await callback2(this.db.changefeed, {
table: "projects",
select: { project_id: "UUID" },
watch: ["users"],
where: {},
});
dbg("Success");
} catch (err) {
this.handle_error(err);
return;
}
this.feed.on("change", this.handle_change.bind(this));
this.feed.on("error", this.handle_error.bind(this));
this.feed.on("close", () => this.handle_error("changefeed closed"));
this.set_state("ready");
}
private dbg(f) {
return this.db._dbg(`Tracker.${f}`);
}
private handle_error(err) {
if (this.state == "closed") return;
// There was an error in the changefeed.
// Error is totally fatal, so we close up shop.
const dbg = this.dbg("handle_error");
dbg(`err='${err}'`);
this.emit("error", err);
this.close();
}
private set_state(state: State): void {
this.state = state;
this.emit(state);
}
close() {
if (this.state == "closed") {
return;
}
this.set_state("closed");
this.removeAllListeners();
if (this.feed != null) {
this.feed.close();
}
if (this.register_todo != null) {
// clear any outstanding callbacks
for (const account_id in this.register_todo) {
const callbacks = this.register_todo[account_id];
if (callbacks != null) {
for (const cb of callbacks) {
cb("closed");
}
}
}
}
close(this);
this.state = "closed";
}
private handle_change_delete(old_val): void {
this.assert_state("ready", "handle_change_delete");
const { project_id } = old_val;
if (this.users[project_id] == null) {
// no users, so nothing to worry about.
return;
}
for (const account_id in this.users[project_id]) {
this.remove_user_from_project(account_id, project_id);
}
return;
}
private handle_change(x: ChangeEvent): void {
this.assert_state("ready", "handle_change");
if (x.action === "delete") {
if (x.old_val == null) return; // should never happen
this.handle_change_delete(x.old_val);
} else {
if (x.new_val == null) return; // should never happen
this.handle_change_update(x.new_val);
}
}
private async handle_change_update(new_val): Promise<void> {
this.assert_state("ready", "handle_change_update");
const dbg = this.dbg("handle_change_update");
dbg(new_val);
// users on a project changed or project created
const { project_id } = new_val;
let users: QueryResult[];
try {
users = await query(this.db, {
query: "SELECT jsonb_object_keys(users) AS account_id FROM projects",
where: { "project_id = $::UUID": project_id },
});
} catch (err) {
this.handle_error(err);
return;
}
if (this.users[project_id] == null) {
// we are not already watching this project
let any = false;
for (const { account_id } of users) {
if (this.accounts[account_id]) {
any = true;
break;
}
}
if (!any) {
// *and* none of our tracked users are on this project... so don't care
return;
}
}
// first add any users who got added, and record which accounts are relevant
const users_now: SetOfAccounts = {};
for (const { account_id } of users) {
users_now[account_id] = true;
}
const users_before: SetOfAccounts =
this.users[project_id] != null ? this.users[project_id] : {};
for (const account_id in users_now) {
if (!users_before[account_id]) {
this.add_user_to_project(account_id, project_id);
}
}
for (const account_id in users_before) {
if (!users_now[account_id]) {
this.remove_user_from_project(account_id, project_id);
}
}
}
// add and remove user from a project, maintaining our data structures
private add_user_to_project(account_id: string, project_id: string): void {
this.assert_state("ready", "add_user_to_project");
if (
this.projects[account_id] != null &&
this.projects[account_id][project_id]
) {
// already added
return;
}
this.emit(`add_user_to_project-${account_id}`, project_id);
if (this.users[project_id] == null) {
this.users[project_id] = {};
}
const users = this.users[project_id];
users[account_id] = true;
if (this.projects[account_id] == null) {
this.projects[account_id] = {};
}
const projects = this.projects[account_id];
projects[project_id] = true;
if (this.collabs[account_id] == null) {
this.collabs[account_id] = {};
}
const collabs = this.collabs[account_id];
for (const other_account_id in users) {
if (collabs[other_account_id] != null) {
collabs[other_account_id] += 1;
} else {
collabs[other_account_id] = 1;
this.emit(`add_collaborator-${account_id}`, other_account_id);
}
const other_collabs = this.collabs[other_account_id];
if (other_collabs[account_id] != null) {
other_collabs[account_id] += 1;
} else {
other_collabs[account_id] = 1;
this.emit(`add_collaborator-${other_account_id}`, account_id);
}
}
}
private remove_user_from_project(
account_id: string,
project_id: string,
no_emit: boolean = false
): void {
this.assert_state("ready", "remove_user_from_project");
if (
(account_id != null ? account_id.length : undefined) !== 36 ||
(project_id != null ? project_id.length : undefined) !== 36
) {
throw Error("invalid account_id or project_id");
}
if (
!(this.projects[account_id] != null
? this.projects[account_id][project_id]
: undefined)
) {
return;
}
if (!no_emit) {
this.emit(`remove_user_from_project-${account_id}`, project_id);
}
if (this.collabs[account_id] == null) {
this.collabs[account_id] = {};
}
for (const other_account_id in this.users[project_id]) {
this.collabs[account_id][other_account_id] -= 1;
if (this.collabs[account_id][other_account_id] === 0) {
delete this.collabs[account_id][other_account_id];
if (!no_emit) {
this.emit(`remove_collaborator-${account_id}`, other_account_id);
}
}
this.collabs[other_account_id][account_id] -= 1;
if (this.collabs[other_account_id][account_id] === 0) {
delete this.collabs[other_account_id][account_id];
if (!no_emit) {
this.emit(`remove_collaborator-${other_account_id}`, account_id);
}
}
}
delete this.users[project_id][account_id];
delete this.projects[account_id][project_id];
}
// Register the given account so that this client watches the database
// in order to be aware of all projects and collaborators of the
// given account.
public async register(account_id: string): Promise<void> {
await callback(this.register_cb.bind(this), account_id);
}
private register_cb(account_id: string, cb: Function): void {
const dbg = this.dbg(`register(account_id="${account_id}"`);
if (this.accounts[account_id] != null) {
dbg(
`already registered -- listener counts ${JSON.stringify(
this.listener_counts(account_id)
)}`
);
cb();
return;
}
if (len(this.register_todo) === 0) {
// no registration is currently happening
this.register_todo[account_id] = [cb];
// kick things off -- this will keep registering accounts
// until everything is done, then this.register_todo will have length 0.
this.do_register();
} else {
// Accounts are being registered right now. Add to the todo list.
const v = this.register_todo[account_id];
if (v != null) {
v.push(cb);
} else {
this.register_todo[account_id] = [cb];
}
}
}
// Call do_register_work to completely clear the work
// this.register_todo work queue.
// NOTE: do_register_work does each account, *one after another*,
// rather than doing everything in parallel. WARNING: DO NOT
// rewrite this to do everything in parallel, unless you think you
// thoroughly understand the algorithm, since I think
// doing things in parallel would horribly break!
private async do_register(): Promise<void> {
if (this.state != "ready") return; // maybe shutting down.
// This gets a single account_id, if there are any:
let account_id: string | undefined = undefined;
for (account_id in this.register_todo) break;
if (account_id == null) return; // nothing to do.
const dbg = this.dbg(`do_register(account_id="${account_id}")`);
dbg("registering account");
if (this.do_register_lock)
throw Error("do_register MUST NOT be called twice at once!");
this.do_register_lock = true;
try {
// Register this account
let projects: QueryResult[];
try {
// 2021-05-10: one user has a really large number of projects, which causes the hub to crash
// TODO: fix this ORDER BY .. LIMIT .. part properly
projects = await query(this.db, {
query:
"SELECT project_id, json_agg(o) as users FROM (SELECT project_id, jsonb_object_keys(users) AS o FROM projects WHERE users ? $1::TEXT ORDER BY last_edited DESC LIMIT 10000) s group by s.project_id",
params: [account_id],
});
} catch (err) {
const e = `error registering '${account_id}' -- err=${err}`;
dbg(e);
this.handle_error(e); // it is game over.
return;
}
// we care about this account_id
this.accounts[account_id] = true;
dbg("now adding all users to project tracker -- start");
for (const project of projects) {
if (this.users[project.project_id] != null) {
// already have data about this project
continue;
} else {
for (const collab_account_id of project.users) {
if (collab_account_id == null) {
continue; // just skip; evidently rarely this isn't defined, maybe due to db error?
}
this.add_user_to_project(collab_account_id, project.project_id);
}
}
}
dbg("successfully registered -- stop");
// call the callbacks
const callbacks = this.register_todo[account_id];
if (callbacks != null) {
for (const cb of callbacks) {
cb();
}
// We are done (trying to) register account_id.
delete this.register_todo[account_id];
}
} finally {
this.do_register_lock = false;
}
if (len(this.register_todo) > 0) {
// Deal with next account that needs to be registered
this.do_register();
}
}
// TODO: not actually used by any client yet... but obviously it should
// be since this would be a work/memory leak, right?
public unregister(account_id: string): void {
if (!this.accounts[account_id]) return; // nothing to do
const v: string[] = [];
for (const project_id in this.projects[account_id]) {
v.push(project_id);
}
delete this.accounts[account_id];
// Forget about any projects they account_id is on that are no longer
// necessary to watch...
for (const project_id of v) {
let need: boolean = false;
for (const other_account_id in this.users[project_id]) {
if (this.accounts[other_account_id] != null) {
need = true;
break;
}
}
if (!need) {
for (const other_account_id in this.users[project_id]) {
this.remove_user_from_project(other_account_id, project_id, true);
}
delete this.users[project_id];
}
}
}
// Return *set* of projects that this user is a collaborator on
public get_projects(account_id: string): { [project_id: string]: boolean } {
if (!this.accounts[account_id]) {
// This should never happen, but very rarely it DOES. I do not know why, having studied the
// code. But when it does, just raising an exception blows up the server really badly.
// So for now we just async register the account, return that it is not a collaborator
// on anything. Then some query will fail, get tried again, and work since registration will
// have finished.
//throw Error("account (='#{account_id}') must be registered")
this.register(account_id);
return {};
}
return this.projects[account_id] != null ? this.projects[account_id] : {};
}
// map from collabs of account_id to number of projects they collab
// on (account_id itself counted twice)
public get_collabs(account_id: string): { [account_id: string]: number } {
if (this.state == "closed") return {};
return this.collabs[account_id] != null ? this.collabs[account_id] : {};
}
private listener_counts(account_id: string): object {
const x: any = {};
for (const e of [
"add_user_to_project",
"remove_user_from_project",
"add_collaborator",
"remove_collaborator",
]) {
const event = e + "-" + account_id;
x[event] = this.listenerCount(event);
}
return x;
}
}
function all_query(db: PostgreSQL, opts: QueryOptions, cb: Function): void {
if (opts == null) {
throw Error("opts must not be null");
}
opts.cb = all_results(cb);
db._query(opts);
}
async function query(
db: PostgreSQL,
opts: QueryOptions
): Promise<QueryResult[]> {
return await callback(all_query, db, opts);
}