@toa.io/extensions.convergence 1.0.0-alpha.298
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +37 -0
- package/readme.md +117 -0
- package/transpiled/Destination.d.ts +25 -0
- package/transpiled/Destination.js +45 -0
- package/transpiled/Destination.js.map +1 -0
- package/transpiled/Factory.d.ts +30 -0
- package/transpiled/Factory.js +53 -0
- package/transpiled/Factory.js.map +1 -0
- package/transpiled/Storage.d.ts +39 -0
- package/transpiled/Storage.js +115 -0
- package/transpiled/Storage.js.map +1 -0
- package/transpiled/index.d.ts +1 -0
- package/transpiled/index.js +2 -0
- package/transpiled/index.js.map +1 -0
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@toa.io/extensions.convergence",
|
|
3
|
+
"version": "1.0.0-alpha.298",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Toa Convergence extension",
|
|
6
|
+
"author": "temich <tema.gurtovoy@gmail.com>",
|
|
7
|
+
"homepage": "https://github.com/toa-io/toa#readme",
|
|
8
|
+
"main": "transpiled/index.js",
|
|
9
|
+
"types": "transpiled/index.d.ts",
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/toa-io/toa.git"
|
|
13
|
+
},
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/toa-io/toa/issues"
|
|
16
|
+
},
|
|
17
|
+
"publishConfig": {
|
|
18
|
+
"access": "public"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"transpiled",
|
|
22
|
+
"readme.md",
|
|
23
|
+
"!**/tsconfig.tsbuildinfo",
|
|
24
|
+
"!**/*.test.*",
|
|
25
|
+
"!**/.env*"
|
|
26
|
+
],
|
|
27
|
+
"scripts": {
|
|
28
|
+
"transpile": "tsc"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@toa.io/core": "1.0.0-alpha.293",
|
|
32
|
+
"@toa.io/definitions": "1.0.0-alpha.298",
|
|
33
|
+
"@toa.io/generic": "1.0.0-alpha.292",
|
|
34
|
+
"@toa.io/pointer": "1.0.0-alpha.293",
|
|
35
|
+
"openspan": "1.0.0-alpha.293"
|
|
36
|
+
}
|
|
37
|
+
}
|
package/readme.md
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# Toa Convergence
|
|
2
|
+
|
|
3
|
+
Two or more deployments of one context, each with its own database, converging on the same
|
|
4
|
+
entity state.
|
|
5
|
+
|
|
6
|
+
## Declaration
|
|
7
|
+
|
|
8
|
+
A region is a deployment of the context, not a variant of it: one context declares every region
|
|
9
|
+
there is, at its root.
|
|
10
|
+
|
|
11
|
+
```yaml
|
|
12
|
+
# context.toa.yaml
|
|
13
|
+
convergence:
|
|
14
|
+
- region: eu
|
|
15
|
+
priority: 0 # a rank: 0 outranks 1
|
|
16
|
+
binding:
|
|
17
|
+
provider: amqp
|
|
18
|
+
pointer: [amqp://cnv-eu-0.example.com, amqp://cnv-eu-1.example.com]
|
|
19
|
+
- region: us
|
|
20
|
+
priority: 1
|
|
21
|
+
binding: { provider: amqp, pointer: amqp://cnv-us.example.com }
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Nothing is declared in a manifest. A context that declares convergence converges **every
|
|
25
|
+
component that stores anything**, as the outbox publishes every event something consumes.
|
|
26
|
+
|
|
27
|
+
`pointer` is a [pointer](/libraries/pointer), so a URL carries no credentials — they are
|
|
28
|
+
deployed as secrets — and shards syntax works. It is flat: a URL or a list of them. A region has
|
|
29
|
+
one set of brokers, and they are that region's own, shared with nothing else.
|
|
30
|
+
|
|
31
|
+
## Deploying
|
|
32
|
+
|
|
33
|
+
Which region a deployment is, is chosen when it is deployed:
|
|
34
|
+
|
|
35
|
+
```shell
|
|
36
|
+
$ TOA_CONVERGENCE_REGION=eu toa deploy production
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
An operator can write it in `.env` instead of prefixing every command. Without it, a context
|
|
40
|
+
that declares convergence is refused rather than deployed as a region it cannot name.
|
|
41
|
+
|
|
42
|
+
**The region an existing application is first deployed as is rank `0`.** Its records were
|
|
43
|
+
written by whatever region it is now becoming, and that is what they are recorded as.
|
|
44
|
+
|
|
45
|
+
## The brokers
|
|
46
|
+
|
|
47
|
+
Convergence has its own brokers, one set per region. The context binding carries what happens
|
|
48
|
+
inside a region; these carry what crosses between them, and RabbitMQ federation is a thing to
|
|
49
|
+
keep away from the brokers an application depends on to serve a request.
|
|
50
|
+
|
|
51
|
+
Each region's broker holds two exchanges, the same two names everywhere:
|
|
52
|
+
|
|
53
|
+
| | |
|
|
54
|
+
| --- | --- |
|
|
55
|
+
| `convergence.out` | what this deployment publishes to. Nothing local binds to it, so a region is never delivered its own writes. |
|
|
56
|
+
| `convergence.in` | **federated** from every other region's `convergence.out`. |
|
|
57
|
+
| `convergence.<namespace>.<name>` | a durable queue per component, bound to `convergence.in` under that component's key. |
|
|
58
|
+
|
|
59
|
+
**Toa does not configure the federation.** On each region's broker, an upstream per other region
|
|
60
|
+
and one policy over them:
|
|
61
|
+
|
|
62
|
+
```shell
|
|
63
|
+
$ rabbitmqctl set_parameter federation-upstream us \
|
|
64
|
+
'{"uri":"amqp://cnv-us.example.com","exchange":"convergence.out","max-hops":1}'
|
|
65
|
+
|
|
66
|
+
$ rabbitmqctl set_policy convergence '^convergence\.in$' \
|
|
67
|
+
'{"federation-upstream-set":"all"}' --apply-to exchanges
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Two exchanges and one link per other region, whatever the number of components. Federation
|
|
71
|
+
propagates a queue's binding keys upstream, so a region pulls only records for the components it
|
|
72
|
+
runs.
|
|
73
|
+
|
|
74
|
+
## What it guarantees
|
|
75
|
+
|
|
76
|
+
**Convergence.** Every region ends with the same record, whatever order records arrive in and
|
|
77
|
+
however often they repeat.
|
|
78
|
+
|
|
79
|
+
**Whole records.** A record is replaced entire. Two regions writing the same entity at the same
|
|
80
|
+
time lose one of the two writes completely; there is no field-level merge.
|
|
81
|
+
|
|
82
|
+
**Latency, not atomicity.** A read in one region may be behind another by the federation link.
|
|
83
|
+
Nothing is transactional across regions.
|
|
84
|
+
|
|
85
|
+
**One failure does not become the other's.** A convergence broker that is down delays
|
|
86
|
+
convergence and nothing else: the region's own events are unaffected, and neither is republished
|
|
87
|
+
because of the other.
|
|
88
|
+
|
|
89
|
+
**Changes only.** Convergence carries what happens after it is on. Seeding a new region is a
|
|
90
|
+
database copy, made before it serves.
|
|
91
|
+
|
|
92
|
+
## What it requires
|
|
93
|
+
|
|
94
|
+
**A replica set.** A component that converges does not start where the outbox is not durable —
|
|
95
|
+
where a change that failed to publish would be lost rather than recovered, and the regions would
|
|
96
|
+
differ with nothing to say so. Events degrade there; convergence refuses.
|
|
97
|
+
|
|
98
|
+
**[Atomicity](/connectors/atomicity).** Without it the outbox pump recovers nothing, so a
|
|
99
|
+
publication that fails is never retried and that change is lost for good. Nothing detects this,
|
|
100
|
+
which is why it is written here rather than raised.
|
|
101
|
+
|
|
102
|
+
**The same components in every region.** A component deployed in one and not another has no
|
|
103
|
+
queue there, so its records cannot be routed; publishing is `mandatory`, so what had nowhere to
|
|
104
|
+
go is logged rather than dropped in silence.
|
|
105
|
+
|
|
106
|
+
**Unique indexes other than `_id` are not safe.** Two regions can independently take the same
|
|
107
|
+
value, and the record that arrives second cannot be stored at all. It is logged and dropped;
|
|
108
|
+
redelivery would not help.
|
|
109
|
+
|
|
110
|
+
## What a record carries
|
|
111
|
+
|
|
112
|
+
[`REGION`](/documentation/component/declaration.md#entity) is which region wrote a record, as
|
|
113
|
+
that region's rank. An incoming record wins where its version is greater, or where the version
|
|
114
|
+
is equal and the region that wrote it outranks the one that wrote what is stored. `priority` is
|
|
115
|
+
read there and nowhere else.
|
|
116
|
+
|
|
117
|
+
An unmanaged operation reads the driver's own handle, and will see the field.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Connector } from '@toa.io/core';
|
|
2
|
+
import type { Locator } from '@toa.io/core';
|
|
3
|
+
import type { bindings, outbox } from '@toa.io/core/types';
|
|
4
|
+
/**
|
|
5
|
+
* Where a committed state change of this component goes, beside its own events. The outbox
|
|
6
|
+
* owns everything about getting it there — the row committed with the entity, the retry, the
|
|
7
|
+
* drain — so this is only what the record becomes on the wire.
|
|
8
|
+
*/
|
|
9
|
+
export declare class Destination extends Connector implements outbox.Destination {
|
|
10
|
+
readonly name = "convergence";
|
|
11
|
+
private readonly label;
|
|
12
|
+
private readonly resolve;
|
|
13
|
+
private readonly span;
|
|
14
|
+
private outbound;
|
|
15
|
+
constructor(locator: Locator, resolve: () => Promise<bindings.Outbound>);
|
|
16
|
+
emit(event: outbox.Row['event']): Promise<void>;
|
|
17
|
+
protected open(): Promise<void>;
|
|
18
|
+
}
|
|
19
|
+
/** What convergence puts on the wire. Owned by this extension at both ends. */
|
|
20
|
+
export interface Message {
|
|
21
|
+
/** the record as it stands — VERSION, timestamps, REGION and all */
|
|
22
|
+
record: object;
|
|
23
|
+
/** W3C traceparent, so the merge continues the trace of the write that caused it */
|
|
24
|
+
trace?: string;
|
|
25
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { console, current, encode } from 'openspan';
|
|
2
|
+
import { Connector } from '@toa.io/core';
|
|
3
|
+
import { CHANNEL } from '@toa.io/definitions/extensions.convergence';
|
|
4
|
+
/**
|
|
5
|
+
* Where a committed state change of this component goes, beside its own events. The outbox
|
|
6
|
+
* owns everything about getting it there — the row committed with the entity, the retry, the
|
|
7
|
+
* drain — so this is only what the record becomes on the wire.
|
|
8
|
+
*/
|
|
9
|
+
export class Destination extends Connector {
|
|
10
|
+
name = CHANNEL;
|
|
11
|
+
label;
|
|
12
|
+
resolve;
|
|
13
|
+
span;
|
|
14
|
+
outbound;
|
|
15
|
+
constructor(locator, resolve) {
|
|
16
|
+
super();
|
|
17
|
+
this.label = locator.id;
|
|
18
|
+
this.resolve = resolve;
|
|
19
|
+
this.span = {
|
|
20
|
+
name: `${CHANNEL} send`,
|
|
21
|
+
kind: 'producer',
|
|
22
|
+
attributes: { 'messaging.destination.name': CHANNEL }
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
async emit(event) {
|
|
26
|
+
await console.span(this.span, async () => {
|
|
27
|
+
const context = current();
|
|
28
|
+
/*
|
|
29
|
+
* The record says which region wrote it, so the message does not say it again. `trace`
|
|
30
|
+
* is convergence's own field rather than anything a binding puts there: it owns both
|
|
31
|
+
* ends, and it wants the merge to continue the trace of the write that caused it.
|
|
32
|
+
*/
|
|
33
|
+
const message = { record: event.state };
|
|
34
|
+
if (context !== undefined)
|
|
35
|
+
message.trace = encode(context);
|
|
36
|
+
await this.outbound.send(this.label, message);
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
async open() {
|
|
40
|
+
this.outbound = await this.resolve();
|
|
41
|
+
await this.outbound.connect();
|
|
42
|
+
this.depends(this.outbound);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
//# sourceMappingURL=Destination.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"Destination.js","sourceRoot":"","sources":["../source/Destination.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,EAAoB,MAAM,UAAU,CAAA;AACrE,OAAO,EAAE,SAAS,EAAE,MAAM,cAAc,CAAA;AACxC,OAAO,EAAE,OAAO,EAAE,MAAM,4CAA4C,CAAA;AAIpE;;;;GAIG;AACH,MAAM,OAAO,WAAY,SAAQ,SAAS;IACxB,IAAI,GAAG,OAAO,CAAA;IAEb,KAAK,CAAQ;IACb,OAAO,CAAkC;IACzC,IAAI,CAAa;IAE1B,QAAQ,CAAoB;IAEpC,YAAmB,OAAgB,EAAE,OAAyC;QAC5E,KAAK,EAAE,CAAA;QAEP,IAAI,CAAC,KAAK,GAAG,OAAO,CAAC,EAAE,CAAA;QACvB,IAAI,CAAC,OAAO,GAAG,OAAO,CAAA;QAEtB,IAAI,CAAC,IAAI,GAAG;YACV,IAAI,EAAE,GAAG,OAAO,OAAO;YACvB,IAAI,EAAE,UAAU;YAChB,UAAU,EAAE,EAAE,4BAA4B,EAAE,OAAO,EAAE;SACtD,CAAA;IACH,CAAC;IAEM,KAAK,CAAC,IAAI,CAAC,KAA0B;QAC1C,MAAM,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,KAAK,IAAI,EAAE;YACvC,MAAM,OAAO,GAAG,OAAO,EAAE,CAAA;YAEzB;;;;eAIG;YACH,MAAM,OAAO,GAAY,EAAE,MAAM,EAAE,KAAK,CAAC,KAAK,EAAE,CAAA;YAEhD,IAAI,OAAO,KAAK,SAAS;gBAAE,OAAO,CAAC,KAAK,GAAG,MAAM,CAAC,OAAO,CAAC,CAAA;YAE1D,MAAM,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,KAAK,EAAE,OAAO,CAAC,CAAA;QAC/C,CAAC,CAAC,CAAA;IACJ,CAAC;IAEkB,KAAK,CAAC,IAAI;QAC3B,IAAI,CAAC,QAAQ,GAAG,MAAM,IAAI,CAAC,OAAO,EAAE,CAAA;QAEpC,MAAM,IAAI,CAAC,QAAQ,CAAC,OAAO,EAAE,CAAA;QAE7B,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAA;IAC7B,CAAC;CACF"}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { Destination } from './Destination.js';
|
|
2
|
+
import type { Locator } from '@toa.io/core';
|
|
3
|
+
import type { extensions, storages } from '@toa.io/core/types';
|
|
4
|
+
/**
|
|
5
|
+
* Two contributions, and the whole of the extension. What a record says about the region that
|
|
6
|
+
* wrote it, and the rule that reads it, are the runtime's; what a row is published to, and how
|
|
7
|
+
* it is carried, are the outbox's and the binding's. This is what ties the three together.
|
|
8
|
+
*/
|
|
9
|
+
export declare class Factory implements extensions.Factory {
|
|
10
|
+
private readonly host;
|
|
11
|
+
/** one publisher for the process, however many components send through it */
|
|
12
|
+
private outbound;
|
|
13
|
+
constructor(host: extensions.Host);
|
|
14
|
+
/** out */
|
|
15
|
+
destination(locator: Locator, _: null, manifest: Manifest): Destination | undefined;
|
|
16
|
+
/**
|
|
17
|
+
* In. Guarded, because what decorates a storage is every extension loaded in the process
|
|
18
|
+
* rather than the ones a manifest named: once anything here converges, this is asked about
|
|
19
|
+
* everything. Convergence is a property of a deployment, so what it is deployed with is what
|
|
20
|
+
* says whether there is any.
|
|
21
|
+
*/
|
|
22
|
+
storage(storage: storages.Storage, locator: Locator): storages.Storage;
|
|
23
|
+
private publisher;
|
|
24
|
+
private binding;
|
|
25
|
+
private uris;
|
|
26
|
+
}
|
|
27
|
+
interface Manifest {
|
|
28
|
+
entity?: object;
|
|
29
|
+
}
|
|
30
|
+
export {};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { environment } from '@toa.io/generic';
|
|
2
|
+
import { resolve } from '@toa.io/pointer';
|
|
3
|
+
import { BINDING, BROKERS, CHANNEL, ID } from '@toa.io/definitions/extensions.convergence';
|
|
4
|
+
import { Destination } from './Destination.js';
|
|
5
|
+
import { Converging } from './Storage.js';
|
|
6
|
+
/**
|
|
7
|
+
* Two contributions, and the whole of the extension. What a record says about the region that
|
|
8
|
+
* wrote it, and the rule that reads it, are the runtime's; what a row is published to, and how
|
|
9
|
+
* it is carried, are the outbox's and the binding's. This is what ties the three together.
|
|
10
|
+
*/
|
|
11
|
+
export class Factory {
|
|
12
|
+
host;
|
|
13
|
+
/** one publisher for the process, however many components send through it */
|
|
14
|
+
outbound;
|
|
15
|
+
constructor(host) {
|
|
16
|
+
this.host = host;
|
|
17
|
+
}
|
|
18
|
+
/** out */
|
|
19
|
+
destination(locator, _, manifest) {
|
|
20
|
+
if (!converging() || manifest.entity === undefined)
|
|
21
|
+
return undefined;
|
|
22
|
+
return new Destination(locator, async () => this.publisher());
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* In. Guarded, because what decorates a storage is every extension loaded in the process
|
|
26
|
+
* rather than the ones a manifest named: once anything here converges, this is asked about
|
|
27
|
+
* everything. Convergence is a property of a deployment, so what it is deployed with is what
|
|
28
|
+
* says whether there is any.
|
|
29
|
+
*/
|
|
30
|
+
storage(storage, locator) {
|
|
31
|
+
if (!converging())
|
|
32
|
+
return storage;
|
|
33
|
+
return new Converging(storage, locator, async (sink) => this.host.inbound(this.binding(), CHANNEL, this.uris(), locator.id, sink));
|
|
34
|
+
}
|
|
35
|
+
async publisher() {
|
|
36
|
+
this.outbound ??= this.host.outbound(this.binding(), CHANNEL, this.uris());
|
|
37
|
+
return this.outbound;
|
|
38
|
+
}
|
|
39
|
+
binding() {
|
|
40
|
+
const reference = environment.get(BINDING);
|
|
41
|
+
if (reference === undefined)
|
|
42
|
+
throw new Error(`${BINDING} is not set, so nothing says what carries the channel.`);
|
|
43
|
+
return reference;
|
|
44
|
+
}
|
|
45
|
+
uris() {
|
|
46
|
+
return resolve(ID, BROKERS);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/** Whether this deployment converges at all, which is a thing it is deployed with or not. */
|
|
50
|
+
function converging() {
|
|
51
|
+
return environment.get(BINDING) !== undefined;
|
|
52
|
+
}
|
|
53
|
+
//# sourceMappingURL=Factory.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"Factory.js","sourceRoot":"","sources":["../source/Factory.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAA;AAC7C,OAAO,EAAE,OAAO,EAAE,MAAM,iBAAiB,CAAA;AACzC,OAAO,EACL,OAAO,EACP,OAAO,EACP,OAAO,EACP,EAAE,EACH,MAAM,4CAA4C,CAAA;AACnD,OAAO,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAA;AAC9C,OAAO,EAAE,UAAU,EAAE,MAAM,cAAc,CAAA;AAIzC;;;;GAIG;AACH,MAAM,OAAO,OAAO;IACD,IAAI,CAAiB;IAEtC,6EAA6E;IACrE,QAAQ,CAAwC;IAExD,YAAmB,IAAqB;QACtC,IAAI,CAAC,IAAI,GAAG,IAAI,CAAA;IAClB,CAAC;IAED,UAAU;IACH,WAAW,CAChB,OAAgB,EAChB,CAAO,EACP,QAAkB;QAElB,IAAI,CAAC,UAAU,EAAE,IAAI,QAAQ,CAAC,MAAM,KAAK,SAAS;YAAE,OAAO,SAAS,CAAA;QAEpE,OAAO,IAAI,WAAW,CAAC,OAAO,EAAE,KAAK,IAAI,EAAE,CAAC,IAAI,CAAC,SAAS,EAAE,CAAC,CAAA;IAC/D,CAAC;IAED;;;;;OAKG;IACI,OAAO,CAAC,OAAyB,EAAE,OAAgB;QACxD,IAAI,CAAC,UAAU,EAAE;YAAE,OAAO,OAAO,CAAA;QAEjC,OAAO,IAAI,UAAU,CAAC,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,IAAI,EAAE,EAAE,CACrD,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,OAAO,EAAE,EAAE,OAAO,EAAE,IAAI,CAAC,IAAI,EAAE,EAAE,OAAO,CAAC,EAAE,EAAE,IAAI,CAAC,CAC1E,CAAA;IACH,CAAC;IAEO,KAAK,CAAC,SAAS;QACrB,IAAI,CAAC,QAAQ,KAAK,IAAI,CAAC,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,OAAO,EAAE,EAAE,OAAO,EAAE,IAAI,CAAC,IAAI,EAAE,CAAC,CAAA;QAE1E,OAAO,IAAI,CAAC,QAAQ,CAAA;IACtB,CAAC;IAEO,OAAO;QACb,MAAM,SAAS,GAAG,WAAW,CAAC,GAAG,CAAC,OAAO,CAAC,CAAA;QAE1C,IAAI,SAAS,KAAK,SAAS;YACzB,MAAM,IAAI,KAAK,CAAC,GAAG,OAAO,wDAAwD,CAAC,CAAA;QAErF,OAAO,SAAS,CAAA;IAClB,CAAC;IAEO,IAAI;QACV,OAAO,OAAO,CAAC,EAAE,EAAE,OAAO,CAAC,CAAA;IAC7B,CAAC;CACF;AAED,6FAA6F;AAC7F,SAAS,UAAU;IACjB,OAAO,WAAW,CAAC,GAAG,CAAC,OAAO,CAAC,KAAK,SAAS,CAAA;AAC/C,CAAC"}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { Connector } from '@toa.io/core';
|
|
2
|
+
import type { Readable } from 'node:stream';
|
|
3
|
+
import type { Connector as Link, Locator } from '@toa.io/core';
|
|
4
|
+
import type { bindings, outbox, storages } from '@toa.io/core/types';
|
|
5
|
+
/**
|
|
6
|
+
* A record from another region is written as it stands: its version, its timestamps, its
|
|
7
|
+
* region. That is neither a transition nor an assignment — it is not this region's version to
|
|
8
|
+
* increment nor its timestamps to set — so it goes to the storage rather than through an
|
|
9
|
+
* operation, and this is what holds one.
|
|
10
|
+
*
|
|
11
|
+
* Everything else it delegates. It decorates the storage because that is where a handle to one
|
|
12
|
+
* is, not because it changes anything a component reads or writes.
|
|
13
|
+
*/
|
|
14
|
+
export declare class Converging extends Connector implements storages.Storage, bindings.Inbound {
|
|
15
|
+
private readonly storage;
|
|
16
|
+
private readonly locator;
|
|
17
|
+
private readonly subscribe;
|
|
18
|
+
private readonly delivery;
|
|
19
|
+
private readonly processing;
|
|
20
|
+
constructor(storage: storages.Storage, locator: Locator, connect: (sink: bindings.Inbound) => Promise<Link>);
|
|
21
|
+
/**
|
|
22
|
+
* There is no counter to increment here, so the outcome is on the span: without it,
|
|
23
|
+
* convergence dropping a duplicate and convergence dropping everything look exactly alike.
|
|
24
|
+
*/
|
|
25
|
+
accept(message: object): Promise<void>;
|
|
26
|
+
protected open(): Promise<void>;
|
|
27
|
+
get raw(): unknown;
|
|
28
|
+
get outbox(): storages.Storage['outbox'];
|
|
29
|
+
get migrates(): boolean | undefined;
|
|
30
|
+
get merges(): boolean | undefined;
|
|
31
|
+
get(query: storages.Query): Promise<storages.Record | null>;
|
|
32
|
+
find(query: storages.Query): Promise<storages.Record[]>;
|
|
33
|
+
stream(query?: storages.Query): Promise<Readable>;
|
|
34
|
+
store(record: storages.Record, row?: outbox.Row): Promise<boolean>;
|
|
35
|
+
massStore(records: storages.Record[], rows?: outbox.Row[]): Promise<boolean>;
|
|
36
|
+
upsert(query: storages.Query, changeset: object, row?: outbox.Row): Promise<storages.Record | null>;
|
|
37
|
+
ensure(query: storages.Query | undefined, properties: object, record: storages.Record, row?: outbox.Row): Promise<storages.Record>;
|
|
38
|
+
merge(record: storages.Record): Promise<boolean>;
|
|
39
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { console, decode, run } from 'openspan';
|
|
2
|
+
import { Connector } from '@toa.io/core';
|
|
3
|
+
import { CHANNEL } from '@toa.io/definitions/extensions.convergence';
|
|
4
|
+
/**
|
|
5
|
+
* A record from another region is written as it stands: its version, its timestamps, its
|
|
6
|
+
* region. That is neither a transition nor an assignment — it is not this region's version to
|
|
7
|
+
* increment nor its timestamps to set — so it goes to the storage rather than through an
|
|
8
|
+
* operation, and this is what holds one.
|
|
9
|
+
*
|
|
10
|
+
* Everything else it delegates. It decorates the storage because that is where a handle to one
|
|
11
|
+
* is, not because it changes anything a component reads or writes.
|
|
12
|
+
*/
|
|
13
|
+
export class Converging extends Connector {
|
|
14
|
+
storage;
|
|
15
|
+
locator;
|
|
16
|
+
subscribe;
|
|
17
|
+
delivery;
|
|
18
|
+
processing;
|
|
19
|
+
constructor(storage, locator, connect) {
|
|
20
|
+
super();
|
|
21
|
+
this.storage = storage;
|
|
22
|
+
this.locator = locator;
|
|
23
|
+
this.subscribe = connect;
|
|
24
|
+
// what it decorates opens before it and closes after it, so the storage is whole while a
|
|
25
|
+
// delivery is still draining
|
|
26
|
+
this.depends(storage);
|
|
27
|
+
this.delivery = {
|
|
28
|
+
name: `${CHANNEL} deliver`,
|
|
29
|
+
kind: 'producer',
|
|
30
|
+
service: CHANNEL,
|
|
31
|
+
attributes: { 'messaging.destination.name': CHANNEL }
|
|
32
|
+
};
|
|
33
|
+
this.processing = {
|
|
34
|
+
name: `${CHANNEL} merge`,
|
|
35
|
+
kind: 'consumer',
|
|
36
|
+
service: locator.id,
|
|
37
|
+
attributes: { 'messaging.destination.name': CHANNEL }
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* There is no counter to increment here, so the outcome is on the span: without it,
|
|
42
|
+
* convergence dropping a duplicate and convergence dropping everything look exactly alike.
|
|
43
|
+
*/
|
|
44
|
+
async accept(message) {
|
|
45
|
+
const { record, trace } = message;
|
|
46
|
+
const remote = trace === undefined ? null : decode(trace);
|
|
47
|
+
const task = async () => console.span(this.delivery, async () => console.span(this.processing, async () => {
|
|
48
|
+
const merged = await this.storage.merge(record);
|
|
49
|
+
console.debug('Convergence merge', {
|
|
50
|
+
component: this.locator.id,
|
|
51
|
+
outcome: merged ? 'applied' : 'stale'
|
|
52
|
+
});
|
|
53
|
+
}));
|
|
54
|
+
if (remote === null)
|
|
55
|
+
await task();
|
|
56
|
+
else
|
|
57
|
+
await run(remote, task);
|
|
58
|
+
}
|
|
59
|
+
async open() {
|
|
60
|
+
if (this.storage.merges !== true)
|
|
61
|
+
throw new Error(`Component '${this.locator.id}' converges, which its storage does not do. ` +
|
|
62
|
+
'A record written in another region has nowhere to land.');
|
|
63
|
+
/*
|
|
64
|
+
* Without a durable outbox a publication is lost where the process dies before it lands,
|
|
65
|
+
* and a lost one here is two regions differing for good, with nothing that notices and
|
|
66
|
+
* nothing that repairs it. That is why this refuses where events only degrade.
|
|
67
|
+
*/
|
|
68
|
+
if (this.storage.outbox === undefined)
|
|
69
|
+
throw new Error(`Component '${this.locator.id}' converges, and its outbox is not durable. ` +
|
|
70
|
+
'MongoDB is not a replica set, so a change that fails to publish is lost rather ' +
|
|
71
|
+
'than recovered, and the regions would differ with nothing to say so.');
|
|
72
|
+
const inbound = await this.subscribe(this);
|
|
73
|
+
await inbound.connect();
|
|
74
|
+
this.depends(inbound);
|
|
75
|
+
}
|
|
76
|
+
// region delegated
|
|
77
|
+
get raw() {
|
|
78
|
+
return this.storage.raw;
|
|
79
|
+
}
|
|
80
|
+
get outbox() {
|
|
81
|
+
return this.storage.outbox;
|
|
82
|
+
}
|
|
83
|
+
get migrates() {
|
|
84
|
+
return this.storage.migrates;
|
|
85
|
+
}
|
|
86
|
+
get merges() {
|
|
87
|
+
return this.storage.merges;
|
|
88
|
+
}
|
|
89
|
+
async get(query) {
|
|
90
|
+
return this.storage.get(query);
|
|
91
|
+
}
|
|
92
|
+
async find(query) {
|
|
93
|
+
return this.storage.find(query);
|
|
94
|
+
}
|
|
95
|
+
async stream(query) {
|
|
96
|
+
return this.storage.stream(query);
|
|
97
|
+
}
|
|
98
|
+
async store(record, row) {
|
|
99
|
+
return this.storage.store(record, row);
|
|
100
|
+
}
|
|
101
|
+
async massStore(records, rows) {
|
|
102
|
+
return this.storage.massStore(records, rows);
|
|
103
|
+
}
|
|
104
|
+
async upsert(query, changeset, row) {
|
|
105
|
+
return this.storage.upsert(query, changeset, row);
|
|
106
|
+
}
|
|
107
|
+
// eslint-disable-next-line max-params
|
|
108
|
+
async ensure(query, properties, record, row) {
|
|
109
|
+
return this.storage.ensure(query, properties, record, row);
|
|
110
|
+
}
|
|
111
|
+
async merge(record) {
|
|
112
|
+
return this.storage.merge(record);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
//# sourceMappingURL=Storage.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"Storage.js","sourceRoot":"","sources":["../source/Storage.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,GAAG,EAAoB,MAAM,UAAU,CAAA;AACjE,OAAO,EAAE,SAAS,EAAE,MAAM,cAAc,CAAA;AACxC,OAAO,EAAE,OAAO,EAAE,MAAM,4CAA4C,CAAA;AAMpE;;;;;;;;GAQG;AACH,MAAM,OAAO,UAAW,SAAQ,SAAS;IACtB,OAAO,CAAkB;IACzB,OAAO,CAAS;IAChB,SAAS,CAA2C;IACpD,QAAQ,CAAa;IACrB,UAAU,CAAa;IAExC,YACE,OAAyB,EACzB,OAAgB,EAChB,OAAkD;QAElD,KAAK,EAAE,CAAA;QAEP,IAAI,CAAC,OAAO,GAAG,OAAO,CAAA;QACtB,IAAI,CAAC,OAAO,GAAG,OAAO,CAAA;QACtB,IAAI,CAAC,SAAS,GAAG,OAAO,CAAA;QAExB,yFAAyF;QACzF,6BAA6B;QAC7B,IAAI,CAAC,OAAO,CAAC,OAAO,CAAC,CAAA;QAErB,IAAI,CAAC,QAAQ,GAAG;YACd,IAAI,EAAE,GAAG,OAAO,UAAU;YAC1B,IAAI,EAAE,UAAU;YAChB,OAAO,EAAE,OAAO;YAChB,UAAU,EAAE,EAAE,4BAA4B,EAAE,OAAO,EAAE;SACtD,CAAA;QAED,IAAI,CAAC,UAAU,GAAG;YAChB,IAAI,EAAE,GAAG,OAAO,QAAQ;YACxB,IAAI,EAAE,UAAU;YAChB,OAAO,EAAE,OAAO,CAAC,EAAE;YACnB,UAAU,EAAE,EAAE,4BAA4B,EAAE,OAAO,EAAE;SACtD,CAAA;IACH,CAAC;IAED;;;OAGG;IACI,KAAK,CAAC,MAAM,CAAC,OAAe;QACjC,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,GAAG,OAAkB,CAAA;QAE5C,MAAM,MAAM,GAAG,KAAK,KAAK,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAA;QAEzD,MAAM,IAAI,GAAG,KAAK,IAAmB,EAAE,CACrC,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,QAAQ,EAAE,KAAK,IAAI,EAAE,CACrC,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,UAAU,EAAE,KAAK,IAAI,EAAE;YACvC,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,OAAO,CAAC,KAAM,CAAC,MAAyB,CAAC,CAAA;YAEnE,OAAO,CAAC,KAAK,CAAC,mBAAmB,EAAE;gBACjC,SAAS,EAAE,IAAI,CAAC,OAAO,CAAC,EAAE;gBAC1B,OAAO,EAAE,MAAM,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,OAAO;aACtC,CAAC,CAAA;QACJ,CAAC,CAAC,CACH,CAAA;QAEH,IAAI,MAAM,KAAK,IAAI;YAAE,MAAM,IAAI,EAAE,CAAA;;YAC5B,MAAM,GAAG,CAAC,MAAM,EAAE,IAAI,CAAC,CAAA;IAC9B,CAAC;IAEkB,KAAK,CAAC,IAAI;QAC3B,IAAI,IAAI,CAAC,OAAO,CAAC,MAAM,KAAK,IAAI;YAC9B,MAAM,IAAI,KAAK,CACb,cAAc,IAAI,CAAC,OAAO,CAAC,EAAE,8CAA8C;gBACzE,yDAAyD,CAC5D,CAAA;QAEH;;;;WAIG;QACH,IAAI,IAAI,CAAC,OAAO,CAAC,MAAM,KAAK,SAAS;YACnC,MAAM,IAAI,KAAK,CACb,cAAc,IAAI,CAAC,OAAO,CAAC,EAAE,8CAA8C;gBACzE,iFAAiF;gBACjF,sEAAsE,CACzE,CAAA;QAEH,MAAM,OAAO,GAAG,MAAM,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,CAAA;QAE1C,MAAM,OAAO,CAAC,OAAO,EAAE,CAAA;QAEvB,IAAI,CAAC,OAAO,CAAC,OAAO,CAAC,CAAA;IACvB,CAAC;IAED,mBAAmB;IAEnB,IAAW,GAAG;QACZ,OAAO,IAAI,CAAC,OAAO,CAAC,GAAG,CAAA;IACzB,CAAC;IAED,IAAW,MAAM;QACf,OAAO,IAAI,CAAC,OAAO,CAAC,MAAM,CAAA;IAC5B,CAAC;IAED,IAAW,QAAQ;QACjB,OAAO,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAA;IAC9B,CAAC;IAED,IAAW,MAAM;QACf,OAAO,IAAI,CAAC,OAAO,CAAC,MAAM,CAAA;IAC5B,CAAC;IAEM,KAAK,CAAC,GAAG,CAAC,KAAqB;QACpC,OAAO,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,KAAK,CAAC,CAAA;IAChC,CAAC;IAEM,KAAK,CAAC,IAAI,CAAC,KAAqB;QACrC,OAAO,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,CAAA;IACjC,CAAC;IAEM,KAAK,CAAC,MAAM,CAAC,KAAsB;QACxC,OAAO,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,CAAA;IACnC,CAAC;IAEM,KAAK,CAAC,KAAK,CAAC,MAAuB,EAAE,GAAgB;QAC1D,OAAO,IAAI,CAAC,OAAO,CAAC,KAAK,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;IACxC,CAAC;IAEM,KAAK,CAAC,SAAS,CAAC,OAA0B,EAAE,IAAmB;QACpE,OAAO,IAAI,CAAC,OAAO,CAAC,SAAS,CAAC,OAAO,EAAE,IAAI,CAAC,CAAA;IAC9C,CAAC;IAEM,KAAK,CAAC,MAAM,CACjB,KAAqB,EACrB,SAAiB,EACjB,GAAgB;QAEhB,OAAO,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,KAAK,EAAE,SAAS,EAAE,GAAG,CAAC,CAAA;IACnD,CAAC;IAED,sCAAsC;IAC/B,KAAK,CAAC,MAAM,CACjB,KAAiC,EACjC,UAAkB,EAClB,MAAuB,EACvB,GAAgB;QAEhB,OAAO,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,KAAK,EAAE,UAAU,EAAE,MAAM,EAAE,GAAG,CAAC,CAAA;IAC5D,CAAC;IAEM,KAAK,CAAC,KAAK,CAAC,MAAuB;QACxC,OAAO,IAAI,CAAC,OAAO,CAAC,KAAM,CAAC,MAAM,CAAC,CAAA;IACpC,CAAC;CAGF"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { Factory } from './Factory.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../source/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAA"}
|