Path: blob/master/src/packages/conat/sync/open-files.ts
1452 views
/*1Keep track of open files.23We use the "dko" distributed key:value store because of the potential of merge4conflicts, e.g,. one client changes the compute server id and another changes5whether a file is deleted. By using dko, only the field that changed is sync'd6out, so we get last-write-wins on the level of fields.78WARNINGS:9An old version use dkv with merge conflict resolution, but with multiple clients10and the project, feedback loops or something happened and it would start getting11slow -- basically, merge conflicts could take a few seconds to resolve, which would12make opening a file start to be slow. Instead we use DKO data type, where fields13are treated separately atomically by the storage system. A *subtle issue* is14that when you set an object, this is NOT treated atomically. E.g., if you15set 2 fields in a set operation, then 2 distinct changes are emitted as the16two fields get set.1718DEVELOPMENT:1920Change to packages/backend, since packages/conat doesn't have a way to connect:2122~/cocalc/src/packages/backend$ node2324> z = await require('@cocalc/backend/conat/sync').openFiles({project_id:cc.current().project_id})25> z.touch({path:'a.txt'})26> z.get({path:'a.txt'})27{ open: true, count: 1, time:2025-02-09T16:37:20.713Z }28> z.touch({path:'a.txt'})29> z.get({path:'a.txt'})30{ open: true, count: 2 }31> z.time({path:'a.txt'})322025-02-09T16:36:58.510Z33> z.touch({path:'foo/b.md',id:0})34> z.getAll()35{36'a.txt': { open: true, count: 3 },37'foo/b.md': { open: true, count: 1 }3839Frontend Dev in browser:4041z = await cc.client.conat_client.openFiles({project_id:cc.current().project_id))42z.getAll()43}44*/4546import { type State } from "@cocalc/conat/types";47import { dko, type DKO } from "@cocalc/conat/sync/dko";48import { EventEmitter } from "events";49import getTime, { getSkew } from "@cocalc/conat/time";5051// info about interest in open files (and also what was explicitly deleted) older52// than this is automatically purged.53const MAX_AGE_MS = 1000 * 60 * 60 * 24;5455interface Deleted {56// what deleted state is57deleted: boolean;58// when deleted state set59time: number;60}6162interface Backend {63// who has it opened -- the compute_server_id (0 for project)64id: number;65// when they last reported having it opened66time: number;67}6869export interface KVEntry {70// a web browser has the file open at this point in time (in ms)71time?: number;72// if the file was removed from disk (and not immmediately written back),73// then deleted gets set to the time when this happened (in ms since epoch)74// and the file is closed on the backend. It won't be re-opened until75// either (1) the file is created on disk again, or (2) deleted is cleared.76// Note: the actual time here isn't really important -- what matter is the number77// is nonzero. It's just used for a display to the user.78// We store the deleted state *and* when this was set, so that in case79// of merge conflict we can do something sensible.80deleted?: Deleted;8182// if file is actively opened on a compute server/project, then it sets83// this entry. Right when it closes the file, it clears this.84// If it gets killed/broken and doesn't have a chance to clear it, then85// backend.time can be used to decide this isn't valid.86backend?: Backend;8788// optional information89doctype?;90}9192export interface Entry extends KVEntry {93// path to file relative to HOME94path: string;95}9697interface Options {98project_id: string;99noAutosave?: boolean;100noCache?: boolean;101}102103export async function createOpenFiles(opts: Options) {104const openFiles = new OpenFiles(opts);105await openFiles.init();106return openFiles;107}108109export class OpenFiles extends EventEmitter {110private project_id: string;111private noCache?: boolean;112private noAutosave?: boolean;113private kv?: DKO;114public state: "disconnected" | "connected" | "closed" = "disconnected";115116constructor({ project_id, noAutosave, noCache }: Options) {117super();118if (!project_id) {119throw Error("project_id must be specified");120}121this.project_id = project_id;122this.noAutosave = noAutosave;123this.noCache = noCache;124}125126private setState = (state: State) => {127this.state = state;128this.emit(state);129};130131private initialized = false;132init = async () => {133if (this.initialized) {134throw Error("init can only be called once");135}136this.initialized = true;137const d = await dko<KVEntry>({138name: "open-files",139project_id: this.project_id,140config: {141max_age: MAX_AGE_MS,142},143noAutosave: this.noAutosave,144noCache: this.noCache,145noInventory: true,146});147this.kv = d;148d.on("change", this.handleChange);149// ensure clock is synchronized150await getSkew();151this.setState("connected");152};153154private handleChange = ({ key: path }) => {155const entry = this.get(path);156if (entry != null) {157// not deleted and timestamp is set:158this.emit("change", entry as Entry);159}160};161162close = () => {163if (this.kv == null) {164return;165}166this.setState("closed");167this.removeAllListeners();168this.kv.removeListener("change", this.handleChange);169this.kv.close();170delete this.kv;171// @ts-ignore172delete this.project_id;173};174175private getKv = () => {176const { kv } = this;177if (kv == null) {178throw Error("closed");179}180return kv;181};182183private set = (path, entry: KVEntry) => {184this.getKv().set(path, entry);185};186187// When a client has a file open, they should periodically188// touch it to indicate that it is open.189// updates timestamp and ensures open=true.190touch = (path: string, doctype?) => {191if (!path) {192throw Error("path must be specified");193}194const kv = this.getKv();195const cur = kv.get(path);196const time = getTime();197if (doctype) {198this.set(path, {199...cur,200time,201doctype,202});203} else {204this.set(path, {205...cur,206time,207});208}209};210211setError = (path: string, err?: any) => {212const kv = this.getKv();213if (!err) {214const current = { ...kv.get(path) };215delete current.error;216this.set(path, current);217} else {218const current = { ...kv.get(path) };219current.error = { time: Date.now(), error: `${err}` };220this.set(path, current);221}222};223224setDeleted = (path: string) => {225const kv = this.getKv();226this.set(path, {227...kv.get(path),228deleted: { deleted: true, time: getTime() },229});230};231232isDeleted = (path: string) => {233return !!this.getKv().get(path)?.deleted?.deleted;234};235236setNotDeleted = (path: string) => {237const kv = this.getKv();238this.set(path, {239...kv.get(path),240deleted: { deleted: false, time: getTime() },241});242};243244// set that id is the backend with the file open.245// This should be called by that backend periodically246// when it has the file opened.247setBackend = (path: string, id: number) => {248const kv = this.getKv();249this.set(path, {250...kv.get(path),251backend: { id, time: getTime() },252});253};254255// get current backend that has file opened.256getBackend = (path: string): Backend | undefined => {257return this.getKv().get(path)?.backend;258};259260// ONLY if backend for path is currently set to id, then clear261// the backend field.262setNotBackend = (path: string, id: number) => {263const kv = this.getKv();264const cur = { ...kv.get(path) };265if (cur?.backend?.id == id) {266delete cur.backend;267this.set(path, cur);268}269};270271getAll = (): Entry[] => {272const x = this.getKv().getAll();273return Object.keys(x).map((path) => {274return { ...x[path], path };275});276};277278get = (path: string): Entry | undefined => {279const x = this.getKv().get(path);280if (x == null) {281return x;282}283return { ...x, path };284};285286delete = (path) => {287this.getKv().delete(path);288};289290clear = () => {291this.getKv().clear();292};293294save = async () => {295await this.getKv().save();296};297298hasUnsavedChanges = () => {299return this.getKv().hasUnsavedChanges();300};301}302303304