transaction improvements
This commit is contained in:
parent
066c03f690
commit
198c996231
|
@ -1,7 +1,7 @@
|
||||||
import Debug from 'debug';
|
import Debug from 'debug';
|
||||||
import {RhizomeNode} from "../src/node";
|
|
||||||
import {Entity} from "../src/entity";
|
|
||||||
import {Collection} from "../src/collection";
|
import {Collection} from "../src/collection";
|
||||||
|
import {Entity} from "../src/entity";
|
||||||
|
import {RhizomeNode} from "../src/node";
|
||||||
const debug = Debug('example-app');
|
const debug = Debug('example-app');
|
||||||
|
|
||||||
// As an app we want to be able to write and read data.
|
// As an app we want to be able to write and read data.
|
||||||
|
@ -36,11 +36,17 @@ type User = {
|
||||||
|
|
||||||
await rhizomeNode.start();
|
await rhizomeNode.start();
|
||||||
|
|
||||||
// Let's use the rhizomic database for some more things.
|
// TODO: Use the rhizomic database for some more things.
|
||||||
// Like what?
|
// Like what?
|
||||||
// - Logging
|
// - Logging
|
||||||
// - Chat
|
// - Chat
|
||||||
//
|
|
||||||
|
// TODO: Allow configuration regarding read/write concern i.e.
|
||||||
|
// if we perform a read immediately do we see the value we wrote?
|
||||||
|
// Intuition says yes, we want that-- but how do we expose the propagation status?
|
||||||
|
|
||||||
|
// Insert a "user" record
|
||||||
|
|
||||||
const taliesinData: User = {
|
const taliesinData: User = {
|
||||||
id: 'taliesin-1',
|
id: 'taliesin-1',
|
||||||
name: 'Taliesin',
|
name: 'Taliesin',
|
||||||
|
@ -48,10 +54,13 @@ type User = {
|
||||||
age: Math.floor(Math.random() * 1000)
|
age: Math.floor(Math.random() * 1000)
|
||||||
};
|
};
|
||||||
|
|
||||||
const taliesinPutResult = await users.put(undefined, taliesinData);
|
|
||||||
|
|
||||||
{
|
{
|
||||||
const result = JSON.stringify(taliesinPutResult);
|
const taliesinPutResult = await users.put(undefined, taliesinData);
|
||||||
|
const resolvedUser = {
|
||||||
|
id: taliesinPutResult.id,
|
||||||
|
...taliesinPutResult.properties
|
||||||
|
} as User;
|
||||||
|
const result = JSON.stringify(resolvedUser);
|
||||||
const expected = JSON.stringify(taliesinData);
|
const expected = JSON.stringify(taliesinData);
|
||||||
|
|
||||||
if (result === expected) {
|
if (result === expected) {
|
||||||
|
@ -63,41 +72,27 @@ type User = {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Allow configuration regarding read/write concern i.e.
|
// Read back what we wrote
|
||||||
// if we perform a read immediately do we see the value we wrote?
|
|
||||||
// Intuition says yes, we want that-- but how do we expose the propagation status?
|
|
||||||
|
|
||||||
const resolved = users.resolve('taliesin-1');
|
{
|
||||||
if (!resolved) throw new Error('unable to resolve entity we just created');
|
const resolved = users.resolve('taliesin-1');
|
||||||
|
if (!resolved) throw new Error('unable to resolve entity we just created');
|
||||||
|
|
||||||
debug('resolved', resolved);
|
const resolvedUser = {
|
||||||
|
id: resolved.id,
|
||||||
|
...resolved.properties
|
||||||
|
} as User;
|
||||||
|
|
||||||
const resolvedUser = {
|
const result = JSON.stringify(resolvedUser);
|
||||||
id: resolved.id,
|
const expected = JSON.stringify(taliesinData);
|
||||||
...resolved.properties
|
|
||||||
} as User;
|
|
||||||
|
|
||||||
/*
|
if (result === expected) {
|
||||||
function sortKeys (o: {[key: string]: unknown}): {[key: string]: unknown} {
|
debug('Get result matches expected: ' + expected);
|
||||||
const r: {[key: string]: unknown} = {};
|
} else {
|
||||||
r.id = o.id;
|
debug(`Get result does not match expected.` +
|
||||||
Object.keys(o).sort().forEach((key) => {
|
`\n\nExpected \n${expected}` +
|
||||||
if (key === "id") return;
|
`\nReceived\n${result}`);
|
||||||
r[key] = o[key];
|
}
|
||||||
})
|
|
||||||
return r;
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
|
|
||||||
const result = JSON.stringify(resolvedUser);
|
|
||||||
const expected = JSON.stringify(taliesinData);
|
|
||||||
|
|
||||||
if (result === expected) {
|
|
||||||
debug('Get result matches expected: ' + expected);
|
|
||||||
} else {
|
|
||||||
debug(`Get result does not match expected.` +
|
|
||||||
`\n\nExpected \n${expected}` +
|
|
||||||
`\nReceived\n${result}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -6,7 +6,7 @@
|
||||||
import Debug from 'debug';
|
import Debug from 'debug';
|
||||||
import {randomUUID} from "node:crypto";
|
import {randomUUID} from "node:crypto";
|
||||||
import EventEmitter from "node:events";
|
import EventEmitter from "node:events";
|
||||||
import {Delta, DeltaID} from "./delta";
|
import {Delta, DeltaFilter} from "./delta";
|
||||||
import {Entity, EntityProperties} from "./entity";
|
import {Entity, EntityProperties} from "./entity";
|
||||||
import {Lossy, ResolvedViewOne, Resolver} from "./lossy";
|
import {Lossy, ResolvedViewOne, Resolver} from "./lossy";
|
||||||
import {RhizomeNode} from "./node";
|
import {RhizomeNode} from "./node";
|
||||||
|
@ -17,6 +17,7 @@ export class Collection {
|
||||||
rhizomeNode?: RhizomeNode;
|
rhizomeNode?: RhizomeNode;
|
||||||
name: string;
|
name: string;
|
||||||
eventStream = new EventEmitter();
|
eventStream = new EventEmitter();
|
||||||
|
lossy?: Lossy;
|
||||||
|
|
||||||
constructor(name: string) {
|
constructor(name: string) {
|
||||||
this.name = name;
|
this.name = name;
|
||||||
|
@ -31,10 +32,15 @@ export class Collection {
|
||||||
rhizomeConnect(rhizomeNode: RhizomeNode) {
|
rhizomeConnect(rhizomeNode: RhizomeNode) {
|
||||||
this.rhizomeNode = rhizomeNode;
|
this.rhizomeNode = rhizomeNode;
|
||||||
|
|
||||||
rhizomeNode.deltaStream.subscribeDeltas((delta: Delta) => {
|
this.lossy = new Lossy(this.rhizomeNode.lossless);
|
||||||
// TODO: Make sure this is the kind of delta we're looking for
|
|
||||||
debug(`collection ${this.name} received delta ${JSON.stringify(delta)}`);
|
// Listen for completed transactions, and emit updates to event stream
|
||||||
this.ingestDelta(delta);
|
this.rhizomeNode.lossless.eventStream.on("updated", (id) => {
|
||||||
|
// TODO: Filter so we only get members of our collection
|
||||||
|
|
||||||
|
// TODO: Reslover / Delta Filter?
|
||||||
|
const res = this.resolve(id);
|
||||||
|
this.eventStream.emit("update", res);
|
||||||
});
|
});
|
||||||
|
|
||||||
rhizomeNode.httpServer.httpApi.serveCollection(this);
|
rhizomeNode.httpServer.httpApi.serveCollection(this);
|
||||||
|
@ -42,15 +48,6 @@ export class Collection {
|
||||||
debug(`connected ${this.name} to rhizome`);
|
debug(`connected ${this.name} to rhizome`);
|
||||||
}
|
}
|
||||||
|
|
||||||
ingestDelta(delta: Delta) {
|
|
||||||
if (!this.rhizomeNode) return;
|
|
||||||
|
|
||||||
const updated = this.rhizomeNode.lossless.ingestDelta(delta);
|
|
||||||
|
|
||||||
this.eventStream.emit('ingested', delta);
|
|
||||||
this.eventStream.emit('updated', updated);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Applies the javascript rules for updating object values,
|
// Applies the javascript rules for updating object values,
|
||||||
// e.g. set to `undefined` to delete a property.
|
// e.g. set to `undefined` to delete a property.
|
||||||
// This function is here instead of Entity so that it can:
|
// This function is here instead of Entity so that it can:
|
||||||
|
@ -62,7 +59,10 @@ export class Collection {
|
||||||
creator: string,
|
creator: string,
|
||||||
host: string,
|
host: string,
|
||||||
resolver?: Resolver
|
resolver?: Resolver
|
||||||
): Delta[] {
|
): {
|
||||||
|
transactionDelta: Delta | undefined,
|
||||||
|
deltas: Delta[]
|
||||||
|
} {
|
||||||
const deltas: Delta[] = [];
|
const deltas: Delta[] = [];
|
||||||
let oldProperties: EntityProperties = {};
|
let oldProperties: EntityProperties = {};
|
||||||
|
|
||||||
|
@ -86,10 +86,6 @@ export class Collection {
|
||||||
creator,
|
creator,
|
||||||
host,
|
host,
|
||||||
pointers: [{
|
pointers: [{
|
||||||
localContext: "_transaction",
|
|
||||||
target: transactionId,
|
|
||||||
targetContext: "deltas"
|
|
||||||
}, {
|
|
||||||
localContext: this.name,
|
localContext: this.name,
|
||||||
target: entityId,
|
target: entityId,
|
||||||
targetContext: key
|
targetContext: key
|
||||||
|
@ -101,21 +97,34 @@ export class Collection {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// We can generate a separate delta describing this transaction
|
let transactionDelta: Delta | undefined;
|
||||||
const transactionDelta = new Delta({
|
|
||||||
creator,
|
|
||||||
host,
|
|
||||||
pointers: [{
|
|
||||||
localContext: "_transaction",
|
|
||||||
target: transactionId,
|
|
||||||
targetContext: "size"
|
|
||||||
}, {
|
|
||||||
localContext: "size",
|
|
||||||
target: deltas.length
|
|
||||||
}]
|
|
||||||
});
|
|
||||||
|
|
||||||
return [transactionDelta, ...deltas];
|
if (deltas.length > 1) {
|
||||||
|
// We can generate a separate delta describing this transaction
|
||||||
|
transactionDelta = new Delta({
|
||||||
|
creator,
|
||||||
|
host,
|
||||||
|
pointers: [{
|
||||||
|
localContext: "_transaction",
|
||||||
|
target: transactionId,
|
||||||
|
targetContext: "size"
|
||||||
|
}, {
|
||||||
|
localContext: "size",
|
||||||
|
target: deltas.length
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Also need to annotate the deltas with the transactionId
|
||||||
|
for (const delta of deltas) {
|
||||||
|
delta.pointers.unshift({
|
||||||
|
localContext: "_transaction",
|
||||||
|
target: transactionId,
|
||||||
|
targetContext: "deltas"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {transactionDelta, deltas};
|
||||||
}
|
}
|
||||||
|
|
||||||
onCreate(cb: (entity: Entity) => void) {
|
onCreate(cb: (entity: Entity) => void) {
|
||||||
|
@ -159,7 +168,7 @@ export class Collection {
|
||||||
entityId = randomUUID();
|
entityId = randomUUID();
|
||||||
}
|
}
|
||||||
|
|
||||||
const deltas = this.generateDeltas(
|
const {transactionDelta, deltas} = this.generateDeltas(
|
||||||
entityId,
|
entityId,
|
||||||
properties,
|
properties,
|
||||||
this.rhizomeNode?.config.creator,
|
this.rhizomeNode?.config.creator,
|
||||||
|
@ -167,67 +176,47 @@ export class Collection {
|
||||||
resolver,
|
resolver,
|
||||||
);
|
);
|
||||||
|
|
||||||
debug(`put ${entityId} generated deltas:`, JSON.stringify(deltas));
|
const ingested = new Promise<boolean>((resolve) => {
|
||||||
|
this.rhizomeNode!.lossless.eventStream.on("updated", (id: DomainEntityID) => {
|
||||||
// Here we set up a listener so we can wait for all our deltas to be
|
if (id === entityId) resolve(true);
|
||||||
// ingested into our lossless view before proceeding.
|
|
||||||
// TODO: Hoist this into a more generic transaction mechanism.
|
|
||||||
|
|
||||||
//
|
|
||||||
|
|
||||||
const allIngested = new Promise<boolean>((resolve) => {
|
|
||||||
const ingestedIds = new Set<DeltaID>();
|
|
||||||
this.eventStream.on('ingested', (delta: Delta) => {
|
|
||||||
// TODO: timeout
|
|
||||||
if (deltas.map(({id}) => id).includes(delta.id)) {
|
|
||||||
ingestedIds.add(delta.id);
|
|
||||||
if (ingestedIds.size === deltas.length) {
|
|
||||||
resolve(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
deltas.forEach(async (delta: Delta) => {
|
if (transactionDelta) {
|
||||||
|
deltas.unshift(transactionDelta);
|
||||||
|
}
|
||||||
|
|
||||||
|
deltas.forEach(async (delta: Delta) => {
|
||||||
// record this delta just as if we had received it from a peer
|
// record this delta just as if we had received it from a peer
|
||||||
delta.receivedFrom = this.rhizomeNode!.myRequestAddr;
|
delta.receivedFrom = this.rhizomeNode!.myRequestAddr;
|
||||||
this.rhizomeNode!.deltaStream.deltasAccepted.push(delta);
|
this.rhizomeNode!.deltaStream.deltasAccepted.push(delta);
|
||||||
|
|
||||||
// publish the delta to our subscribed peers
|
// publish the delta to our subscribed peers
|
||||||
await this.rhizomeNode!.deltaStream.publishDelta(delta);
|
await this.rhizomeNode!.deltaStream.publishDelta(delta);
|
||||||
debug(`published delta ${JSON.stringify(delta)}`);
|
|
||||||
|
|
||||||
// ingest the delta as though we had received it from a peer
|
// ingest the delta as though we had received it from a peer
|
||||||
this.ingestDelta(delta);
|
this.rhizomeNode!.lossless.ingestDelta(delta);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Return updated view of this entity
|
// Return updated view of this entity
|
||||||
// Let's wait for an event notifying us that the entity has been updated.
|
// Let's wait for an event notifying us that the entity has been updated.
|
||||||
// This means all of our deltas have been applied.
|
// This means all of our deltas have been applied.
|
||||||
|
|
||||||
await allIngested;
|
await ingested;
|
||||||
|
|
||||||
const res = this.resolve(entityId, resolver);
|
const res = this.resolve(entityId, resolver);
|
||||||
if (!res) throw new Error("could not get what we just put!");
|
if (!res) throw new Error("could not get what we just put!");
|
||||||
|
|
||||||
this.eventStream.emit("update", res);
|
|
||||||
|
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
resolve<T = ResolvedViewOne>(id: string, resolver?: Resolver): T | undefined {
|
resolve<T = ResolvedViewOne>(
|
||||||
// Now with lossy view approach, instead of just returning what we
|
id: string,
|
||||||
// already have, let's compute our view now.
|
resolver?: Resolver,
|
||||||
// return this.entities.resolve(id);
|
deltaFilter?: DeltaFilter
|
||||||
// TODO: Caching
|
): T | undefined {
|
||||||
|
|
||||||
if (!this.rhizomeNode) return undefined;
|
if (!this.rhizomeNode) return undefined;
|
||||||
|
|
||||||
const lossy = new Lossy(this.rhizomeNode.lossless);
|
const res = this.lossy?.resolve(resolver, [id], deltaFilter) || {};
|
||||||
// TODO: deltaFilter
|
|
||||||
const res = lossy.resolve(resolver, [id]);
|
|
||||||
debug('lossy view', res);
|
|
||||||
|
|
||||||
return res[id] as T;
|
return res[id] as T;
|
||||||
}
|
}
|
||||||
|
|
45
src/delta.ts
45
src/delta.ts
|
@ -1,6 +1,6 @@
|
||||||
import microtime from 'microtime';
|
|
||||||
import {randomUUID} from "crypto";
|
import {randomUUID} from "crypto";
|
||||||
import {PeerAddress, Timestamp} from "./types";
|
import microtime from 'microtime';
|
||||||
|
import {CreatorID, HostID, PeerAddress, Timestamp, TransactionID} from "./types";
|
||||||
|
|
||||||
export type DeltaID = string;
|
export type DeltaID = string;
|
||||||
|
|
||||||
|
@ -12,22 +12,39 @@ export type Pointer = {
|
||||||
targetContext?: string;
|
targetContext?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export class Delta {
|
export class DeltaNetworkImage {
|
||||||
id: DeltaID;
|
id: DeltaID;
|
||||||
|
timeCreated: Timestamp;
|
||||||
|
host: HostID;
|
||||||
|
creator: CreatorID;
|
||||||
|
pointers: Pointer[];
|
||||||
|
constructor({id, timeCreated, host, creator, pointers}: DeltaNetworkImage) {
|
||||||
|
this.id = id;
|
||||||
|
this.host = host;
|
||||||
|
this.creator = creator;
|
||||||
|
this.timeCreated = timeCreated;
|
||||||
|
this.pointers = pointers;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export class Delta extends DeltaNetworkImage {
|
||||||
receivedFrom?: PeerAddress;
|
receivedFrom?: PeerAddress;
|
||||||
timeReceived: Timestamp;
|
timeReceived: Timestamp;
|
||||||
timeCreated: Timestamp;
|
transactionId?: TransactionID;
|
||||||
creator: string;
|
|
||||||
host: string;
|
// TODO: Verify the following assumption:
|
||||||
pointers: Pointer[] = [];
|
// We're assuming that you only call this constructor when
|
||||||
constructor(delta: Omit<Delta, "id" | "timeReceived" | "timeCreated">) {
|
// actually creating a new delta.
|
||||||
this.id = randomUUID();
|
// When receiving one from the network, you can
|
||||||
this.timeCreated = microtime.now();
|
constructor({host, creator, pointers}: Partial<DeltaNetworkImage>) {
|
||||||
|
// TODO: Verify that when receiving a delta from the network we can
|
||||||
|
// retain the delta's id.
|
||||||
|
const id = randomUUID();
|
||||||
|
const timeCreated = microtime.now();
|
||||||
|
if (!host || !creator || !pointers) throw new Error('uninitializied values');
|
||||||
|
super({id, timeCreated, host, creator, pointers});
|
||||||
|
this.timeCreated = timeCreated;
|
||||||
this.timeReceived = this.timeCreated;
|
this.timeReceived = this.timeCreated;
|
||||||
this.creator = delta.creator;
|
|
||||||
this.host = delta.host;
|
|
||||||
this.receivedFrom = delta.receivedFrom;
|
|
||||||
this.pointers = delta.pointers;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import Debug from 'debug';
|
import Debug from 'debug';
|
||||||
import EventEmitter from 'node:events';
|
import EventEmitter from 'node:events';
|
||||||
import objectHash from 'object-hash';
|
import objectHash from 'object-hash';
|
||||||
import {Delta} from './delta';
|
import {Delta, DeltaNetworkImage} from './delta';
|
||||||
import {RhizomeNode} from './node';
|
import {RhizomeNode} from './node';
|
||||||
const debug = Debug('deltas');
|
const debug = Debug('deltas');
|
||||||
|
|
||||||
|
@ -91,7 +91,8 @@ export class DeltaStream {
|
||||||
}
|
}
|
||||||
|
|
||||||
serializeDelta(delta: Delta): string {
|
serializeDelta(delta: Delta): string {
|
||||||
return JSON.stringify(delta);
|
const deltaNetworkImage = new DeltaNetworkImage(delta);
|
||||||
|
return JSON.stringify(deltaNetworkImage);
|
||||||
}
|
}
|
||||||
|
|
||||||
deserializeDelta(input: string): Delta {
|
deserializeDelta(input: string): Delta {
|
||||||
|
|
117
src/lossless.ts
117
src/lossless.ts
|
@ -2,13 +2,15 @@
|
||||||
// We can maintain a record of all the targeted entities, and the deltas that targeted them
|
// We can maintain a record of all the targeted entities, and the deltas that targeted them
|
||||||
|
|
||||||
import Debug from 'debug';
|
import Debug from 'debug';
|
||||||
import {Delta, DeltaFilter, DeltaID} from './delta';
|
import EventEmitter from 'events';
|
||||||
|
import {Delta, DeltaFilter, DeltaNetworkImage} from './delta';
|
||||||
|
import {Transactions} from './transactions';
|
||||||
import {DomainEntityID, PropertyID, PropertyTypes, TransactionID, ViewMany} from "./types";
|
import {DomainEntityID, PropertyID, PropertyTypes, TransactionID, ViewMany} from "./types";
|
||||||
const debug = Debug('lossless');
|
const debug = Debug('lossless');
|
||||||
|
|
||||||
export type CollapsedPointer = {[key: PropertyID]: PropertyTypes};
|
export type CollapsedPointer = {[key: PropertyID]: PropertyTypes};
|
||||||
|
|
||||||
export type CollapsedDelta = Omit<Delta, 'pointers'> & {
|
export type CollapsedDelta = Omit<DeltaNetworkImage, 'pointers'> & {
|
||||||
pointers: CollapsedPointer[];
|
pointers: CollapsedPointer[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -21,9 +23,9 @@ export type LosslessViewOne = {
|
||||||
|
|
||||||
export type LosslessViewMany = ViewMany<LosslessViewOne>;
|
export type LosslessViewMany = ViewMany<LosslessViewOne>;
|
||||||
|
|
||||||
class DomainEntityMap extends Map<DomainEntityID, DomainEntity> {};
|
class LosslessEntityMap extends Map<DomainEntityID, LosslessEntity> {};
|
||||||
|
|
||||||
class DomainEntity {
|
class LosslessEntity {
|
||||||
id: DomainEntityID;
|
id: DomainEntityID;
|
||||||
properties = new Map<PropertyID, Set<Delta>>();
|
properties = new Map<PropertyID, Set<Delta>>();
|
||||||
|
|
||||||
|
@ -44,7 +46,6 @@ class DomainEntity {
|
||||||
this.properties.set(targetContext, propertyDeltas);
|
this.properties.set(targetContext, propertyDeltas);
|
||||||
}
|
}
|
||||||
|
|
||||||
debug(`adding delta for entity ${this.id}`);
|
|
||||||
propertyDeltas.add(delta);
|
propertyDeltas.add(delta);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -61,50 +62,24 @@ class DomainEntity {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class Transaction {
|
|
||||||
size?: number;
|
|
||||||
receivedDeltaIds = new Set<DeltaID>();
|
|
||||||
}
|
|
||||||
|
|
||||||
class Transactions {
|
|
||||||
transactions = new Map<TransactionID, Transaction>();
|
|
||||||
|
|
||||||
getOrInit(id: TransactionID): Transaction {
|
|
||||||
let t = this.transactions.get(id);
|
|
||||||
if (!t) {
|
|
||||||
t = new Transaction();
|
|
||||||
this.transactions.set(id, t);
|
|
||||||
}
|
|
||||||
return t;
|
|
||||||
}
|
|
||||||
|
|
||||||
receivedDelta(id: TransactionID, deltaId: DeltaID) {
|
|
||||||
const t = this.getOrInit(id);
|
|
||||||
t.receivedDeltaIds.add(deltaId);
|
|
||||||
}
|
|
||||||
|
|
||||||
isComplete(id: TransactionID) {
|
|
||||||
const t = this.getOrInit(id);
|
|
||||||
return t.size !== undefined && t.receivedDeltaIds.size === t.size;
|
|
||||||
}
|
|
||||||
|
|
||||||
setSize(id: TransactionID, size: number) {
|
|
||||||
const t = this.getOrInit(id);
|
|
||||||
t.size = size;
|
|
||||||
}
|
|
||||||
|
|
||||||
get ids() {
|
|
||||||
return Array.from(this.transactions.keys());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Lossless {
|
export class Lossless {
|
||||||
domainEntities = new DomainEntityMap();
|
domainEntities = new LosslessEntityMap();
|
||||||
transactions = new Transactions();
|
transactions = new Transactions();
|
||||||
referencedAs = new Map<string, Set<DomainEntityID>>();
|
referencedAs = new Map<string, Set<DomainEntityID>>();
|
||||||
// referencingAs = new Map<string, Set<DomainEntityID>>();
|
eventStream = new EventEmitter();
|
||||||
|
|
||||||
ingestDelta(delta: Delta) {
|
constructor() {
|
||||||
|
this.transactions.eventStream.on("completed", (transactionId) => {
|
||||||
|
debug(`completed transaction ${transactionId}`);
|
||||||
|
const transaction = this.transactions.get(transactionId);
|
||||||
|
if (!transaction) return;
|
||||||
|
for (const id of transaction.entityIds) {
|
||||||
|
this.eventStream.emit("updated", id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ingestDelta(delta: Delta): TransactionID | undefined {
|
||||||
const targets = delta.pointers
|
const targets = delta.pointers
|
||||||
.filter(({targetContext}) => !!targetContext)
|
.filter(({targetContext}) => !!targetContext)
|
||||||
.map(({target}) => target)
|
.map(({target}) => target)
|
||||||
|
@ -114,7 +89,7 @@ export class Lossless {
|
||||||
let ent = this.domainEntities.get(target);
|
let ent = this.domainEntities.get(target);
|
||||||
|
|
||||||
if (!ent) {
|
if (!ent) {
|
||||||
ent = new DomainEntity(target);
|
ent = new LosslessEntity(target);
|
||||||
this.domainEntities.set(target, ent);
|
this.domainEntities.set(target, ent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -134,43 +109,15 @@ export class Lossless {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const {target: transactionId} = delta.pointers.find(({
|
const transactionId = this.transactions.ingestDelta(delta, targets);
|
||||||
localContext,
|
|
||||||
target,
|
|
||||||
targetContext
|
|
||||||
}) =>
|
|
||||||
localContext === "_transaction" &&
|
|
||||||
typeof target === "string" &&
|
|
||||||
targetContext === "deltas"
|
|
||||||
) || {};
|
|
||||||
|
|
||||||
if (transactionId) {
|
if (!transactionId) {
|
||||||
// This delta is part of a transaction
|
// No transaction -- we can issue an update event immediately
|
||||||
this.transactions.receivedDelta(transactionId as string, delta.id);
|
for (const id of targets) {
|
||||||
} else {
|
this.eventStream.emit("updated", id);
|
||||||
const {target: transactionId} = delta.pointers.find(({
|
|
||||||
localContext,
|
|
||||||
target,
|
|
||||||
targetContext
|
|
||||||
}) =>
|
|
||||||
localContext === "_transaction" &&
|
|
||||||
typeof target === "string" &&
|
|
||||||
targetContext === "size"
|
|
||||||
) || {};
|
|
||||||
|
|
||||||
if (transactionId) {
|
|
||||||
// This delta describes a transaction
|
|
||||||
const {target: size} = delta.pointers.find(({
|
|
||||||
localContext,
|
|
||||||
target
|
|
||||||
}) =>
|
|
||||||
localContext === "size" &&
|
|
||||||
typeof target === "number"
|
|
||||||
) || {};
|
|
||||||
|
|
||||||
this.transactions.setSize(transactionId as string, size as number);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return transactionId;
|
||||||
}
|
}
|
||||||
|
|
||||||
view(entityIds?: DomainEntityID[], deltaFilter?: DeltaFilter): LosslessViewMany {
|
view(entityIds?: DomainEntityID[], deltaFilter?: DeltaFilter): LosslessViewMany {
|
||||||
|
@ -180,8 +127,6 @@ export class Lossless {
|
||||||
const ent = this.domainEntities.get(id);
|
const ent = this.domainEntities.get(id);
|
||||||
if (!ent) continue;
|
if (!ent) continue;
|
||||||
|
|
||||||
debug(`domain entity ${id}`, JSON.stringify(ent));
|
|
||||||
|
|
||||||
const referencedAs = new Set<string>();
|
const referencedAs = new Set<string>();
|
||||||
const properties: {
|
const properties: {
|
||||||
[key: PropertyID]: CollapsedDelta[]
|
[key: PropertyID]: CollapsedDelta[]
|
||||||
|
@ -191,6 +136,14 @@ export class Lossless {
|
||||||
properties[key] = properties[key] || [];
|
properties[key] = properties[key] || [];
|
||||||
|
|
||||||
for (const delta of deltas) {
|
for (const delta of deltas) {
|
||||||
|
// If this delta is part of a transaction,
|
||||||
|
// we need to be able to wait for the whole transaction.
|
||||||
|
if (delta.transactionId) {
|
||||||
|
if (!this.transactions.isComplete(delta.transactionId)) {
|
||||||
|
// TODO: Test this condition
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (deltaFilter) {
|
if (deltaFilter) {
|
||||||
const include = deltaFilter(delta);
|
const include = deltaFilter(delta);
|
||||||
|
|
28
src/lossy.ts
28
src/lossy.ts
|
@ -60,7 +60,6 @@ export function lastValueFromLosslessViewOne(
|
||||||
value?: string | number,
|
value?: string | number,
|
||||||
timeUpdated?: number
|
timeUpdated?: number
|
||||||
} = {};
|
} = {};
|
||||||
debug(`trying to get last value for ${key} from ${JSON.stringify(ent.properties[key])}`);
|
|
||||||
res.timeUpdated = 0;
|
res.timeUpdated = 0;
|
||||||
|
|
||||||
for (const delta of ent.properties[key] || []) {
|
for (const delta of ent.properties[key] || []) {
|
||||||
|
@ -76,23 +75,25 @@ export function lastValueFromLosslessViewOne(
|
||||||
}
|
}
|
||||||
|
|
||||||
function defaultResolver(losslessView: LosslessViewMany): ResolvedViewMany {
|
function defaultResolver(losslessView: LosslessViewMany): ResolvedViewMany {
|
||||||
const resolved: ResolvedViewMany = {};
|
const resolved: ResolvedViewMany = {};
|
||||||
|
|
||||||
// debug('default resolver, lossless view', JSON.stringify(losslessView));
|
// debug('default resolver, lossless view', JSON.stringify(losslessView));
|
||||||
for (const [id, ent] of Object.entries(losslessView)) {
|
for (const [id, ent] of Object.entries(losslessView)) {
|
||||||
resolved[id] = {id, properties: {}};
|
resolved[id] = {id, properties: {}};
|
||||||
|
|
||||||
for (const key of Object.keys(ent.properties)) {
|
for (const key of Object.keys(ent.properties)) {
|
||||||
const {value} = lastValueFromLosslessViewOne(ent, key) || {};
|
const {value} = lastValueFromLosslessViewOne(ent, key) || {};
|
||||||
|
|
||||||
// debug(`[ ${key} ] = ${value}`);
|
// debug(`[ ${key} ] = ${value}`);
|
||||||
resolved[id].properties[key] = value;
|
resolved[id].properties[key] = value;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return resolved;
|
}
|
||||||
};
|
return resolved;
|
||||||
|
};
|
||||||
|
|
||||||
|
// TODO: Incremental updates of lossy models. For example, with last-write-wins,
|
||||||
|
// we keep the timeUpdated for each field. A second stage resolver can rearrange
|
||||||
|
// the data structure to a preferred shape and may discard the timeUpdated info.
|
||||||
export class Lossy {
|
export class Lossy {
|
||||||
lossless: Lossless;
|
lossless: Lossless;
|
||||||
|
|
||||||
|
@ -104,6 +105,7 @@ export class Lossy {
|
||||||
// apply a filter to the deltas composing that lossless view,
|
// apply a filter to the deltas composing that lossless view,
|
||||||
// and then apply a supplied resolver function which receives
|
// and then apply a supplied resolver function which receives
|
||||||
// the filtered lossless view as input.
|
// the filtered lossless view as input.
|
||||||
|
// TODO: Cache things!
|
||||||
resolve<T = ResolvedViewOne>(fn?: Resolver<T> | Resolver, entityIds?: DomainEntityID[], deltaFilter?: DeltaFilter): T {
|
resolve<T = ResolvedViewOne>(fn?: Resolver<T> | Resolver, entityIds?: DomainEntityID[], deltaFilter?: DeltaFilter): T {
|
||||||
if (!fn) {
|
if (!fn) {
|
||||||
fn = defaultResolver;
|
fn = defaultResolver;
|
||||||
|
|
|
@ -31,7 +31,7 @@ export class RhizomeNode {
|
||||||
requestReply: RequestReply;
|
requestReply: RequestReply;
|
||||||
httpServer: HttpServer;
|
httpServer: HttpServer;
|
||||||
deltaStream: DeltaStream;
|
deltaStream: DeltaStream;
|
||||||
lossless = new Lossless();
|
lossless: Lossless;
|
||||||
peers: Peers;
|
peers: Peers;
|
||||||
myRequestAddr: PeerAddress;
|
myRequestAddr: PeerAddress;
|
||||||
myPublishAddr: PeerAddress;
|
myPublishAddr: PeerAddress;
|
||||||
|
@ -66,9 +66,13 @@ export class RhizomeNode {
|
||||||
this.httpServer = new HttpServer(this);
|
this.httpServer = new HttpServer(this);
|
||||||
this.deltaStream = new DeltaStream(this);
|
this.deltaStream = new DeltaStream(this);
|
||||||
this.peers = new Peers(this);
|
this.peers = new Peers(this);
|
||||||
|
this.lossless = new Lossless();
|
||||||
}
|
}
|
||||||
|
|
||||||
async start() {
|
async start() {
|
||||||
|
// Connect our lossless view to the delta stream
|
||||||
|
this.deltaStream.subscribeDeltas((delta) => this.lossless.ingestDelta(delta));
|
||||||
|
|
||||||
// Start ZeroMQ publish and reply sockets
|
// Start ZeroMQ publish and reply sockets
|
||||||
this.pubSub.start();
|
this.pubSub.start();
|
||||||
this.requestReply.start();
|
this.requestReply.start();
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
import Debug from 'debug';
|
import Debug from 'debug';
|
||||||
import {Message} from 'zeromq';
|
import {Message} from 'zeromq';
|
||||||
import {SEED_PEERS} from "./config";
|
import {SEED_PEERS} from "./config";
|
||||||
|
import {Delta} from "./delta";
|
||||||
import {RhizomeNode} from "./node";
|
import {RhizomeNode} from "./node";
|
||||||
import {Subscription} from './pub-sub';
|
import {Subscription} from './pub-sub';
|
||||||
import {PeerRequest, RequestSocket, ResponseSocket} from "./request-reply";
|
import {PeerRequest, RequestSocket, ResponseSocket} from "./request-reply";
|
||||||
import {PeerAddress} from "./types";
|
import {PeerAddress} from "./types";
|
||||||
import {Delta} from "./delta";
|
|
||||||
const debug = Debug('peers');
|
const debug = Debug('peers');
|
||||||
|
|
||||||
export enum RequestMethods {
|
export enum RequestMethods {
|
||||||
|
@ -92,7 +92,9 @@ export class Peers {
|
||||||
debug('it\'s a request for deltas');
|
debug('it\'s a request for deltas');
|
||||||
// TODO: stream these rather than
|
// TODO: stream these rather than
|
||||||
// trying to write them all in one message
|
// trying to write them all in one message
|
||||||
await res.send(JSON.stringify(this.rhizomeNode.deltaStream.deltasAccepted));
|
const deltas = this.rhizomeNode.deltaStream.deltasAccepted;
|
||||||
|
debug(`sending ${deltas.length} deltas`);
|
||||||
|
await res.send(JSON.stringify(deltas));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -45,7 +45,6 @@ export class ResponseSocket {
|
||||||
if (typeof msg === 'object') {
|
if (typeof msg === 'object') {
|
||||||
msg = JSON.stringify(msg);
|
msg = JSON.stringify(msg);
|
||||||
}
|
}
|
||||||
debug('sending reply', {msg});
|
|
||||||
await this.sock.send(msg);
|
await this.sock.send(msg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,136 @@
|
||||||
|
import Debug from "debug";
|
||||||
|
import EventEmitter from "events";
|
||||||
|
import {Delta, DeltaID} from "./delta";
|
||||||
|
import {DomainEntityID, TransactionID} from "./types";
|
||||||
|
const debug = Debug("transactions");
|
||||||
|
|
||||||
|
function getDeltaTransactionId(delta: Delta): TransactionID | undefined {
|
||||||
|
const {target: transactionId} = delta.pointers.find(({
|
||||||
|
localContext,
|
||||||
|
target,
|
||||||
|
targetContext
|
||||||
|
}) =>
|
||||||
|
localContext === "_transaction" &&
|
||||||
|
typeof target === "string" &&
|
||||||
|
targetContext === "deltas"
|
||||||
|
) || {};
|
||||||
|
|
||||||
|
if (transactionId && typeof transactionId === "string") {
|
||||||
|
return transactionId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTransactionSize(delta: Delta): {
|
||||||
|
transactionId: TransactionID,
|
||||||
|
size: number
|
||||||
|
} | undefined {
|
||||||
|
const {target: transactionId} = delta.pointers.find(({
|
||||||
|
localContext,
|
||||||
|
target,
|
||||||
|
targetContext
|
||||||
|
}) =>
|
||||||
|
localContext === "_transaction" &&
|
||||||
|
typeof target === "string" &&
|
||||||
|
targetContext === "size"
|
||||||
|
) || {};
|
||||||
|
|
||||||
|
if (transactionId && typeof transactionId === "string") {
|
||||||
|
// This delta describes a transaction
|
||||||
|
const {target: size} = delta.pointers.find(({
|
||||||
|
localContext,
|
||||||
|
target
|
||||||
|
}) =>
|
||||||
|
localContext === "size" &&
|
||||||
|
typeof target === "number"
|
||||||
|
) || {};
|
||||||
|
|
||||||
|
return {transactionId, size: size as number};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Transaction {
|
||||||
|
size?: number;
|
||||||
|
receivedDeltaIds = new Set<DeltaID>();
|
||||||
|
entityIds = new Set<DomainEntityID>();
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Transactions {
|
||||||
|
transactions = new Map<TransactionID, Transaction>();
|
||||||
|
eventStream = new EventEmitter();
|
||||||
|
|
||||||
|
get(id: TransactionID): Transaction | undefined {
|
||||||
|
return this.transactions.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
getOrInit(id: TransactionID): Transaction {
|
||||||
|
let t = this.transactions.get(id);
|
||||||
|
if (!t) {
|
||||||
|
t = new Transaction();
|
||||||
|
this.transactions.set(id, t);
|
||||||
|
}
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
ingestDelta(delta: Delta, targets: DomainEntityID[]): TransactionID | undefined {
|
||||||
|
{
|
||||||
|
const transactionId = getDeltaTransactionId(delta);
|
||||||
|
if (transactionId) {
|
||||||
|
const t = this.getOrInit(transactionId);
|
||||||
|
for (const id of targets) {
|
||||||
|
t.entityIds.add(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// This delta is part of a transaction
|
||||||
|
// Add this to the delta's data structure for quick reference
|
||||||
|
delta.transactionId = transactionId;
|
||||||
|
|
||||||
|
// Update our transaction tracking
|
||||||
|
this.receivedDelta(transactionId, delta.id);
|
||||||
|
|
||||||
|
// Notify that the transaction is complete
|
||||||
|
if (this.isComplete(transactionId)) {
|
||||||
|
this.eventStream.emit("completed", transactionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return transactionId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const {transactionId, size} = getTransactionSize(delta) || {};
|
||||||
|
if (transactionId && size) {
|
||||||
|
// This delta describes a transaction
|
||||||
|
|
||||||
|
debug(`transaction ${transactionId} has size ${size}`);
|
||||||
|
|
||||||
|
this.setSize(transactionId, size as number);
|
||||||
|
|
||||||
|
// Check if the transaction is complete
|
||||||
|
if (this.isComplete(transactionId)) {
|
||||||
|
this.eventStream.emit("completed", transactionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return transactionId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
receivedDelta(id: TransactionID, deltaId: DeltaID) {
|
||||||
|
const t = this.getOrInit(id);
|
||||||
|
t.receivedDeltaIds.add(deltaId);
|
||||||
|
}
|
||||||
|
|
||||||
|
isComplete(id: TransactionID) {
|
||||||
|
const t = this.getOrInit(id);
|
||||||
|
return t.size !== undefined && t.receivedDeltaIds.size === t.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSize(id: TransactionID, size: number) {
|
||||||
|
const t = this.getOrInit(id);
|
||||||
|
t.size = size;
|
||||||
|
}
|
||||||
|
|
||||||
|
get ids() {
|
||||||
|
return Array.from(this.transactions.keys());
|
||||||
|
}
|
||||||
|
}
|
|
@ -9,6 +9,8 @@ export type PropertyTypes = string | number | undefined;
|
||||||
export type DomainEntityID = string;
|
export type DomainEntityID = string;
|
||||||
export type PropertyID = string;
|
export type PropertyID = string;
|
||||||
export type TransactionID = string;
|
export type TransactionID = string;
|
||||||
|
export type HostID = string;
|
||||||
|
export type CreatorID = string;
|
||||||
|
|
||||||
export type Timestamp = number;
|
export type Timestamp = number;
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue