1
0

Backporting a bunch of optimizations from db-only branch

This commit is contained in:
Zef Hemel 2024-01-13 17:30:15 +01:00
parent 509ece91f0
commit bf1eb03129
24 changed files with 264 additions and 104 deletions

View File

@ -5,16 +5,19 @@ import { LimitedMap } from "./limited_map.ts";
Deno.test("limited map", async () => {
const mp = new LimitedMap<string>(3);
mp.set("a", "a");
mp.set("b", "b");
mp.set("b", "b", 5);
mp.set("c", "c");
await sleep(2);
assertEquals(mp.get("a"), "a");
await sleep(2);
assertEquals(mp.get("b"), "b");
await sleep(2);
assertEquals(mp.get("c"), "c");
// Drops the first key
mp.set("d", "d");
await sleep(2);
// console.log(mp.toJSON());
assertEquals(mp.get("a"), undefined);
await sleep(10);
// "b" should have been dropped
assertEquals(mp.get("b"), undefined);
assertEquals(mp.get("c"), "c");
console.log(mp.toJSON());
});

View File

@ -1,20 +1,36 @@
type LimitedMapRecord<V> = Record<string, { value: V; la: number }>;
type LimitedMapRecord<V> = { value: V; la: number };
export class LimitedMap<V> {
constructor(private maxSize: number, private map: LimitedMapRecord<V> = {}) {
private map: Map<string, LimitedMapRecord<V>>;
constructor(
private maxSize: number,
initialJson: Record<string, LimitedMapRecord<V>> = {},
) {
this.map = new Map(Object.entries(initialJson));
}
set(key: string, value: V) {
if (Object.keys(this.map).length >= this.maxSize) {
/**
* @param key
* @param value
* @param ttl time to live (in ms)
*/
set(key: string, value: V, ttl?: number) {
if (ttl) {
setTimeout(() => {
this.map.delete(key);
}, ttl);
}
if (this.map.size >= this.maxSize) {
// Remove the oldest key before adding a new one
const oldestKey = this.getOldestKey();
delete this.map[oldestKey!];
this.map.delete(oldestKey!);
}
this.map[key] = { value, la: Date.now() };
this.map.set(key, { value, la: Date.now() });
}
get(key: string): V | undefined {
const entry = this.map[key];
const entry = this.map.get(key);
if (entry) {
// Update the last accessed timestamp
entry.la = Date.now();
@ -24,26 +40,23 @@ export class LimitedMap<V> {
}
remove(key: string) {
delete this.map[key];
this.map.delete(key);
}
toJSON() {
return this.map;
return Object.fromEntries(this.map.entries());
}
private getOldestKey(): string | undefined {
let oldestKey: string | undefined;
let oldestTimestamp: number | undefined;
for (const key in this.map) {
if (Object.prototype.hasOwnProperty.call(this.map, key)) {
const entry = this.map[key];
for (const [key, entry] of this.map.entries()) {
if (!oldestTimestamp || entry.la < oldestTimestamp) {
oldestKey = key;
oldestTimestamp = entry.la;
}
}
}
return oldestKey;
}

View File

@ -1,5 +1,5 @@
import { assertEquals } from "../../test_deps.ts";
import { PromiseQueue, sleep } from "./async.ts";
import { assert, assertEquals } from "../../test_deps.ts";
import { batchRequests, PromiseQueue, sleep } from "./async.ts";
Deno.test("PromiseQueue test", async () => {
const q = new PromiseQueue();
@ -24,3 +24,19 @@ Deno.test("PromiseQueue test", async () => {
});
assertEquals(wasRun, true);
});
Deno.test("Batch test", async () => {
// Generate an array with numbers up to 100
const elements = Array.from(Array(100).keys());
const multiplied = await batchRequests(elements, async (batch) => {
await sleep(2);
// Batches should be 9 or smaller (last batch will be smaller)
assert(batch.length <= 9);
return batch.map((e) => e * 2);
}, 9);
assertEquals(multiplied, elements.map((e) => e * 2));
const multiplied2 = await batchRequests(elements, async (batch) => {
return batch.map((e) => e * 2);
}, 10000);
assertEquals(multiplied2, elements.map((e) => e * 2));
});

View File

@ -67,3 +67,25 @@ export class PromiseQueue {
this.run(); // Continue processing the next promise in the queue
}
}
export async function batchRequests<I, O>(
values: I[],
fn: (batch: I[]) => Promise<O[]>,
batchSize: number,
): Promise<O[]> {
const results: O[] = [];
// Split values into batches of batchSize
const batches: I[][] = [];
for (let i = 0; i < values.length; i += batchSize) {
batches.push(values.slice(i, i + batchSize));
}
// Run fn on them in parallel
const batchResults = await Promise.all(batches.map(fn));
// Flatten the results
for (const batchResult of batchResults) {
if (Array.isArray(batchResult)) { // If fn returns an array, collect them
results.push(...batchResult);
}
}
return results;
}

View File

@ -73,6 +73,11 @@ export type Query = {
render?: string;
renderAll?: boolean;
distinct?: boolean;
/**
* When set, the DS implementation _may_ cache the result for the given number of seconds.
*/
cacheSecs?: number;
};
export type KvQuery = Omit<Query, "querySource"> & {

View File

@ -46,6 +46,7 @@ export class EventHook implements Hook<EventHookT> {
throw new Error("Event hook is not initialized");
}
const responses: any[] = [];
const promises: Promise<void>[] = [];
for (const plug of this.system.loadedPlugs.values()) {
const manifest = plug.manifest;
for (
@ -60,6 +61,8 @@ export class EventHook implements Hook<EventHookT> {
) {
// Only dispatch functions that can run in this environment
if (await plug.canInvoke(name)) {
// Queue the promise
promises.push((async () => {
try {
const result = await plug.invoke(name, args);
if (result !== undefined) {
@ -70,6 +73,7 @@ export class EventHook implements Hook<EventHookT> {
`Error dispatching event ${eventName} to plug ${plug.name}: ${e.message}`,
);
}
})());
}
}
}
@ -79,13 +83,19 @@ export class EventHook implements Hook<EventHookT> {
const localListeners = this.localListeners.get(eventName);
if (localListeners) {
for (const localListener of localListeners) {
// Queue the promise
promises.push((async () => {
const result = await Promise.resolve(localListener(...args));
if (result) {
responses.push(result);
}
})());
}
}
// Wait for all promises to resolve
await Promise.all(promises);
return responses;
}

View File

@ -3,6 +3,7 @@ import { System } from "../system.ts";
import { fullQueueName } from "../lib/mq_util.ts";
import { MQMessage } from "$sb/types.ts";
import { MessageQueue } from "../lib/mq.ts";
import { throttle } from "$sb/lib/async.ts";
type MQSubscription = {
queue: string;
@ -24,14 +25,14 @@ export class MQHook implements Hook<MQHookT> {
this.system = system;
system.on({
plugLoaded: () => {
this.reloadQueues();
this.throttledReloadQueues();
},
plugUnloaded: () => {
this.reloadQueues();
this.throttledReloadQueues();
},
});
this.reloadQueues();
this.throttledReloadQueues();
}
stop() {
@ -40,6 +41,10 @@ export class MQHook implements Hook<MQHookT> {
this.subscriptions = [];
}
throttledReloadQueues = throttle(() => {
this.reloadQueues();
}, 1000);
reloadQueues() {
this.stop();
for (const plug of this.system.loadedPlugs.values()) {

View File

@ -7,7 +7,7 @@ import { assertEquals } from "https://deno.land/std@0.165.0/testing/asserts.ts";
import { PrefixedKvPrimitives } from "./prefixed_kv_primitives.ts";
async function test(db: KvPrimitives) {
const datastore = new DataStore(new PrefixedKvPrimitives(db, ["ds"]), {
const datastore = new DataStore(new PrefixedKvPrimitives(db, ["ds"]), false, {
count: (arr: any[]) => arr.length,
});
await datastore.set(["user", "peter"], { name: "Peter" });

View File

@ -2,14 +2,18 @@ import { applyQueryNoFilterKV, evalQueryExpression } from "$sb/lib/query.ts";
import { FunctionMap, KV, KvKey, KvQuery } from "$sb/types.ts";
import { builtinFunctions } from "$sb/lib/builtin_query_functions.ts";
import { KvPrimitives } from "./kv_primitives.ts";
import { LimitedMap } from "../../common/limited_map.ts";
/**
* This is the data store class you'll actually want to use, wrapping the primitives
* in a more user-friendly way
*/
export class DataStore {
private cache = new LimitedMap<any>(20);
constructor(
readonly kv: KvPrimitives,
private enableCache = false,
private functionMap: FunctionMap = builtinFunctions,
) {
}
@ -50,6 +54,21 @@ export class DataStore {
}
async query<T = any>(query: KvQuery): Promise<KV<T>[]> {
let cacheKey: string | undefined;
const cacheSecs = query.cacheSecs;
// Should we do caching?
if (cacheSecs && this.enableCache) {
// Remove the cacheSecs from the query
query = { ...query, cacheSecs: undefined };
console.log("Going to cache query", query);
cacheKey = JSON.stringify(query);
const cachedResult = this.cache.get(cacheKey);
if (cachedResult) {
// Let's use the cached result
return cachedResult;
}
}
const results: KV<T>[] = [];
let itemCount = 0;
// Accumulate results
@ -76,7 +95,12 @@ export class DataStore {
}
}
// Apply order by, limit, and select
return applyQueryNoFilterKV(query, results, this.functionMap);
const finalResult = applyQueryNoFilterKV(query, results, this.functionMap);
if (cacheKey) {
// Store in the cache
this.cache.set(cacheKey, finalResult, cacheSecs! * 1000);
}
return finalResult;
}
async queryDelete(query: KvQuery): Promise<void> {

View File

@ -23,6 +23,9 @@ export class DataStoreMQ implements MessageQueue {
seq = 0;
async batchSend(queue: string, bodies: any[]): Promise<void> {
if (bodies.length === 0) {
return;
}
const messages: KV<MQMessage>[] = bodies.map((body) => {
const id = `${Date.now()}-${String(++this.seq).padStart(6, "0")}`;
const key = [...queuedPrefix, queue, id];
@ -54,6 +57,9 @@ export class DataStoreMQ implements MessageQueue {
prefix: [...queuedPrefix, queue],
limit: ["number", maxItems],
});
if (messages.length === 0) {
return [];
}
// Put them in the processing queue
await this.ds.batchSet(
messages.map((m) => ({
@ -137,6 +143,9 @@ export class DataStoreMQ implements MessageQueue {
}
async batchAck(queue: string, ids: string[]) {
if (ids.length === 0) {
return;
}
await this.ds.batchDelete(
ids.map((id) => [...processingPrefix, queue, id]),
);
@ -152,6 +161,9 @@ export class DataStoreMQ implements MessageQueue {
prefix: processingPrefix,
filter: ["<", ["attr", "ts"], ["number", now - timeout]],
});
if (messages.length === 0) {
return;
}
await this.ds.batchDelete(messages.map((m) => m.key));
const newMessages: KV<ProcessingMessage>[] = [];
for (const { value: m } of messages) {

View File

@ -18,7 +18,9 @@ export async function pageComplete(completeEvent: CompleteEvent) {
completeEvent.linePrefix,
);
const tagToQuery = isInTemplateContext ? "template" : "page";
let allPages: PageMeta[] = await queryObjects<PageMeta>(tagToQuery, {});
let allPages: PageMeta[] = await queryObjects<PageMeta>(tagToQuery, {
cacheSecs: 5,
});
const prefix = match[1];
if (prefix.startsWith("!")) {
// Federation prefix, let's first see if we're matching anything from federation that is locally synced

View File

@ -43,7 +43,10 @@ export async function anchorComplete(completeEvent: CompleteEvent) {
// "bare" anchor, match any page for completion purposes
filter = undefined;
}
const allAnchors = await queryObjects<AnchorObject>("anchor", { filter });
const allAnchors = await queryObjects<AnchorObject>("anchor", {
filter,
cacheSecs: 5,
});
return {
from: completeEvent.pos - match[1].length,
options: allAnchors.map((a) => ({

View File

@ -64,7 +64,7 @@ export async function clearIndex(): Promise<void> {
/**
* Indexes entities in the data store
*/
export async function indexObjects<T>(
export function indexObjects<T>(
page: string,
objects: ObjectValue<T>[],
): Promise<void> {
@ -127,14 +127,12 @@ export async function indexObjects<T>(
}
}
if (allAttributes.size > 0) {
await indexObjects<AttributeObject>(
page,
[...allAttributes].map(([key, value]) => {
[...allAttributes].forEach(([key, value]) => {
const [tagName, name] = key.split(":");
const attributeType = value.startsWith("!")
? value.substring(1)
: value;
return {
const attributeType = value.startsWith("!") ? value.substring(1) : value;
kvs.push({
key: ["attribute", cleanKey(key, page)],
value: {
ref: key,
tag: "attribute",
tagName,
@ -142,11 +140,15 @@ export async function indexObjects<T>(
attributeType,
readOnly: value.startsWith("!"),
page,
};
}),
);
} as T,
});
});
}
if (kvs.length > 0) {
return batchSet(page, kvs);
} else {
return Promise.resolve();
}
}
function cleanKey(ref: string, page: string) {

View File

@ -42,23 +42,17 @@ export function determineType(v: any): string {
export async function objectAttributeCompleter(
attributeCompleteEvent: AttributeCompleteEvent,
): Promise<AttributeCompletion[]> {
const prefixFilter: QueryExpression = ["call", "startsWith", [[
"attr",
"name",
], ["string", attributeCompleteEvent.prefix]]];
const attributeFilter: QueryExpression | undefined =
attributeCompleteEvent.source === ""
? prefixFilter
: ["and", prefixFilter, ["=", ["attr", "tagName"], [
"string",
attributeCompleteEvent.source,
]]];
? undefined
: ["=", ["attr", "tagName"], ["string", attributeCompleteEvent.source]];
const allAttributes = await queryObjects<AttributeObject>("attribute", {
filter: attributeFilter,
distinct: true,
select: [{ name: "name" }, { name: "attributeType" }, { name: "tag" }, {
name: "readOnly",
}],
cacheSecs: 5,
});
return allAttributes.map((value) => {
return {

View File

@ -90,19 +90,17 @@ export const builtins: Record<string, Record<string, string>> = {
export async function loadBuiltinsIntoIndex() {
console.log("Loading builtins attributes into index");
const allTags: ObjectValue<TagObject>[] = [];
const allObjects: ObjectValue<any>[] = [];
for (const [tagName, attributes] of Object.entries(builtins)) {
allTags.push({
allObjects.push({
ref: tagName,
tag: "tag",
name: tagName,
page: builtinPseudoPage,
parent: "builtin",
});
await indexObjects<AttributeObject>(
builtinPseudoPage,
Object.entries(attributes).map(([name, attributeType]) => {
return {
allObjects.push(
...Object.entries(attributes).map(([name, attributeType]) => ({
ref: `${tagName}:${name}`,
tag: "attribute",
tagName,
@ -112,9 +110,8 @@ export async function loadBuiltinsIntoIndex() {
: attributeType,
readOnly: attributeType.startsWith("!"),
page: builtinPseudoPage,
};
}),
})),
);
}
await indexObjects(builtinPseudoPage, allTags);
await indexObjects(builtinPseudoPage, allObjects);
}

View File

@ -23,13 +23,12 @@ functions:
env: server
query:
path: api.ts:query
env: server
indexObjects:
path: api.ts:indexObjects
env: server
queryObjects:
path: api.ts:queryObjects
env: server
# Note: not setting env: server to allow for client-side datastore query caching
getObjectByRef:
path: api.ts:getObjectByRef
env: server

View File

@ -14,8 +14,12 @@ export async function lintYAML({ tree }: LintEvent): Promise<LintDiagnostic[]> {
const diagnostics: LintDiagnostic[] = [];
const frontmatter = await extractFrontmatter(tree);
const tags = ["page", ...frontmatter.tags || []];
await traverseTreeAsync(tree, async (node) => {
if (node.type === "FrontMatterCode") {
// Query all readOnly attributes for pages with this tag set
const readOnlyAttributes = await queryObjects<AttributeObject>("attribute", {
const readOnlyAttributes = await queryObjects<AttributeObject>(
"attribute",
{
filter: ["and", ["=", ["attr", "tagName"], [
"array",
tags.map((tag): QueryExpression => ["string", tag]),
@ -26,9 +30,8 @@ export async function lintYAML({ tree }: LintEvent): Promise<LintDiagnostic[]> {
]],
distinct: true,
select: [{ name: "name" }],
});
await traverseTreeAsync(tree, async (node) => {
if (node.type === "FrontMatterCode") {
},
);
const lintResult = await lintYaml(
renderToText(node),
node.from!,

View File

@ -73,6 +73,7 @@ export async function tagComplete(completeEvent: CompleteEvent) {
filter: ["=", ["attr", "parent"], ["string", parent]],
select: [{ name: "name" }],
distinct: true,
cacheSecs: 5,
});
if (parent === "page") {

View File

@ -91,7 +91,6 @@ export async function performQuery(parsedQuery: Query, pageObject: PageMeta) {
export async function lintQuery(
{ name, tree }: LintEvent,
): Promise<LintDiagnostic[]> {
const pageObject = await loadPageObject(name);
const diagnostics: LintDiagnostic[] = [];
await traverseTreeAsync(tree, async (node) => {
if (node.type === "FencedCode") {
@ -111,6 +110,7 @@ export async function lintQuery(
}
const bodyText = codeText.children![0].text!;
try {
const pageObject = await loadPageObject(name);
const parsedQuery = await parseQuery(
await replaceTemplateVars(bodyText, pageObject),
);

View File

@ -9,7 +9,9 @@ export async function completeTaskState(completeEvent: CompleteEvent) {
if (!taskMatch) {
return null;
}
const allStates = await queryObjects<TaskStateObject>("taskstate", {});
const allStates = await queryObjects<TaskStateObject>("taskstate", {
cacheSecs: 5,
});
const states = [...new Set(allStates.map((s) => s.state))];
return {

View File

@ -56,6 +56,7 @@ export async function templateSlashComplete(
"boolean",
false,
]]],
cacheSecs: 5,
});
return allTemplates.map((template) => ({
label: template.trigger!,

View File

@ -9,3 +9,16 @@ export {
} from "https://deno.land/x/oak@v12.4.0/mod.ts";
export * as etag from "https://deno.land/x/oak@v12.4.0/etag.ts";
export { oakCors } from "https://deno.land/x/cors@v1.2.2/mod.ts";
export { Hono } from "https://deno.land/x/hono@v3.12.2/mod.ts";
export {
deleteCookie,
getCookie,
setCookie,
} from "https://deno.land/x/hono@v3.12.2/helper.ts";
export { cors } from "https://deno.land/x/hono@v3.12.2/middleware.ts";
export type {
HonoRequest,
// Next,
} from "https://deno.land/x/hono@v3.12.2/mod.ts";

View File

@ -135,7 +135,7 @@ export class Client {
`${this.dbPrefix}_state`,
);
await stateKvPrimitives.init();
this.stateDataStore = new DataStore(stateKvPrimitives);
this.stateDataStore = new DataStore(stateKvPrimitives, true);
// Setup message queue
this.mq = new DataStoreMQ(this.stateDataStore);

View File

@ -1,15 +1,48 @@
import { KvQuery } from "$sb/types.ts";
import { LimitedMap } from "../../common/limited_map.ts";
import type { SysCallMapping } from "../../plugos/system.ts";
import type { Client } from "../client.ts";
import { proxySyscalls } from "./util.ts";
import { proxySyscall, proxySyscalls } from "./util.ts";
export function dataStoreProxySyscalls(client: Client): SysCallMapping {
return proxySyscalls(client, [
const syscalls = proxySyscalls(client, [
"datastore.delete",
"datastore.set",
"datastore.batchSet",
"datastore.batchDelete",
"datastore.batchGet",
"datastore.get",
"datastore.query",
]);
// Add a cache for datastore.query
const queryCache = new LimitedMap<any>(5);
syscalls["datastore.query"] = async (ctx, query: KvQuery) => {
let cacheKey: string | undefined;
const cacheSecs = query.cacheSecs;
// Should we do caching?
if (cacheSecs) {
// Remove the cacheSecs from the query
query = { ...query, cacheSecs: undefined };
cacheKey = JSON.stringify(query);
const cachedResult = queryCache.get(cacheKey);
if (cachedResult) {
// Let's use the cached result
return cachedResult;
}
}
const result = await proxySyscall(
ctx,
client.httpSpacePrimitives,
"datastore.query",
[
query,
],
);
if (cacheKey) {
// Store in the cache
queryCache.set(cacheKey, result, cacheSecs! * 1000);
}
return result;
};
return syscalls;
}