@dao-xyz/peerbit
Version:
Distributed p2p database on IPFS
1,143 lines (1,022 loc) • 32.7 kB
text/typescript
import LazyLevel from "@dao-xyz/lazy-level";
import { AbstractLevel } from "abstract-level";
import { Level } from "level";
import { MemoryLevel } from "memory-level";
import { multiaddr, Multiaddr } from "@multiformats/multiaddr";
import type { Libp2p } from "libp2p";
import {
createExchangeHeadsMessage,
ExchangeHeadsMessage,
AbsolutMinReplicas,
EntryWithRefs,
MinReplicas,
} from "./exchange-heads.js";
import { Entry, Log, LogOptions } from "@dao-xyz/peerbit-log";
import {
serialize,
deserialize,
BorshError,
BinaryReader,
BinaryWriter,
} from "@dao-xyz/borsh";
import { TransportMessage } from "./message.js";
import {
X25519PublicKey,
AccessError,
DecryptedThing,
Ed25519Keypair,
EncryptedThing,
MaybeEncrypted,
PublicKeyEncryptionResolver,
Ed25519PublicKey,
sha256,
Identity,
} from "@dao-xyz/peerbit-crypto";
import { FastKeychain } from "./encryption.js";
import { MaybeSigned } from "@dao-xyz/peerbit-crypto";
import { Program, Address, LogCallbackOptions } from "@dao-xyz/peerbit-program";
import PQueue from "p-queue";
import { logger as loggerFn } from "@dao-xyz/peerbit-logger";
import {
DirectSub,
PubSubData,
Subscription,
SubscriptionEvent,
UnsubcriptionEvent,
waitForSubscribers,
} from "@dao-xyz/libp2p-direct-sub";
import sodium from "libsodium-wrappers";
import path from "path-browserify";
import { TimeoutError, waitFor } from "@dao-xyz/peerbit-time";
import "@libp2p/peer-id";
import { createLibp2pExtended, Libp2pExtended } from "@dao-xyz/peerbit-libp2p";
import {
OBSERVER_TYPE_VARIANT,
Replicator,
REPLICATOR_TYPE_VARIANT,
SubscriptionType,
} from "@dao-xyz/peerbit-program";
import { TrimToByteLengthOption } from "@dao-xyz/peerbit-log";
import { TrimToLengthOption } from "@dao-xyz/peerbit-log";
import { startsWith } from "@dao-xyz/uint8arrays";
import { CreateOptions as ClientCreateOptions } from "@dao-xyz/peerbit-libp2p";
import { DirectBlock } from "@dao-xyz/libp2p-direct-block";
import { LevelDatastore } from "datastore-level";
export const logger = loggerFn({ module: "peer" });
const MIN_REPLICAS = 2;
interface ProgramWithMetadata {
program: Program;
openCounter: number;
}
interface LogWithMetaata {
open: number;
log: Log<any>;
sync?: SyncFilter;
minReplicas: MinReplicas;
}
export type OptionalCreateOptions = {
limitSigning?: boolean;
minReplicas?: number;
refreshIntreval?: number;
libp2pExternal?: boolean;
};
export type CreateOptions = {
directory?: string;
cache: LazyLevel;
identity: Ed25519Keypair;
keychain: FastKeychain;
} & OptionalCreateOptions;
export type SyncFilter = (entries: Entry<any>) => Promise<boolean> | boolean;
export type CreateInstanceOptions = {
libp2p?: Libp2pExtended | ClientCreateOptions;
directory?: string;
cache?: LazyLevel;
} & OptionalCreateOptions;
export type OpenOptions = {
entryToReplicate?: Entry<any>;
role?: SubscriptionType;
sync?: SyncFilter;
timeout?: number;
minReplicas?: MinReplicas | number;
trim?: TrimToByteLengthOption | TrimToLengthOption;
reset?: boolean;
} & { log?: LogCallbackOptions };
const isLibp2pInstance = (libp2p: Libp2pExtended | ClientCreateOptions) =>
!!(libp2p as Libp2p).getMultiaddrs;
const groupByGid = async <T extends Entry<any> | EntryWithRefs<any>>(
entries: T[]
): Promise<Map<string, T[]>> => {
const groupByGid: Map<string, T[]> = new Map();
for (const head of entries) {
const gid = await (head instanceof Entry
? head.getGid()
: head.entry.getGid());
let value = groupByGid.get(gid);
if (!value) {
value = [];
groupByGid.set(gid, value);
}
value.push(head);
}
return groupByGid;
};
const createLevel = (path?: string): AbstractLevel<any, string, Uint8Array> => {
return path
? new Level(path, { valueEncoding: "view" })
: new MemoryLevel({ valueEncoding: "view" });
};
const createCache = async (
directory: string | undefined,
options?: { reset?: boolean }
) => {
const cache = await new LazyLevel(createLevel(directory));
// "Wake up" the caches if they need it
if (cache) await cache.open();
if (options?.reset) {
await cache._store.clear();
}
return cache;
};
const createSubCache = async (
from: LazyLevel,
name: string,
options?: { reset?: boolean }
) => {
const cache = await new LazyLevel(from._store.sublevel(name));
// "Wake up" the caches if they need it
if (cache) await cache.open();
if (options?.reset) {
await cache._store.clear();
}
return cache;
};
export class Peerbit {
_libp2p: Libp2pExtended;
directory?: string;
_minReplicas: number;
/// program address => Program metadata
programs: Map<string, ProgramWithMetadata>;
limitSigning: boolean;
logs: Map<string, LogWithMetaata> = new Map();
private _sortedPeersCache: Map<string, string[]> = new Map();
private _gidPeersHistory: Map<string, Set<string>> = new Map();
private _openProgramQueue: PQueue;
private _disconnected = false;
private _disconnecting = false;
private _refreshInterval: any;
private _lastSubscriptionMessageId = 0;
private _cache: LazyLevel;
private _libp2pExternal?: boolean = false;
// Libp2p peerid in Identity form
private _identityHash: string;
private _identity: Ed25519Keypair;
private _keychain: FastKeychain; // Keychain + Caching + X25519 keys
constructor(libp2p: Libp2pExtended, options: CreateOptions) {
if (libp2p == null) {
throw new Error("Libp2p required");
}
this._libp2p = libp2p;
if (this.libp2p.peerId.type !== "Ed25519") {
throw new Error(
"Unsupported id type, expecting Ed25519 but got " +
this.libp2p.peerId.type
);
}
if (this.libp2p.peerId.type !== "Ed25519") {
throw new Error("Only Ed25519 peerIds are supported");
}
this._identity = options.identity;
this._keychain = options.keychain;
this._identityHash = this._identity.publicKey.hashcode();
this.directory = options.directory;
this.programs = new Map();
this._minReplicas = options.minReplicas || MIN_REPLICAS;
this.limitSigning = options.limitSigning || false;
this._cache = options.cache;
this._libp2pExternal = options.libp2pExternal;
this._openProgramQueue = new PQueue({ concurrency: 1 });
this.libp2p.services.pubsub.addEventListener(
"data",
this._onMessage.bind(this)
);
this.libp2p.services.pubsub.addEventListener(
"subscribe",
this._onSubscription.bind(this)
);
this.libp2p.services.pubsub.addEventListener(
"unsubscribe",
this._onUnsubscription.bind(this)
);
}
static async create(options: CreateInstanceOptions = {}) {
await sodium.ready; // Some of the modules depends on sodium to be readyy
let libp2pExtended: Libp2pExtended = options.libp2p as Libp2pExtended;
const blocksDirectory =
options.directory != null
? path.join(options.directory, "/blocks").toString()
: undefined;
let libp2pExternal = false;
const datastore =
options.directory != null
? new LevelDatastore(path.join(options.directory, "/libp2p").toString())
: undefined;
if (datastore) {
await datastore.open();
}
if (!libp2pExtended) {
libp2pExtended = await createLibp2pExtended({
services: {
blocks: (c) => new DirectBlock(c, { directory: blocksDirectory }),
pubsub: (c) => new DirectSub(c),
},
// If directory is passed, we store keys within that directory, else we will use memory datastore (which is the default behaviour)
datastore,
});
} else {
if (isLibp2pInstance(libp2pExtended)) {
libp2pExternal = true; // libp2p was created outside
} else {
const extendedOptions = libp2pExtended as any as ClientCreateOptions;
libp2pExtended = await createLibp2pExtended({
...extendedOptions,
services: {
blocks: (c) => new DirectBlock(c, { directory: blocksDirectory }),
pubsub: (c) => new DirectSub(c),
...extendedOptions?.services,
},
datastore,
});
}
}
if (datastore) {
const stopFn = libp2pExtended.stop.bind(libp2pExtended);
libp2pExtended.stop = async () => {
await stopFn();
await datastore?.close();
};
}
if (!libp2pExtended.isStarted()) {
await libp2pExtended.start();
}
if (libp2pExtended.peerId.type !== "Ed25519") {
throw new Error(
"Unsupported id type, expecting Ed25519 but got " +
libp2pExtended.peerId.type
);
}
const directory = options.directory;
const cache =
options.cache ||
(await createCache(
directory ? path.join(directory, "/cache") : undefined
));
const identity = Ed25519Keypair.fromPeerId(libp2pExtended.peerId);
const peer = new Peerbit(libp2pExtended, {
directory,
cache,
libp2pExternal,
limitSigning: options.limitSigning,
minReplicas: options.minReplicas,
refreshIntreval: options.refreshIntreval,
identity,
keychain: await FastKeychain.create(identity, libp2pExtended.keychain),
});
return peer;
}
get libp2p(): Libp2pExtended {
return this._libp2p;
}
get cache() {
return this._cache;
}
get encryption(): PublicKeyEncryptionResolver {
return this._keychain;
}
get disconnected() {
return this._disconnected;
}
get disconnecting() {
return this._disconnecting;
}
get identityHash() {
return this._identityHash;
}
get identity(): Ed25519Keypair {
return this._identity;
}
async importKeypair(keypair: Ed25519Keypair) {
return this._keychain.importKeypair(keypair);
}
async exportKeypair<T extends Ed25519PublicKey | X25519PublicKey>(
publicKey: T
) {
return this._keychain.exportKeypair<T>(publicKey);
}
/**
* Dial a peer with an Ed25519 peerId
*/
async dial(address: string | Multiaddr | Multiaddr[] | Peerbit) {
const maddress =
typeof address == "string"
? multiaddr(address)
: address instanceof Peerbit
? address.libp2p.getMultiaddrs()
: address;
const connection = await this.libp2p.dial(maddress);
const publicKey = Ed25519PublicKey.fromPeerId(connection.remotePeer);
// TODO, do this as a promise instead using the onPeerConnected vents in pubsub and blocks
return waitFor(
() =>
this.libp2p.services.pubsub.peers.has(publicKey.hashcode()) &&
this.libp2p.services.blocks.peers.has(publicKey.hashcode())
);
}
async stop() {
this._disconnecting = true;
// Close a direct connection and remove it from internal state
this._refreshInterval && clearInterval(this._refreshInterval);
// Close all open databases
await Promise.all(
[...this.programs.values()].map((program) => program.program.close())
);
await this._cache.close();
// Close libp2p (after above)
if (!this._libp2pExternal) {
// only close it if we created it
await this.libp2p.stop();
}
// Remove all databases from the state
this.programs = new Map();
this._disconnecting = false;
this._disconnected = true;
}
// Callback for local writes to the database. We the update to pubsub.
onWrite<T>(_program: Program, log: Log<any>, entry: Entry<T>): void {
// TODO Should we also do gidHashhistory update here?
createExchangeHeadsMessage(
log,
[entry],
true,
this.limitSigning ? undefined : this.identity
).then((bytes) => {
this.libp2p.services.pubsub.publish(bytes, { topics: [log.idString] });
});
}
_maybeOpenStorePromise: Promise<boolean>;
// Callback for receiving a message from the network
async _onMessage(evt: CustomEvent<PubSubData>) {
const message = evt.detail;
/* logger.debug(
`${this.id}: Recieved message on topics: ${
message.topics.length > 1
? "#" + message.topics.length
: message.topics[0]
} ${message.data.length}`
); */
if (message.topics.find((x) => this.logs.has(x)) == null) {
return; // not for me
}
if (this._disconnecting) {
logger.warn("Got message while disconnecting");
return;
}
if (this._disconnected) {
if (!areWeTestingWithJest())
throw new Error("Got message while disconnected");
return; // because these could just be testing sideffects
}
try {
/* const peer =
message.type === "signed"
? (message as SignedPubSubMessage).from
: undefined; */
const maybeEncryptedMessage = deserialize(
message.data,
MaybeEncrypted
) as MaybeEncrypted<MaybeSigned<TransportMessage>>;
const decrypted = await maybeEncryptedMessage.decrypt(
this.encryption.getAnyKeypair
);
const signedMessage = decrypted.getValue(MaybeSigned);
await signedMessage.verify();
const msg = signedMessage.getValue(TransportMessage);
if (msg instanceof ExchangeHeadsMessage) {
/**
* I have recieved heads from someone else.
* I can use them to load associated logs and join/sync them with the data stores I own
*/
const { logId } = msg;
const { heads } = msg;
// replication topic === trustedNetwork address
const idString = Log.createIdString(logId);
logger.debug(
`${this.identity.publicKey.hashcode()}: Recieved heads: ${
heads.length === 1 ? heads[0].entry.hash : "#" + heads.length
}, logId: ${idString}`
);
if (heads) {
const logInfo = this.logs.get(idString);
if (!logInfo) {
logger.error(
"Missing log info, which was expected to exist for " + idString
);
return;
}
const filteredHeads: EntryWithRefs<any>[] = [];
for (const head of heads) {
if (!logInfo.log.has(head.entry.hash)) {
head.entry.init({
// we need to init because we perhaps need to decrypt gid
encryption: logInfo.log.encryption,
encoding: logInfo.log.encoding,
});
filteredHeads.push(head);
}
}
let toMerge: EntryWithRefs<any>[];
if (!logInfo.sync) {
toMerge = [];
for (const [gid, value] of await groupByGid(filteredHeads)) {
if (
!(await this.isLeader(
logInfo.log,
gid,
logInfo.minReplicas.value
))
) {
logger.debug(
`${this.identity.publicKey.hashcode()}: Dropping heads with gid: ${gid}. Because not leader`
);
continue;
}
for (const head of value) {
toMerge.push(head);
}
}
} else {
toMerge = await Promise.all(
filteredHeads.map((x) => logInfo.sync!(x.entry))
).then((filter) => filteredHeads.filter((v, ix) => filter[ix]));
}
if (toMerge.length > 0) {
await logInfo.log.join(toMerge);
/* TODO does this debug affect performance?
logger.debug(
`${this.id}: Synced ${toMerge.length} heads for '${programAddressObject}/${storeIndex}':\n`,
JSON.stringify(
toMerge.map((e) => e.entry.hash),
null,
2
)
); */
}
}
} else {
throw new Error("Unexpected message");
}
} catch (e: any) {
if (e instanceof BorshError) {
logger.trace(
`${this.identity.publicKey.hashcode()}: Failed to handle message on topic: ${JSON.stringify(
message.topics
)} ${message.data.length}: Got message for a different namespace`
);
return;
}
if (e instanceof AccessError) {
logger.trace(
`${this.identity.publicKey.hashcode()}: Failed to handle message on topic: ${JSON.stringify(
message.topics
)} ${message.data.length}: Got message I could not decrypt`
);
return;
}
logger.error(e);
}
}
async _onUnsubscription(evt: CustomEvent<UnsubcriptionEvent>) {
logger.debug(
`Peer disconnected '${evt.detail.from.hashcode()}' from '${JSON.stringify(
evt.detail.unsubscriptions.map((x) => x.topic)
)}'`
);
return this.handleSubscriptionChange(
evt.detail.from.hashcode(),
evt.detail.unsubscriptions,
false
);
}
async _onSubscription(evt: CustomEvent<SubscriptionEvent>) {
logger.debug(
`New peer '${evt.detail.from.hashcode()}' connected to '${JSON.stringify(
evt.detail.subscriptions.map((x) => x.topic)
)}'`
);
return this.handleSubscriptionChange(
evt.detail.from.hashcode(),
evt.detail.subscriptions,
true
);
}
private modifySortedSubscriptionCache(
topic: string,
subscribed: boolean,
fromHash: string
) {
const sortedPeer = this._sortedPeersCache.get(topic);
if (sortedPeer) {
const code = fromHash;
if (subscribed) {
// TODO use Set + list for fast lookup
if (!sortedPeer.find((x) => x === code)) {
sortedPeer.push(code);
sortedPeer.sort((a, b) => a.localeCompare(b));
}
} else {
const deleteIndex = sortedPeer.findIndex((x) => x === code);
sortedPeer.splice(deleteIndex, 1);
}
} else if (subscribed) {
this._sortedPeersCache.set(topic, [fromHash]);
}
}
async handleSubscriptionChange(
fromHash: string,
changes: Subscription[],
subscribed: boolean
) {
for (const c of changes) {
if (!c.data || !startsWith(c.data, REPLICATOR_TYPE_VARIANT)) {
return;
}
this._lastSubscriptionMessageId += 1;
this.modifySortedSubscriptionCache(c.topic, subscribed, fromHash);
}
for (const subscription of changes) {
if (subscription.data) {
try {
const type = deserialize(subscription.data, SubscriptionType);
if (type instanceof Replicator) {
const p = this.logs.get(subscription.topic);
if (p) {
await this.replicationReorganization([p.log.idString]);
}
}
} catch (error: any) {
logger.warn(
"Recieved subscription with invalid data on topic: " +
subscription.topic +
". Error: " +
error?.message
);
}
}
}
}
/**
* When a peers join the networkk and want to participate the leaders for particular log subgraphs might change, hence some might start replicating, might some stop
* This method will go through my owned entries, and see whether I should share them with a new leader, and/or I should stop care about specific entries
* @param channel
*/
async replicationReorganization(changedLogs: Set<string> | string[]) {
let changed = false;
for (const logId of changedLogs) {
const logInfo = this.logs.get(logId);
if (!logInfo || logInfo.log.closed) {
continue;
}
const heads = await logInfo.log.getHeads();
const groupedByGid = await groupByGid(heads);
let storeChanged = false;
for (const [gid, entries] of groupedByGid) {
const toSend: Map<string, Entry<any>> = new Map();
const newPeers: string[] = [];
if (entries.length === 0) {
continue; // TODO maybe close store?
}
const oldPeersSet = this._gidPeersHistory.get(gid);
const currentPeers = await this.findLeaders(
logInfo.log,
gid,
logInfo.minReplicas.value
);
for (const currentPeer of currentPeers) {
if (
!oldPeersSet?.has(currentPeer) &&
currentPeer !== this.identityHash
) {
storeChanged = true;
// second condition means that if the new peer is us, we should not do anything, since we are expecting to recieve heads, not send
newPeers.push(currentPeer);
// send heads to the new peer
// console.log('new gid for peer', newPeers.length, this.id.toString(), newPeer, gid, entries.length, newPeers)
try {
logger.debug(
`${this.identity.publicKey.hashcode()}: Exchange heads ${
entries.length === 1 ? entries[0].hash : "#" + entries.length
} on rebalance`
);
for (const entry of entries) {
toSend.set(entry.hash, entry);
}
} catch (error) {
if (error instanceof TimeoutError) {
logger.error(
"Missing channel when reorg to peer: " +
currentPeer.toString()
);
continue;
}
throw error;
}
}
}
// We don't need this clause anymore because we got the trim option!
if (!currentPeers.find((x) => x === this.identityHash)) {
let entriesToDelete = entries.filter((e) => !e.createdLocally);
if (logInfo.sync) {
// dont delete entries which we wish to keep
entriesToDelete = await Promise.all(
entriesToDelete.map((x) => logInfo.sync!(x))
).then((filter) => entriesToDelete.filter((v, ix) => !filter[ix]));
}
// delete entries since we are not suppose to replicate this anymore
// TODO add delay? freeze time? (to ensure resiliance for bad io)
if (entriesToDelete.length > 0) {
await logInfo.log.remove(entriesToDelete, {
recursively: true,
});
}
// TODO if length === 0 maybe close store?
}
this._gidPeersHistory.set(gid, new Set(currentPeers));
if (toSend.size === 0) {
continue;
}
const bytes = await createExchangeHeadsMessage(
logInfo.log,
[...toSend.values()], // TODO send to peers directly
true,
this.limitSigning ? undefined : this.identity
);
// TODO perhaps send less messages to more recievers for performance reasons?
await this._libp2p.services.pubsub.publish(bytes, {
to: newPeers,
strict: true,
topics: [logInfo.log.idString],
});
}
if (storeChanged) {
await logInfo.log.trim(); // because for entries createdLocally,we can have trim options that still allow us to delete them
}
changed = storeChanged || changed;
}
return changed;
}
/* TODO put this on the program level
getCanTrust(address: Address): CanTrust | undefined {
const p = this.programs.get(address.toString())?.program;
if (p) {
const ct = this.programs.get(address.toString())
?.program as any as CanTrust;
if (ct.isTrusted !== undefined) {
return ct;
}
}
return;
} */
// Callback when a store was closed
async _onClose(program: Program, log: Log<any>) {
// TODO Can we really close a this.programs, either we close all stores in the replication topic or none
const programAddress = program.address?.toString();
logger.debug(`Close ${programAddress}/${log.idString}`);
const logid = log.idString;
const lookup = this.logs.get(logid);
if (lookup) {
lookup.open -= 1;
if (lookup.open === 0) {
this.logs.delete(logid);
await this.unsubscribeToProgram(log as Log<any>); // TODO unsubscribe with 1 role but maybe have another role left?
}
}
}
async _onProgamClose(program: Program, programCache: LazyLevel) {
await programCache.close();
const programAddress = program.address?.toString();
if (programAddress) {
this.programs.delete(programAddress);
}
}
addProgram(program: Program): ProgramWithMetadata {
const programAddress = program.address?.toString();
if (!programAddress) {
throw new Error("Missing program address");
}
const existingProgramAndStores = this.programs.get(programAddress);
if (
!!existingProgramAndStores &&
existingProgramAndStores.program !== program
) {
// second condition only makes this throw error if we are to add a new instance with the same address
throw new Error(`Program at ${programAddress} is already created`);
}
const p = {
program,
openCounter: 1,
replicators: new Set<string>(),
};
this.programs.set(programAddress, p);
return p;
}
/* getReplicators(log: Log<any>): string[] | undefined {
let replicators = this.libp2p.services.pubsub.getSubscribersWithData(
log.idString,
REPLICATOR_TYPE_VARIANT,
{ prefix: true }
);
const iAmReplicating = this._logsById.get(log.idString)?.log.replication.replicating; // TODO add conditional whether this represents a network (I am not replicating if I am not trusted (pointless))
if (iAmReplicating) {
replicators = replicators || [];
replicators.push(this.idKeyHash.toString());
}
return replicators;
} */
getReplicatorsSorted(log: Log<any>): string[] | undefined {
return this._sortedPeersCache.get(log.idString);
}
getObservers(address: Address): string[] | undefined {
return this.libp2p.services.pubsub.getSubscribersWithData(
address.toString(),
OBSERVER_TYPE_VARIANT,
{ prefix: true }
);
}
async isLeader(
log: Log<any>,
slot: { toString(): string },
numberOfLeaders: number
): Promise<boolean> {
const isLeader = (await this.findLeaders(log, slot, numberOfLeaders)).find(
(l) => l === this.identityHash
);
return !!isLeader;
}
async findLeaders(
log: Log<any>,
subject: { toString(): string },
numberOfLeaders: number
): Promise<string[]> {
// For a fixed set or members, the choosen leaders will always be the same (address invariant)
// This allows for that same content is always chosen to be distributed to same peers, to remove unecessary copies
const peers: string[] = this.getReplicatorsSorted(log) || [];
// Assumption: Network specification is accurate
// Replication topic is not an address we assume that the network allows all participants
/* TODO put this on the program level
const network = this.getCanTrust(address);
let peers: string[];
if (network) {
const isTrusted = (peer: string) =>
network
? network.isTrusted(
peer // TODO improve perf, caching etc?
)
: true;
peers = await Promise.all(peersPreFilter.map(isTrusted)).then((results) =>
peersPreFilter.filter((_v, index) => results[index])
);
} else {
peers = peersPreFilter;
} */
if (peers.length === 0) {
return [];
}
numberOfLeaders = Math.min(numberOfLeaders, peers.length);
// Convert this thing we wan't to distribute to 8 bytes so we get can convert it into a u64
// modulus into an index
const utf8writer = new BinaryWriter();
utf8writer.string(subject.toString());
const seed = await sha256(utf8writer.finalize());
// convert hash of slot to a number
const seedNumber = new BinaryReader(
seed.subarray(seed.length - 8, seed.length)
).u64();
const startIndex = Number(seedNumber % BigInt(peers.length));
// we only step forward 1 step (ignoring that step backward 1 could be 'closer')
// This does not matter, we only have to make sure all nodes running the code comes to somewhat the
// same conclusion (are running the same leader selection algorithm)
const leaders = new Array(numberOfLeaders);
for (let i = 0; i < numberOfLeaders; i++) {
leaders[i] = peers[(i + startIndex) % peers.length];
}
return leaders;
}
private async subscribeToProgram(
log: Log<any>,
role: SubscriptionType
): Promise<void> {
if (this._disconnected || this._disconnecting) {
throw new Error("Disconnected");
}
if (role instanceof Replicator) {
this.modifySortedSubscriptionCache(log.idString, true, this.identityHash);
}
this.libp2p.services.pubsub.subscribe(log.idString, {
data: serialize(role),
});
return this.libp2p.services.pubsub.requestSubscribers(log.idString); // get up to date with who are subscribing to this topic
}
private async unsubscribeToProgram(id: Log<any>): Promise<void> {
if (this._disconnected) {
throw new Error("Disconnected");
}
this._sortedPeersCache.delete(id.idString);
await this.libp2p.services.pubsub.unsubscribe(id.idString);
}
hasSubscribedToTopic(topic: string): boolean {
return this.programs.has(topic);
}
/**
* Default behaviour of a store is only to accept heads that are forks (new roots) with some probability
* and to replicate heads (and updates) which is requested by another peer
* @param store
* @param options
* @returns
*/
async open<S extends Program>(
storeOrAddress: /* string | Address | */ S | Address | string,
options: OpenOptions = {}
): Promise<S> {
if (this._disconnected || this._disconnecting) {
throw new Error("Can not open a store while disconnected");
}
const fn = async (): Promise<ProgramWithMetadata> => {
// TODO add locks for store lifecycle, e.g. what happens if we try to open and close a store at the same time?
if (
typeof storeOrAddress === "string" ||
storeOrAddress instanceof Address
) {
storeOrAddress =
storeOrAddress instanceof Address
? storeOrAddress
: Address.parse(storeOrAddress);
}
let program = storeOrAddress as S;
let existing = false;
if (
storeOrAddress instanceof Address ||
typeof storeOrAddress === "string"
) {
try {
const fromExisting = this.programs?.get(storeOrAddress.toString())
?.program as S;
if (fromExisting) {
program = fromExisting;
existing = true;
} else {
program = (await Program.load(
this._libp2p.services.blocks,
storeOrAddress,
options
)) as S; // TODO fix typings
if (program instanceof Program === false) {
throw new Error(
`Failed to open program because program is of type ${program?.constructor.name} and not ${Program.name}`
);
}
}
} catch (error) {
logger.error(
"Failed to load store with address: " + storeOrAddress.toString()
);
throw error;
}
}
if (!program.address && !existing) {
await program.save(this._libp2p.services.blocks);
}
const programAddress = program.address!.toString()!;
if (programAddress) {
const existingProgram = this.programs?.get(programAddress);
if (existingProgram) {
existingProgram.openCounter += 1;
return existingProgram;
}
}
logger.debug(`Open database '${program.constructor.name}`);
const role = options.role || new Replicator();
const minReplicas =
options.minReplicas != null
? typeof options.minReplicas === "number"
? new AbsolutMinReplicas(options.minReplicas)
: options.minReplicas
: new AbsolutMinReplicas(this._minReplicas);
let programCache: LazyLevel | undefined = undefined;
const resolveMinReplicas = (log: Log<any>) =>
this.logs.get(log.idString)!.minReplicas.value;
await program.init(this.libp2p, {
onClose: async () => {
return this._onProgamClose(program, programCache!);
},
onDrop: () => this._onProgamClose(program, programCache!),
role,
// If the program opens more programs
open: (program) => this.open(program, options),
onSave: async (address) => {
programCache = await createSubCache(this._cache, address.toString(), {
reset: options.reset,
});
},
encryption: this.encryption,
waitFor: async (other) => {
await Promise.all(
program.logs.map((x) =>
waitForSubscribers(this.libp2p, other, x.idString)
)
);
},
log: (log) => {
const cfg: LogOptions<any> = {
encryption: this.encryption,
trim: options.trim && {
...options.trim,
filter: {
canTrim: async (gid) =>
!(await this.isLeader(log, gid, resolveMinReplicas(log))), // TODO types
cacheId: () => this._lastSubscriptionMessageId,
},
},
cache: async (name: string) => {
return createSubCache(
programCache!, // TODO types
path.join("log", name)
);
},
onClose: async () => {
await this._onClose(program, log);
return options.log?.onClose?.(log);
},
onDrop: async () => {
await this._onClose(program, log);
return options.log?.onClose?.(log);
},
replication: {
replicators: () => {
// TODO Optimize this so we don't have to recreate the array all the time!
const minReplicas = resolveMinReplicas(log);
const replicators = this.getReplicatorsSorted(log);
if (!replicators) {
return []; // No subscribers and we are not replicating
}
const numberOfGroups = Math.min(
Math.ceil(replicators!.length / minReplicas)
);
const groups = new Array<string[]>(numberOfGroups);
for (let i = 0; i < groups.length; i++) {
groups[i] = [];
}
for (let i = 0; i < replicators!.length; i++) {
groups[i % numberOfGroups].push(replicators![i]);
}
return groups;
},
replicator: (gid) =>
this.isLeader(log, gid, resolveMinReplicas(log)),
},
onOpen: async () => {
const logid = (log as Log<any>).idString;
const lookup = this.logs.get(logid);
if (lookup) {
lookup.open += 1;
} else {
await this.subscribeToProgram(log as Log<any>, role);
this.logs.set(logid, {
log,
open: 1,
sync: options.sync,
minReplicas,
});
}
},
onWrite: async (entry) => {
await this.onWrite(program, log, entry);
return options.log?.onWrite?.(log, entry);
},
onChange: async (change) => {
return options?.log?.onChange?.(log, change);
},
};
return cfg;
},
});
return this.addProgram(program);
};
const openStore = await this._openProgramQueue.add(fn);
if (!openStore?.program.address) {
throw new Error("Unexpected");
}
return openStore.program as S;
}
}
const areWeTestingWithJest = (): boolean => {
return process.env.JEST_WORKER_ID !== undefined;
};