jinaga
Version:
Data management for web and mobile applications.
323 lines (285 loc) • 12.6 kB
text/typescript
import { DistributionEngine } from "../distribution/distribution-engine";
import { FeedResponse } from "../http/messages";
import { Subscriber } from "../observer/subscriber";
import { describeDeclaration, describeSpecification } from "../specification/description";
import { buildFeeds } from "../specification/feed-builder";
import { FeedCache } from "../specification/feed-cache";
import { Specification, reduceSpecification } from "../specification/specification";
import { FactEnvelope, FactReference, ReferencesByName, Storage, factReferenceEquals } from "../storage";
import { computeStringHash } from "../util/encoding";
import { Trace } from "../util/trace";
export interface Network {
feeds(start: FactReference[], specification: Specification): Promise<string[]>;
fetchFeed(feed: string, bookmark: string): Promise<FeedResponse>;
streamFeed(feed: string, bookmark: string, onResponse: (factReferences: FactReference[], nextBookmark: string) => Promise<void>, onError: (err: Error) => void): () => void;
load(factReferences: FactReference[]): Promise<FactEnvelope[]>;
}
export class NetworkNoOp implements Network {
feeds(start: FactReference[], specification: Specification): Promise<string[]> {
return Promise.resolve([]);
}
fetchFeed(feed: string, bookmark: string): Promise<FeedResponse> {
return Promise.resolve({ references: [], bookmark });
}
streamFeed(feed: string, bookmark: string, onResponse: (factReferences: FactReference[], nextBookmark: string) => Promise<void>, onError: (err: Error) => void): () => void {
// Do nothing.
return () => { };
}
load(factReferences: FactReference[]): Promise<FactEnvelope[]> {
return Promise.resolve([]);
}
}
export class NetworkDistribution implements Network {
private feedCache = new FeedCache();
constructor(
private readonly distributionEngine: DistributionEngine,
private readonly user: FactReference | null
) { }
async feeds(start: FactReference[], specification: Specification): Promise<string[]> {
const feeds = buildFeeds(specification);
const namedStart = specification.given.reduce((map, label, index) => ({
...map,
[label.name]: start[index]
}), {} as ReferencesByName);
const canDistribute = await this.distributionEngine.canDistributeToAll(feeds, namedStart, this.user);
if (canDistribute.type === 'failure') {
throw new Error(`Not authorized: ${canDistribute.reason}`);
}
return this.feedCache.addFeeds(feeds, namedStart);
}
async fetchFeed(feed: string, bookmark: string): Promise<FeedResponse> {
const feedObject = this.feedCache.getFeed(feed);
if (!feedObject) {
throw new Error(`Feed ${feed} not found`);
}
const canDistribute = await this.distributionEngine.canDistributeToAll([feedObject.feed], feedObject.namedStart, this.user);
if (canDistribute.type === 'failure') {
throw new Error(`Not authorized: ${canDistribute.reason}`);
}
// Pretend that we are at the end of the feed.
return {
references: [],
bookmark
};
}
streamFeed(feed: string, bookmark: string, onResponse: (factReferences: FactReference[], nextBookmark: string) => Promise<void>, onError: (err: Error) => void): () => void {
const feedObject = this.feedCache.getFeed(feed);
if (!feedObject) {
onError(new Error(`Feed ${feed} not found`));
return () => { };
}
this.distributionEngine.canDistributeToAll([feedObject.feed], feedObject.namedStart, this.user)
.then(canDistribute => {
if (canDistribute.type === 'failure') {
onError(new Error(`Not authorized: ${canDistribute.reason}`));
return;
}
// Pretend that we are at the end of the feed.
onResponse([], bookmark);
})
.catch(err => {
onError(err);
});
return () => { };
}
load(factReferences: FactReference[]): Promise<FactEnvelope[]> {
return Promise.resolve([]);
}
}
class LoadBatch {
private readonly factReferences: FactReference[] = [];
private started = false;
public readonly completed: Promise<void>;
private resolve: (() => void) | undefined;
private reject: ((reason: any) => void) | undefined;
private timeout: NodeJS.Timeout;
constructor(
private readonly network: Network,
private readonly store: Storage,
private readonly notifyFactsAdded: (factsAdded: FactEnvelope[]) => Promise<void>,
private readonly onRun: () => void
) {
this.completed = new Promise<void>((resolve, reject) => {
this.resolve = resolve;
this.reject = reject;
});
this.timeout = setTimeout(() => {
this.run();
this.onRun();
}, 100);
}
add(factReferences: FactReference[]) {
for (const fr of factReferences) {
if (!this.factReferences.some(factReferenceEquals(fr))) {
this.factReferences.push(fr);
}
}
}
trigger() {
clearTimeout(this.timeout);
this.run();
this.onRun();
}
private run() {
if (!this.started) {
this.load()
.then(this.resolve)
.catch(this.reject);
this.started = true;
}
}
private async load() {
const graph: FactEnvelope[] = await this.network.load(this.factReferences);
const factsAdded = await this.store.save(graph);
if (factsAdded.length > 0) {
Trace.counter("facts_saved", factsAdded.length);
await this.notifyFactsAdded(factsAdded);
}
}
}
export class NetworkManager {
private readonly feedsCache = new Map<string, string[]>();
private readonly activeFeeds = new Map<string, Promise<void>>();
private fetchCount = 0;
private currentBatch: LoadBatch | null = null;
private subscribers: Map<string, Subscriber> = new Map();
private readonly feedRefreshIntervalSeconds: number;
constructor(
private readonly network: Network,
private readonly store: Storage,
private readonly notifyFactsAdded: (factsAdded: FactEnvelope[]) => Promise<void>,
feedRefreshIntervalSeconds?: number
) {
this.feedRefreshIntervalSeconds = feedRefreshIntervalSeconds || 4 * 60; // Default to 4 minutes
}
async fetch(start: FactReference[], specification: Specification) {
const reducedSpecification = reduceSpecification(specification);
const feeds: string[] = await this.getFeedsFromCache(start, reducedSpecification);
// Fork to fetch from each feed.
const promises = feeds.map(feed => {
if (this.activeFeeds.has(feed)) {
return this.activeFeeds.get(feed);
}
else {
const promise = this.processFeed(feed);
this.activeFeeds.set(feed, promise);
return promise;
}
});
try {
await Promise.all(promises);
}
catch (e) {
// If any feed fails, then remove the specification from the cache.
this.removeFeedsFromCache(start, reducedSpecification);
throw e;
}
}
async subscribe(start: FactReference[], specification: Specification): Promise<string[]> {
const reducedSpecification = reduceSpecification(specification);
const feeds: string[] = await this.getFeedsFromCache(start, reducedSpecification);
const subscribers = feeds.map(feed => {
let subscriber = this.subscribers.get(feed);
if (!subscriber) {
subscriber = new Subscriber(feed, this.network, this.store, this.notifyFactsAdded, this.feedRefreshIntervalSeconds);
this.subscribers.set(feed, subscriber);
}
return subscriber;
});
const promises = subscribers.map(async subscriber => {
if (subscriber.addRef()) {
await subscriber.start();
}
});
try {
await Promise.all(promises);
}
catch (e) {
// If any feed fails, then remove the specification from the cache.
this.removeFeedsFromCache(start, reducedSpecification);
this.unsubscribe(feeds);
throw e;
}
return feeds;
}
unsubscribe(feeds: string[]) {
for (const feed of feeds) {
const subscriber = this.subscribers.get(feed);
if (!subscriber) {
throw new Error(`Subscriber not found for feed ${feed}`);
}
if (subscriber.release()) {
subscriber.stop();
this.subscribers.delete(feed);
}
}
}
private async getFeedsFromCache(start: FactReference[], specification: Specification): Promise<string[]> {
const hash = getSpecificationHash(start, specification);
const cached = this.feedsCache.get(hash);
if (cached) {
return cached;
}
const feeds = await this.network.feeds(start, specification);
this.feedsCache.set(hash, feeds);
return feeds;
}
private removeFeedsFromCache(start: FactReference[], specification: Specification) {
const hash = getSpecificationHash(start, specification);
this.feedsCache.delete(hash);
}
private async processFeed(feed: string) {
let bookmark = await this.store.loadBookmark(feed);
while (true) {
this.fetchCount++;
let decremented = false;
try {
const { references: factReferences, bookmark: nextBookmark } = await this.network.fetchFeed(feed, bookmark);
if (factReferences.length === 0) {
break;
}
const knownFactReferences: FactReference[] = await this.store.whichExist(factReferences);
const unknownFactReferences: FactReference[] = factReferences.filter(fr => !knownFactReferences.includes(fr));
if (unknownFactReferences.length > 0) {
let batch = this.currentBatch;
if (batch === null) {
// Begin a new batch.
batch = new LoadBatch(this.network, this.store, this.notifyFactsAdded, () => {
if (this.currentBatch === batch) {
this.currentBatch = null;
}
});
this.currentBatch = batch;
}
batch.add(unknownFactReferences);
this.fetchCount--;
decremented = true;
if (this.fetchCount === 0) {
// This is the last fetch, so trigger the batch.
batch.trigger();
}
await batch.completed;
}
bookmark = nextBookmark;
await this.store.saveBookmark(feed, bookmark);
}
finally {
if (!decremented) {
this.fetchCount--;
if (this.fetchCount === 0 && this.currentBatch !== null) {
// This is the last fetch, so trigger the batch.
this.currentBatch.trigger();
}
}
}
}
this.activeFeeds.delete(feed);
}
}
function getSpecificationHash(start: FactReference[], specification: Specification) {
const declarationString = describeDeclaration(start, specification.given);
const specificationString = describeSpecification(specification, 0);
const request = `${declarationString}\n${specificationString}`;
const hash = computeStringHash(request);
return hash;
}