@x5e/gink
Version:
an eventually consistent database
453 lines (415 loc) • 16.5 kB
text/typescript
import {
Medallion,
ChainStart,
Bytes,
AsOf,
ScalarKey,
ClaimedChain,
ActorId,
BroadcastFunc,
BundleView,
KeyPair,
Value,
Placement,
MuidTuple,
} from "./typedefs";
import { BundleInfo, Muid, Entry } from "./typedefs";
import { MemoryStore } from "./MemoryStore";
import { Store } from "./Store";
import { PromiseChainLock } from "./PromiseChainLock";
import { LockableLog } from "./LockableLog";
import { watch, FSWatcher } from "fs";
import { HasMap } from "./HasMap";
import { ClaimBuilder, LogFileBuilder, KeyPairBuilder } from "./builders";
import {
generateTimestamp,
ensure,
getActorId,
concatenate,
isAlive,
} from "./utils";
import { Decomposition } from "./Decomposition";
/*
At time of writing, there's only an in-memory implementation of
IndexedDB available for Node.js. This subclass will append all
transactions it receives to a log file, making it possible to
recreate the same in-memory database in the future by simply
replaying the receipt of each bundle.
This is obviously not ideal; eventually want to move to either
a durable server side indexedDB implementation or create an
implementation of Store using some other system (e.g. LMDB).
*/
export class LogBackedStore extends LockableLog implements Store {
private bundlesProcessed = 0;
private hasMap: HasMap = new HasMap({});
private claimedChains: Map<number, ClaimedChain> = new Map();
private identities: Map<string, string> = new Map(); // Medallion,ChainStart => identity
// While the operating system lock prevents other processes from writing to this file,
// we need to prevent multiple async tasks within this process from trying to interleave operations.
// The memory lock accomplishes this. It might not strictly be necessary, because of how the
// rest of the system is designed, but the overhead is expected to be minimal and may prevent
// some foot shooting.
private memoryLock: PromiseChainLock = new PromiseChainLock();
private redTo: number = 0;
private fileWatcher: FSWatcher;
private foundBundleCallBacks: BroadcastFunc[] = [];
private opened: boolean = false;
private closed: boolean = false;
private logBackedStoreReady: Promise<void>;
/**
*
* @param filename file to store transactions and chain ownership information
* @param exclusive if true, lock the file until closing store, otherwise only lock as-needed.
*/
constructor(
readonly filename: string,
readonly exclusive: boolean = false,
private internalStore = new MemoryStore(),
) {
super(filename, exclusive);
this.logBackedStoreReady = super.ready.then(() =>
this.initializeLogBackedStore(),
);
}
get ready() {
return this.logBackedStoreReady;
}
async getBillionths(muid: Muid, asOf?: AsOf): Promise<bigint> {
await this.pullDataFromFile();
return this.internalStore.getBillionths(muid, asOf);
}
async getVerifyKey(chainInfo: [Medallion, ChainStart]): Promise<Bytes> {
await this.pullDataFromFile();
return this.internalStore.getVerifyKey(chainInfo);
}
async saveKeyPair(keyPair: KeyPair): Promise<void> {
await this.logBackedStoreReady;
const unlockingFunction = await this.memoryLock.acquireLock();
if (!this.exclusive) {
await this.lockFile(true);
}
ensure(this.fileLocked, "expected to be locked");
if (this.redTo === 0) this.redTo += await this.writeMagicNumber();
const keyPairBuilder = new KeyPairBuilder();
keyPairBuilder.setPublicKey(keyPair.publicKey);
keyPairBuilder.setSecretKey(keyPair.secretKey.slice(0, 32));
const logFragment = new LogFileBuilder();
logFragment.setKeyPairsList([keyPairBuilder]);
this.redTo += await this.writeLogFragment(logFragment, true);
if (!this.exclusive) await this.unlockFile();
unlockingFunction();
await this.internalStore.saveKeyPair(keyPair);
}
async pullKeyPair(publicKey: Bytes): Promise<KeyPair> {
return await this.internalStore.pullKeyPair(publicKey);
}
async saveSymmetricKey(symmetricKey: Bytes): Promise<number> {
return await this.internalStore.saveSymmetricKey(symmetricKey);
}
async getSymmetricKey(keyId: number): Promise<Bytes> {
await this.pullDataFromFile();
return await this.internalStore.getSymmetricKey(keyId);
}
private async initializeLogBackedStore(): Promise<void> {
await this.internalStore.ready;
const unlockingFunction = await this.memoryLock.acquireLock();
if (this.exclusive) {
ensure(this.fileLocked);
} else {
await this.lockFile(true);
}
await this.pullDataFromFile();
if (!this.exclusive) {
this.unlockFile();
}
unlockingFunction();
this.opened = true;
this.fileWatcher = watch(
this.filename,
async (eventType, _filename) => {
await new Promise((r) => setTimeout(r, 10));
if (this.closed || !this.opened) return;
let size: number = await this.getFileLength();
if (eventType === "change" && size > this.redTo) {
const unlockingFunction =
await this.memoryLock.acquireLock();
if (!this.exclusive) await this.lockFile(true);
await this.pullDataFromFile();
if (!this.exclusive) await this.unlockFile();
unlockingFunction();
}
},
);
}
async close() {
this.closed = true;
if (this.fileWatcher) this.fileWatcher.close();
if (this.fileLocked) await this.unlockFile().catch();
if (this.fileHandle) await this.fileHandle.close().catch();
if (this.internalStore) await this.internalStore.close().catch();
}
private async pullDataFromFile(): Promise<void> {
if (this.closed) return;
let totalSize = await this.getFileLength();
if (this.redTo == totalSize) return;
let lockedByPull = false;
if (!this.fileLocked) {
await this.lockFile(true);
totalSize = await this.getFileLength();
lockedByPull = true;
}
const logFileBuilder = await this.getLogContents(this.redTo, totalSize);
if (this.redTo === 0) {
ensure(
logFileBuilder.getMagicNumber() === 1263421767,
"log file doesn't have magic number",
);
}
const claims: ClaimBuilder[] = logFileBuilder.getClaimsList();
for (let i = 0; i < claims.length; i++) {
this.claimedChains.set(claims[i].getMedallion(), {
medallion: claims[i].getMedallion(),
chainStart: claims[i].getChainStart(),
actorId: claims[i].getProcessId(),
claimTime: claims[i].getClaimTime(),
});
}
const keyPairs: KeyPairBuilder[] = logFileBuilder.getKeyPairsList();
for (let i = 0; i < keyPairs.length; i++) {
const publicKey = keyPairs[i].getPublicKey_asU8();
const secretKey = keyPairs[i].getSecretKey_asU8();
this.internalStore.saveKeyPair({
publicKey,
secretKey: concatenate(secretKey, publicKey),
});
}
const bundles = logFileBuilder.getBundlesList();
for (const bundleBytes of bundles) {
const bundle: BundleView = new Decomposition(bundleBytes);
const added = await this.internalStore.addBundle(bundle);
if (!added) throw new Error("unexpected not added");
const info = bundle.info;
const identity = bundle.builder.getIdentity();
this.hasMap.markAsHaving(bundle.info);
// This is the start of a chain, and we need to keep track of the identity.
if (info.timestamp === info.chainStart && !info.priorTime) {
ensure(identity, "chain start bundle has no identity");
this.identities.set(
`${info.medallion},${info.chainStart}`,
identity,
);
} else {
ensure(!identity, "non-chain-start bundle has identity");
}
for (const callback of this.foundBundleCallBacks) {
callback(bundle);
}
this.bundlesProcessed += 1;
}
this.redTo = totalSize;
if (lockedByPull) {
await this.unlockFile();
}
}
async getContainerProperties(
containerMuid: Muid,
asOf?: AsOf,
): Promise<Map<string, Value>> {
await this.pullDataFromFile();
return this.internalStore.getContainerProperties(containerMuid, asOf);
}
async getOrderedEntries(
container: Muid,
through = Infinity,
asOf?: AsOf,
): Promise<Map<string, Entry>> {
await this.ready;
if (!this.exclusive) await this.pullDataFromFile();
return this.internalStore.getOrderedEntries(container, through, asOf);
}
async getEntriesBySourceOrTarget(
vertex: Muid,
source: boolean,
asOf?: AsOf,
): Promise<Entry[]> {
await this.ready;
if (!this.exclusive) await this.pullDataFromFile();
return this.internalStore.getEntriesBySourceOrTarget(
vertex,
source,
asOf,
);
}
async getBundlesProcessed() {
await this.ready;
if (!this.exclusive) await this.pullDataFromFile();
return this.bundlesProcessed;
}
async getLocation(entry: Muid, asOf?: AsOf): Promise<Placement> {
await this.ready;
if (!this.exclusive) await this.pullDataFromFile();
return await this.internalStore.getLocation(entry, asOf);
}
async addBundle(
bundle: BundleView,
claimChain?: boolean,
): Promise<Boolean> {
// TODO(https://github.com/x5e/gink/issues/182): delay unlocking the file to give better throughput
await this.ready;
let added = false;
const unlockingFunction = await this.memoryLock.acquireLock();
if (!this.exclusive) await this.lockFile(true);
try {
await this.pullDataFromFile();
if (this.redTo === 0) this.redTo += await this.writeMagicNumber();
const info: BundleInfo = bundle.info;
added = await this.internalStore.addBundle(bundle);
const identity = bundle.builder.getIdentity();
if (identity) {
this.identities.set(
`${info.medallion},${info.chainStart}`,
bundle.builder.getIdentity(),
);
}
if (claimChain) {
if (!added) throw new Error("can't claim chain on old bundle");
await this.claimChain(
info.medallion,
info.chainStart,
getActorId(),
);
}
this.hasMap.markAsHaving(info);
if (added) {
ensure(this.fileLocked);
await this.pullDataFromFile();
const logFragment = new LogFileBuilder();
logFragment.setBundlesList([bundle.bytes]);
this.redTo += await this.writeLogFragment(logFragment, true);
}
} finally {
unlockingFunction();
if (!this.exclusive) await this.unlockFile();
}
return added;
}
async acquireChain(identity: string): Promise<BundleInfo | null> {
await this.ready;
if (!this.exclusive) {
await this.lockFile(true);
}
await this.pullDataFromFile();
let found: BundleInfo | null = null;
for (const claim of this.claimedChains.values()) {
if (await isAlive(claim.actorId)) {
continue; // don't want to conflict with a current process
}
const medallion = claim.medallion;
const chainStart = claim.chainStart;
const chainId = this.identities.get(`${medallion},${chainStart}`);
if (identity != chainId) {
continue; // don't want to step on someone else's toes
}
await this.claimChain(medallion, chainStart, getActorId());
found = this.hasMap.getBundleInfo([medallion, chainStart]);
break;
}
if (!this.exclusive) await this.unlockFile();
return found;
}
private async claimChain(
medallion: Medallion,
chainStart: ChainStart,
actorId?: ActorId,
): Promise<ClaimedChain> {
ensure(this.fileLocked, "file not locked?");
const claimTime = generateTimestamp();
const fragment = new LogFileBuilder();
const claim = new ClaimBuilder();
claim.setChainStart(chainStart);
claim.setMedallion(medallion);
claim.setProcessId(actorId);
claim.setClaimTime(claimTime);
fragment.setClaimsList([claim]);
this.redTo += await this.writeLogFragment(fragment);
const chain = {
medallion,
chainStart,
actorId: actorId || 0,
claimTime,
};
this.claimedChains.set(medallion, chain);
return chain;
}
async getChainIdentity(
chainInfo: [Medallion, ChainStart],
): Promise<string> {
await this.ready;
if (!this.exclusive) await this.pullDataFromFile();
return this.identities.get(`${chainInfo[0]},${chainInfo[1]}`);
}
async getChainTracker(): Promise<HasMap> {
await this.ready;
if (!this.exclusive) await this.pullDataFromFile();
return await this.internalStore.getChainTracker();
}
async getBundles(callBack: (bundle: BundleView) => void): Promise<void> {
await this.ready;
if (!this.exclusive) await this.pullDataFromFile();
await this.internalStore.getBundles(callBack);
}
async getContainerBytes(address: Muid): Promise<Bytes | undefined> {
await this.ready;
if (!this.exclusive) await this.pullDataFromFile();
return this.internalStore.getContainerBytes(address);
}
async getEntryByKey(
container?: Muid,
key?: ScalarKey,
asOf?: AsOf,
): Promise<Entry | undefined> {
await this.ready;
if (!this.exclusive) await this.pullDataFromFile();
return this.internalStore.getEntryByKey(container, key, asOf);
}
async getKeyedEntries(
container: Muid,
asOf?: AsOf,
): Promise<Map<string, Entry>> {
await this.ready;
if (!this.exclusive) await this.pullDataFromFile();
return this.internalStore.getKeyedEntries(container, asOf);
}
async getEntryById(
entryMuid: Muid,
asOf?: AsOf,
): Promise<Entry | undefined> {
await this.ready;
if (!this.exclusive) await this.pullDataFromFile();
return this.internalStore.getEntryById(entryMuid, asOf);
}
async getAllEntries(): Promise<Entry[]> {
await this.ready;
if (!this.exclusive) await this.pullDataFromFile();
return this.internalStore.getAllEntries();
}
async getContainersByName(name: string, asOf?: AsOf): Promise<Muid[]> {
await this.ready;
if (!this.exclusive) await this.pullDataFromFile();
return await this.internalStore.getContainersByName(name, asOf);
}
async getAllContainerTuples(): Promise<MuidTuple[]> {
await this.ready;
if (!this.exclusive) await this.pullDataFromFile();
return await this.internalStore.getAllContainerTuples();
}
/**
* Add a callback if you want another function to run when a new
* bundle is pulled from the log file.
* @param callback a function to be called when a new bundle has been
* received from the log file. It needs to take one argument, bundleInfo
*/
addFoundBundleCallBack(callback: BroadcastFunc) {
this.foundBundleCallBacks.push(callback);
}
}