Major backend refactor (#599)

Backend refactor
This commit is contained in:
Zef Hemel
2023-12-13 17:52:56 +01:00
committed by GitHub
parent 60d7cc704a
commit 9f082c83a9
42 changed files with 959 additions and 503 deletions
@@ -0,0 +1,18 @@
import { MemoryKvPrimitives } from "../../plugos/lib/memory_kv_primitives.ts";
import { assertEquals } from "../../test_deps.ts";
import { ChunkedKvStoreSpacePrimitives } from "./chunked_datastore_space_primitives.ts";
import { testSpacePrimitives } from "./space_primitives.test.ts";
Deno.test("chunked_datastore_space_primitives", async () => {
const memoryKv = new MemoryKvPrimitives();
// In memory store and tiny chunks for testing
const spacePrimitives = new ChunkedKvStoreSpacePrimitives(memoryKv, 5);
await testSpacePrimitives(spacePrimitives);
const [deletedChunk] = await memoryKv.batchGet([[
"content",
"test.bin",
"000",
]]);
// This one was deleted during the test (but here we're checking the underlying store for content)
assertEquals(deletedChunk, undefined);
});
@@ -0,0 +1,81 @@
import type { SpacePrimitives } from "./space_primitives.ts";
import { KvKey } from "$sb/types.ts";
import { KvPrimitives } from "../../plugos/lib/kv_primitives.ts";
import { KvMetaSpacePrimitives } from "./kv_meta_space_primitives.ts";
import { PrefixedKvPrimitives } from "../../plugos/lib/prefixed_kv_primitives.ts";
/**
* A space primitives implementation that stores files in chunks in a KV store.
* This is useful for KV stores that have a size limit per value, such as DenoKV.
* Meta data will be kept with a "meta" prefix and content will be kept with a "content" prefix
* Example use with DenoKV:
* const denoKv = new DenoKvPrimitives(await Deno.openKv());
* const spacePrimitives = new ChunkedDataStoreSpacePrimitives(denoKv, 65536); // max 64kb per chunk
*/
export class ChunkedKvStoreSpacePrimitives extends KvMetaSpacePrimitives {
/**
* @param baseKv the underlying kv primitives (not prefixed with e.g. meta and content)
* @param chunkSize
* @param metaPrefix
* @param contentPrefix
*/
constructor(
baseKv: KvPrimitives,
chunkSize: number,
metaPrefix = ["meta"],
contentPrefix = ["content"],
) {
// Super call with a metaPrefix for storing the file metadata
super(new PrefixedKvPrimitives(baseKv, metaPrefix), {
async readFile(name: string, spacePrimitives: SpacePrimitives) {
const meta = await spacePrimitives.getFileMeta(name);
// Buffer to store the concatenated chunks
const concatenatedChunks = new Uint8Array(meta.size);
let offset = 0;
// Implicit assumption, chunks are ordered by chunk id by the underlying store
for await (
const { value } of baseKv.query({
prefix: [...contentPrefix, name],
})
) {
concatenatedChunks.set(value, offset);
offset += value.length;
}
return concatenatedChunks;
},
async writeFile(
name: string,
data: Uint8Array,
) {
// Persist the data, chunk by chunk
let chunkId = 0;
for (let i = 0; i < data.byteLength; i += chunkSize) {
const chunk = data.slice(i, i + chunkSize);
await baseKv.batchSet([{
// "3 digits ought to be enough for anybody" — famous last words
key: [...contentPrefix, name, String(chunkId).padStart(3, "0")],
value: chunk,
}]);
chunkId++;
}
},
async deleteFile(name: string, spacePrimitives: SpacePrimitives) {
const fileMeta = await spacePrimitives.getFileMeta(name);
// Using this we can calculate the chunk keys
const keysToDelete: KvKey[] = [];
let chunkId = 0;
for (let i = 0; i < fileMeta.size; i += chunkSize) {
keysToDelete.push([
...contentPrefix,
name,
String(chunkId).padStart(3, "0"),
]);
chunkId++;
}
return baseKv.batchDelete(keysToDelete);
},
});
}
}
@@ -1,8 +1,8 @@
import "https://esm.sh/fake-indexeddb@4.0.2/auto";
import { assertEquals } from "../../test_deps.ts";
import { DataStore } from "../../plugos/lib/datastore.ts";
import { IndexedDBKvPrimitives } from "../../plugos/lib/indexeddb_kv_primitives.ts";
import { DataStoreSpacePrimitives } from "./datastore_space_primitives.ts";
import { testSpacePrimitives } from "./space_primitives.test.ts";
Deno.test("DataStoreSpacePrimitives", {
sanitizeResources: false,
@@ -12,34 +12,6 @@ Deno.test("DataStoreSpacePrimitives", {
await db.init();
const space = new DataStoreSpacePrimitives(new DataStore(db));
const files = await space.fetchFileList();
assertEquals(files, []);
// Write text file
const fileMeta = await space.writeFile(
"test.txt",
stringToBytes("Hello World"),
);
assertEquals(
(await space.readFile("test.txt")).data,
stringToBytes("Hello World"),
);
const fbContent = (await space.readFile("test.txt"))
.data;
assertEquals(new TextDecoder().decode(fbContent), "Hello World");
assertEquals(await space.fetchFileList(), [fileMeta]);
const buf = new Uint8Array([1, 2, 3, 4, 5]);
// Write binary file
await space.writeFile("test.bin", buf);
const fMeta = await space.getFileMeta("test.bin");
assertEquals(fMeta.size, 5);
assertEquals((await space.fetchFileList()).length, 2);
await space.deleteFile("test.bin");
assertEquals(await space.fetchFileList(), [fileMeta]);
await testSpacePrimitives(space);
db.close();
});
function stringToBytes(str: string): Uint8Array {
return new TextEncoder().encode(str);
}
+24 -8
View File
@@ -12,6 +12,9 @@ export type FileContent = {
const filesMetaPrefix = ["file", "meta"];
const filesContentPrefix = ["file", "content"];
/**
* TODO: Replace this with ChunkedDatastoreSpacePrimitives
*/
export class DataStoreSpacePrimitives implements SpacePrimitives {
constructor(
private ds: DataStore,
@@ -46,14 +49,27 @@ export class DataStoreSpacePrimitives implements SpacePrimitives {
_selfUpdate?: boolean,
suggestedMeta?: FileMeta,
): Promise<FileMeta> {
const meta: FileMeta = {
name,
created: suggestedMeta?.lastModified || Date.now(),
lastModified: suggestedMeta?.lastModified || Date.now(),
contentType: mime.getType(name) || "application/octet-stream",
size: data.byteLength,
perm: suggestedMeta?.perm || "rw",
};
let meta: FileMeta | undefined;
try {
// Build off of the existing file meta, if file exists
meta = await this.getFileMeta(name);
} catch {
// Not found, that's fine
}
if (!meta) {
meta = {
name,
created: suggestedMeta?.lastModified || Date.now(),
perm: suggestedMeta?.perm || "rw",
contentType: mime.getType(name) || "application/octet-stream",
// Overwritten in a sec
lastModified: 0,
size: 0,
};
}
meta.lastModified = suggestedMeta?.lastModified || Date.now();
meta.size = data.byteLength;
await this.ds.batchSet<FileMeta | FileContent>([
{
key: [...filesContentPrefix, name],
+8 -27
View File
@@ -1,31 +1,12 @@
import { assertEquals } from "../../test_deps.ts";
import { DenoKVSpacePrimitives } from "./deno_kv_space_primitives.ts";
import { DenoKvPrimitives } from "../../plugos/lib/deno_kv_primitives.ts";
import { ChunkedKvStoreSpacePrimitives } from "./chunked_datastore_space_primitives.ts";
import { testSpacePrimitives } from "./space_primitives.test.ts";
Deno.test("deno_kv_space_primitives", async () => {
Deno.test("deno kv test", async () => {
const tempFile = await Deno.makeTempFile({ suffix: ".db" });
const spacePrimitives = new DenoKVSpacePrimitives();
await spacePrimitives.init(tempFile);
await spacePrimitives.writeFile("test.txt", new TextEncoder().encode("test"));
let result = await spacePrimitives.readFile("test.txt");
assertEquals(result.data, new TextEncoder().encode("test"));
let listing = await spacePrimitives.fetchFileList();
assertEquals(listing.length, 1);
await spacePrimitives.writeFile(
"test.txt",
new TextEncoder().encode("test2"),
);
result = await spacePrimitives.readFile("test.txt");
assertEquals(result.data, new TextEncoder().encode("test2"));
await spacePrimitives.deleteFile("test.txt");
listing = await spacePrimitives.fetchFileList();
try {
await spacePrimitives.readFile("test.txt");
throw new Error("Should not be here");
} catch (e: any) {
assertEquals(e.message, "Not found");
}
assertEquals(listing.length, 0);
spacePrimitives.close();
const denoKv = new DenoKvPrimitives(await Deno.openKv(tempFile));
const spacePrimitives = new ChunkedKvStoreSpacePrimitives(denoKv, 65536);
await testSpacePrimitives(spacePrimitives);
denoKv.close();
await Deno.remove(tempFile);
});
-84
View File
@@ -1,84 +0,0 @@
/// <reference lib="deno.unstable" />
import { FileMeta } from "$sb/types.ts";
import type { SpacePrimitives } from "./space_primitives.ts";
import { mime } from "https://deno.land/x/mimetypes@v1.0.0/mod.ts";
export class DenoKVSpacePrimitives implements SpacePrimitives {
private kv!: Deno.Kv;
private dataAttribute = "file";
private metaAttribute = "meta";
async init(path?: string) {
this.kv = await Deno.openKv(path);
}
close() {
this.kv.close();
}
async fetchFileList(): Promise<FileMeta[]> {
const results: FileMeta[] = [];
for await (
const result of this.kv.list({
prefix: [this.metaAttribute],
})
) {
results.push(result.value as FileMeta);
}
return results;
}
async readFile(name: string): Promise<{ data: Uint8Array; meta: FileMeta }> {
const [meta, data] = await this.kv.getMany([[this.metaAttribute, name], [
this.dataAttribute,
name,
]]);
if (!meta.value) {
throw new Error("Not found");
}
return {
data: data.value as Uint8Array,
meta: meta.value as FileMeta,
};
}
async getFileMeta(name: string): Promise<FileMeta> {
const result = await this.kv.get([this.metaAttribute, name]);
if (result.value) {
return result.value as FileMeta;
} else {
throw new Error("Not found");
}
}
async writeFile(
name: string,
data: Uint8Array,
_selfUpdate?: boolean | undefined,
suggestedMeta?: FileMeta | undefined,
): Promise<FileMeta> {
const meta: FileMeta = {
name,
created: suggestedMeta?.created || Date.now(),
lastModified: suggestedMeta?.lastModified || Date.now(),
contentType: mime.getType(name) || "application/octet-stream",
size: data.byteLength,
perm: suggestedMeta?.perm || "rw",
};
const res = await this.kv.atomic()
.set([this.dataAttribute, name], data)
.set([this.metaAttribute, name], meta)
.commit();
if (!res.ok) {
throw res;
}
return meta;
}
async deleteFile(name: string): Promise<void> {
const res = await this.kv.atomic()
.delete([this.dataAttribute, name])
.delete([this.metaAttribute, name])
.commit();
if (!res.ok) {
throw res;
}
}
}
+2 -1
View File
@@ -21,7 +21,8 @@ export class EventedSpacePrimitives implements SpacePrimitives {
constructor(
private wrapped: SpacePrimitives,
private eventHook: EventHook,
) {}
) {
}
dispatchEvent(name: string, ...args: any[]): Promise<any[]> {
return this.eventHook.dispatchEvent(name, ...args);
+7
View File
@@ -6,6 +6,7 @@ export class HttpSpacePrimitives implements SpacePrimitives {
constructor(
readonly url: string,
readonly expectedSpacePath?: string,
private bearerToken?: string,
) {
}
@@ -20,6 +21,12 @@ export class HttpSpacePrimitives implements SpacePrimitives {
...options.headers,
"X-Sync-Mode": "true",
};
if (this.bearerToken) {
options.headers = {
...options.headers,
"Authorization": `Bearer ${this.bearerToken}`,
};
}
try {
const result = await fetch(url, options);
+95
View File
@@ -0,0 +1,95 @@
import { FileMeta } from "$sb/types.ts";
import { KvPrimitives } from "../../plugos/lib/kv_primitives.ts";
import { mime } from "../deps.ts";
import { SpacePrimitives } from "./space_primitives.ts";
export type KvMetaSpacePrimitivesCallbacks = {
readFile: (
name: string,
spacePrimitives: SpacePrimitives,
) => Promise<Uint8Array>;
writeFile: (
name: string,
data: Uint8Array,
spacePrimitives: SpacePrimitives,
) => Promise<void>;
deleteFile: (name: string, spacePrimitives: SpacePrimitives) => Promise<void>;
};
export class KvMetaSpacePrimitives implements SpacePrimitives {
constructor(
protected kv: KvPrimitives,
private callbacks: KvMetaSpacePrimitivesCallbacks,
) {
}
async readFile(
name: string,
): Promise<{ data: Uint8Array; meta: FileMeta }> {
const [data, [meta]] = await Promise.all([
this.callbacks.readFile(name, this),
this.kv.batchGet([[name]]),
]);
return { data, meta: meta };
}
async writeFile(
name: string,
data: Uint8Array,
_selfUpdate?: boolean | undefined,
desiredMeta?: FileMeta | undefined,
): Promise<FileMeta> {
let meta: FileMeta | undefined;
try {
// Build off of the existing file meta, if file exists
meta = await this.getFileMeta(name);
} catch {
// Not found, that's fine
}
if (!meta) {
meta = {
name,
perm: "rw",
created: Date.now(),
contentType: mime.getType(name) || "application/octet-stream",
// These will be overwritten in a bit
lastModified: 0,
size: 0,
};
}
meta = {
...meta,
lastModified: desiredMeta?.lastModified || Date.now(),
size: data.byteLength,
};
await Promise.all([
this.callbacks.writeFile(name, data, this),
this.kv.batchSet([{ key: [name], value: meta }]),
]);
return meta;
}
async deleteFile(name: string): Promise<void> {
await Promise.all([
this.callbacks.deleteFile(name, this),
this.kv.batchDelete([[name]]),
]);
}
async fetchFileList(): Promise<FileMeta[]> {
const files: FileMeta[] = [];
for await (const meta of this.kv.query({})) {
files.push(meta.value);
}
return files;
}
async getFileMeta(name: string): Promise<FileMeta> {
const fileMeta = (await this.kv.batchGet([[name]]))[0];
if (!fileMeta) {
throw new Error("Not found");
}
return fileMeta;
}
}
+22
View File
@@ -0,0 +1,22 @@
import { S3SpacePrimitives } from "./s3_space_primitives.ts";
import { MemoryKvPrimitives } from "../../plugos/lib/memory_kv_primitives.ts";
import { testSpacePrimitives } from "./space_primitives.test.ts";
Deno.test("s3_space_primitives", async () => {
return;
const options = {
accessKey: Deno.env.get("AWS_ACCESS_KEY_ID")!,
secretKey: Deno.env.get("AWS_SECRET_ACCESS_KEY")!,
endPoint: Deno.env.get("AWS_ENDPOINT")!,
region: Deno.env.get("AWS_REGION")!,
bucket: Deno.env.get("AWS_BUCKET")!,
};
const primitives = new S3SpacePrimitives(
new MemoryKvPrimitives(),
["meta"],
"test",
options,
);
await testSpacePrimitives(primitives);
});
+117
View File
@@ -0,0 +1,117 @@
// We're explicitly using 0.4.0 to be able to hijack the path encoding, which is inconsisently broken in 0.5.0
import { S3Client } from "https://deno.land/x/s3_lite_client@0.4.0/mod.ts";
import type { ClientOptions } from "https://deno.land/x/s3_lite_client@0.4.0/client.ts";
import { KvMetaSpacePrimitives } from "./kv_meta_space_primitives.ts";
import { KvPrimitives } from "../../plugos/lib/kv_primitives.ts";
import { mime } from "../deps.ts";
import { KV, KvKey } from "$sb/types.ts";
import { PrefixedKvPrimitives } from "../../plugos/lib/prefixed_kv_primitives.ts";
export type S3SpacePrimitivesOptions = ClientOptions;
/**
* Because S3 cannot store arbitrary metadata (well it can, but you cannot retrieve it when listing objects), we need to store it in a separate KV store
*/
export class S3SpacePrimitives extends KvMetaSpacePrimitives {
client: S3Client;
objectPrefix: string;
constructor(
baseKv: KvPrimitives,
metaPrefix: KvKey,
objectPrefix: string,
options: S3SpacePrimitivesOptions,
) {
const client = new S3Client(options);
super(new PrefixedKvPrimitives(baseKv, metaPrefix), {
async readFile(
name: string,
): Promise<Uint8Array> {
try {
const obj = await client.getObject(encodePath(objectPrefix + name));
return new Uint8Array(await obj.arrayBuffer());
} catch (e: any) {
console.error("Got S3 error", e.message);
if (e.message.includes("does not exist")) {
throw new Error(`Not found`);
}
throw e;
}
},
async writeFile(
name: string,
data: Uint8Array,
): Promise<void> {
await client.putObject(encodePath(objectPrefix + name), data);
},
async deleteFile(name: string): Promise<void> {
await client.deleteObject(encodePath(objectPrefix + name));
},
});
this.client = client;
this.objectPrefix = objectPrefix;
}
/**
* Fetches all objects from S3 bucket, finds any missing files and adds them to the KV store
* Doesn't delete items, nor update any existing items
*/
async syncFileList(): Promise<void> {
const currentFiles = await this.fetchFileList();
const entriesToAdd: KV[] = [];
for await (
const objectData of this.client.listObjects({
prefix: this.objectPrefix,
})
) {
// Find the file meta for this object
let fileMeta = currentFiles.find((f) =>
f.name === decodePath(objectData.key.slice(this.objectPrefix.length))
);
if (fileMeta) {
// Exists, continue
continue;
}
fileMeta = {
name: decodePath(objectData.key.slice(this.objectPrefix.length)),
created: objectData.lastModified.getTime(),
lastModified: objectData.lastModified.getTime(),
contentType: mime.getType(objectData.key) || "application/octet-stream",
size: objectData.size,
perm: "rw",
};
console.log("Adding file metadata to KV", fileMeta.name);
entriesToAdd.push({
key: [fileMeta.name],
value: fileMeta,
});
}
return this.kv.batchSet(entriesToAdd);
}
}
// Stolen from https://github.com/aws/aws-sdk-js/blob/master/lib/util.js
function uriEscapePath(string: string): string {
return string.split("/").map(uriEscape).join("/");
}
function uriEscape(string: string): string {
let output = encodeURIComponent(string);
output = output.replace(/[^A-Za-z0-9_.~\-%]+/g, escape);
// AWS percent-encodes some extra non-standard characters in a URI
output = output.replace(/[*]/g, function (ch) {
return "%" + ch.charCodeAt(0).toString(16).toUpperCase();
});
return output;
}
function encodePath(name: string): string {
return uriEscapePath(name);
}
function decodePath(encoded: string): string {
// AWS only returns ' replace with &apos;
return encoded.replaceAll("&apos;", "'");
}
+69
View File
@@ -0,0 +1,69 @@
import { assert, assertEquals } from "../../test_deps.ts";
import { SpacePrimitives } from "./space_primitives.ts";
export async function testSpacePrimitives(spacePrimitives: SpacePrimitives) {
const files = await spacePrimitives.fetchFileList();
assertEquals(files, []);
// Write text file
const fileMeta = await spacePrimitives.writeFile(
"test.txt",
stringToBytes("Hello World"),
false,
{
name: "test.txt",
perm: "rw",
created: 10,
contentType: "text/plain",
lastModified: 20,
size: 11,
},
);
const { data: retrievedData, meta: retrievedMeta } = await spacePrimitives
.readFile("test.txt");
assertEquals(retrievedData, stringToBytes("Hello World"));
// Check that the meta data is persisted
assertEquals(retrievedMeta.lastModified, 20);
const fbContent = (await spacePrimitives.readFile("test.txt"))
.data;
assertEquals(new TextDecoder().decode(fbContent), "Hello World");
assertEquals(await spacePrimitives.fetchFileList(), [fileMeta]);
const buf = new Uint8Array(1024 * 1024);
buf.set([1, 2, 3, 4, 5]);
// Write binary file
await spacePrimitives.writeFile("test.bin", buf);
const fMeta = await spacePrimitives.getFileMeta("test.bin");
assertEquals(fMeta.size, 1024 * 1024);
assertEquals((await spacePrimitives.fetchFileList()).length, 2);
// console.log(spacePrimitives);
await spacePrimitives.deleteFile("test.bin");
assertEquals(await spacePrimitives.fetchFileList(), [fileMeta]);
// Clean up
await spacePrimitives.deleteFile("test.txt");
assertEquals(await spacePrimitives.fetchFileList(), []);
// Test weird file names
await spacePrimitives.writeFile("test+'s.txt", stringToBytes("Hello world!"));
assertEquals(
stringToBytes("Hello world!"),
(await spacePrimitives.readFile("test+'s.txt")).data,
);
await spacePrimitives.deleteFile("test+'s.txt");
// Check deletion of weird file file name
try {
await spacePrimitives.getFileMeta("test+'s.txt");
assert(false);
} catch (e: any) {
assertEquals(e.message, "Not found");
}
}
function stringToBytes(str: string): Uint8Array {
return new TextEncoder().encode(str);
}
+11 -6
View File
@@ -1,21 +1,26 @@
// export type FileEncoding = "utf8" | "arraybuffer" | "dataurl";
// export type FileData = ArrayBuffer | string;
import { FileMeta } from "$sb/types.ts";
import type { FileMeta } from "$sb/types.ts";
/**
* A generic interface used by `Space` to interact with the underlying storage, designed to be easy to implement for different storage backends
*/
export interface SpacePrimitives {
// Returns a list of file meta data as well as the timestamp of this snapshot
fetchFileList(): Promise<FileMeta[]>;
// The result of this should be consistent with the result of fetchFileList for this entry
getFileMeta(name: string): Promise<FileMeta>;
readFile(
name: string,
): Promise<{ data: Uint8Array; meta: FileMeta }>;
getFileMeta(name: string): Promise<FileMeta>;
writeFile(
name: string,
data: Uint8Array,
// Used to decide whether or not to emit change events
selfUpdate?: boolean,
// May be ignored, but ideally should be used to set the lastModified time
meta?: FileMeta,
): Promise<FileMeta>;
deleteFile(name: string): Promise<void>;
}