letopis 1.4.2 → 3.0.0
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/AGENT-CHEATSHEET.md +516 -398
- package/CHANGELOG.md +739 -0
- package/LICENSE +75 -21
- package/LICENSE-COMMERCIAL.md +49 -0
- package/README.md +1312 -1190
- package/api-contract.json +381 -0
- package/dist/acl.d.ts +81 -1
- package/dist/acl.js +281 -13
- package/dist/admin.d.ts +11 -141
- package/dist/admin.js +28 -186
- package/dist/auth.d.ts +20 -5
- package/dist/auth.js +80 -21
- package/dist/cache.d.ts +8 -1
- package/dist/cache.js +30 -8
- package/dist/chain.d.ts +46 -48
- package/dist/chain.js +556 -223
- package/dist/cli.js +73 -96
- package/dist/demo/booking.d.ts +352 -36
- package/dist/describe.d.ts +47 -0
- package/dist/describe.js +195 -0
- package/dist/errors.d.ts +77 -13
- package/dist/errors.js +231 -30
- package/dist/index.d.ts +62 -74
- package/dist/index.js +312 -155
- package/dist/internal.d.ts +7 -0
- package/dist/jsonschema.js +18 -17
- package/dist/load.d.ts +29 -17
- package/dist/load.js +235 -190
- package/dist/log.d.ts +121 -0
- package/dist/log.js +633 -0
- package/dist/mcp.d.ts +5 -7
- package/dist/mcp.js +194 -225
- package/dist/messages/access.d.ts +90 -0
- package/dist/messages/access.js +113 -0
- package/dist/messages/api.d.ts +237 -0
- package/dist/messages/api.js +304 -0
- package/dist/messages/boot.d.ts +201 -0
- package/dist/messages/boot.js +256 -0
- package/dist/messages/codes.d.ts +172 -0
- package/dist/messages/codes.js +173 -0
- package/dist/messages/index.d.ts +98 -0
- package/dist/messages/index.js +121 -0
- package/dist/messages/log.d.ts +229 -0
- package/dist/messages/log.js +102 -0
- package/dist/messages/mcp.d.ts +306 -0
- package/dist/messages/mcp.js +387 -0
- package/dist/messages/model.d.ts +256 -0
- package/dist/messages/model.js +314 -0
- package/dist/messages/query.d.ts +141 -0
- package/dist/messages/query.js +171 -0
- package/dist/messages/sql-access.d.ts +70 -0
- package/dist/messages/sql-access.js +87 -0
- package/dist/messages/sql-core.d.ts +164 -0
- package/dist/messages/sql-core.js +198 -0
- package/dist/messages/sql-time.d.ts +86 -0
- package/dist/messages/sql-time.js +111 -0
- package/dist/messages/sql-write.d.ts +246 -0
- package/dist/messages/sql-write.js +270 -0
- package/dist/messages/time.d.ts +23 -0
- package/dist/messages/time.js +29 -0
- package/dist/messages/types.d.ts +27 -0
- package/dist/messages/types.js +5 -0
- package/dist/messages/write.d.ts +452 -0
- package/dist/messages/write.js +572 -0
- package/dist/model.d.ts +77 -60
- package/dist/model.js +43 -34
- package/dist/ops.d.ts +35 -0
- package/dist/ops.js +101 -0
- package/dist/pglite.d.ts +0 -9
- package/dist/pglite.js +3 -1
- package/dist/registry.d.ts +3 -19
- package/dist/registry.js +3 -16
- package/dist/signals.d.ts +1 -5
- package/dist/signals.js +1 -5
- package/dist/sql.d.ts +9 -1
- package/dist/sql.js +175 -107
- package/dist/sync.d.ts +10 -4
- package/dist/sync.js +90 -17
- package/dist/tx.d.ts +21 -2
- package/dist/tx.js +76 -13
- package/dist/typed.d.ts +252 -53
- package/dist/types.d.ts +93 -90
- package/dist/types.js +17 -14
- package/dist/up.d.ts +56 -35
- package/dist/up.js +229 -83
- package/dist/uuid.d.ts +8 -0
- package/dist/uuid.js +14 -6
- package/dist/validate.d.ts +13 -1
- package/dist/validate.js +82 -33
- package/dist/watch.d.ts +10 -16
- package/dist/watch.js +36 -77
- package/dist/write.d.ts +87 -17
- package/dist/write.js +441 -206
- package/llms.txt +48 -31
- package/package.json +13 -14
- package/sql/10-core.sql +7 -50
- package/sql/15-errors.sql +64 -10
- package/sql/20-context.sql +18 -9
- package/sql/30-validate.sql +79 -30
- package/sql/40-class.sql +41 -115
- package/sql/50-acl.sql +8 -13
- package/sql/60-write.sql +220 -676
- package/sql/70-read.sql +11 -13
- package/sql/80-auth.sql +109 -42
- package/sql/90-time.sql +108 -947
- package/sql/95-seed.system.sql +7 -14
- package/sql/99-revision.sql +6 -1
- package/sql/README.md +32 -24
- package/sql/migrate/0009-restore-rev.tenants-name.sql +10 -0
- package/sql/migrate/0010-impersonation-no-account.sql +5 -0
- package/sql/migrate/0011-key-lock.otp-enabled.sql +7 -0
- package/sql/migrate/README.md +18 -0
- package/AGENT-CHEATSHEET.en.md +0 -488
- package/MIGRATION.md +0 -190
- package/README.en.md +0 -2910
- package/dist/import.d.ts +0 -185
- package/dist/import.js +0 -820
- package/dist/project.d.ts +0 -359
- package/dist/project.js +0 -1258
- package/sql/75-history.sql +0 -436
- package/sql/88-derived.sql +0 -1236
- package/sql/migrate/0006-changed-paths.sql +0 -3
- package/sql/migrate/0007-secret-account-fk.sql +0 -12
package/dist/project.js
DELETED
|
@@ -1,1258 +0,0 @@
|
|
|
1
|
-
import { LetopisError, fromDbError } from './errors.js';
|
|
2
|
-
import { release, rollbackAfter } from './tx.js';
|
|
3
|
-
import { toRow } from './sql.js';
|
|
4
|
-
import { wakes } from './watch.js';
|
|
5
|
-
import { schemaRevision, SYSTEM_ID } from './up.js';
|
|
6
|
-
import { DDL_REVISION } from './types.js';
|
|
7
|
-
/** Имя проектора — оно же derivedBy производных классов (метасхема `40-class.sql`). */
|
|
8
|
-
const NAME = /^[a-z0-9][a-z0-9_.:-]{0,63}$/;
|
|
9
|
-
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
10
|
-
/** Служебные записи журнала: не версии объекта. */
|
|
11
|
-
const SPECIAL = new Set(['purge', 'trim', 'reset']);
|
|
12
|
-
const ident = (n) => `"${n.replace(/"/g, '""')}"`;
|
|
13
|
-
const rowJson = (a) => `jsonb_build_object('id', ${a}.id, 'rev', ${a}.rev, 'class', ${a}.class, 'tenant', ${a}.tenant, `
|
|
14
|
-
+ `'owner', ${a}.owner, 'links', ${a}.links, 'data', ${a}.data, 'tags', ${a}.tags, 'at', ${a}.at, 'author', ${a}.author, `
|
|
15
|
-
+ `'agent', ${a}.agent, 'op', ${a}.op, 'reason', ${a}.reason, 'moved', ${a}.moved)`;
|
|
16
|
-
const enc = (p) => `${p.tx}:${p.seq}${p.snap ? `~${p.snap}` : ''}`;
|
|
17
|
-
function dec(c) {
|
|
18
|
-
const [head, snap] = c.split('~');
|
|
19
|
-
const [tx, seq] = head.split(':');
|
|
20
|
-
return { tx, seq, ...(snap ? { snap } : {}) };
|
|
21
|
-
}
|
|
22
|
-
/** Снимок нужен, пока курсор не дошёл до его xmax. */
|
|
23
|
-
const keepSnap = (snap, tx) => snap && BigInt(tx) < BigInt(snap.split(':')[1]) ? snap : undefined;
|
|
24
|
-
/** Транзакция tx видна в снимке «xmin:xmax:xip,…» (как pg_visible_in_snapshot). */
|
|
25
|
-
function visible(tx, snap) {
|
|
26
|
-
const [xmin, xmax, xip] = snap.split(':');
|
|
27
|
-
const t = BigInt(tx);
|
|
28
|
-
if (t < BigInt(xmin))
|
|
29
|
-
return true;
|
|
30
|
-
if (t >= BigInt(xmax))
|
|
31
|
-
return false;
|
|
32
|
-
return !(xip ? xip.split(',').some((x) => BigInt(x) === t) : false);
|
|
33
|
-
}
|
|
34
|
-
/**
|
|
35
|
-
* Пустой последний проход перестройки пачками — тоже пачка: id выше последней прочитанной строки до конца диапазона.
|
|
36
|
-
* Его снимок учёл, что таких строк нет, поэтому хвост не применяет изменения, видимые в нём (ревью безопасности №3:
|
|
37
|
-
* без неё надгробие строки с наибольшим id вычиталось лишний раз).
|
|
38
|
-
*/
|
|
39
|
-
const LAST_ID = 'ffffffff-ffff-ffff-ffff-ffffffffffff';
|
|
40
|
-
/** Изменение уже учтено перестройкой пачками: id попал в пачку, в снимке которой транзакция видна. */
|
|
41
|
-
function seen(chunks, id, tx) {
|
|
42
|
-
const c = chunks.find((x) => x.upto >= id);
|
|
43
|
-
return c !== undefined && visible(tx, c.snap);
|
|
44
|
-
}
|
|
45
|
-
/** Отметки выбывших в meta: пустой список — без ключа. */
|
|
46
|
-
function withFailed(meta, failed) {
|
|
47
|
-
const next = { ...meta };
|
|
48
|
-
if (Object.keys(failed).length)
|
|
49
|
-
next.failed = failed;
|
|
50
|
-
else
|
|
51
|
-
delete next.failed;
|
|
52
|
-
return next;
|
|
53
|
-
}
|
|
54
|
-
const emptyReport = () => ({ batches: 0, changes: 0, created: 0, updated: 0, deleted: 0, unchanged: 0, skipped: 0, rebuilds: 0, locked: [] });
|
|
55
|
-
function change(row, cursor, rebuild = false) {
|
|
56
|
-
return {
|
|
57
|
-
cursor, id: row.id, class: row.class, tenant: row.tenant, op: rebuild ? 'create' : row.op, rev: row.rev, at: row.at,
|
|
58
|
-
author: row.author,
|
|
59
|
-
...(row.agent !== undefined ? { agent: row.agent } : {}),
|
|
60
|
-
...(row.reason !== undefined ? { reason: row.reason } : {}),
|
|
61
|
-
...(row.moved !== undefined ? { moved: row.moved } : {}),
|
|
62
|
-
row,
|
|
63
|
-
...(rebuild ? { rebuild: true } : {}),
|
|
64
|
-
};
|
|
65
|
-
}
|
|
66
|
-
/**
|
|
67
|
-
* Причина ошибки — данные арендатора: проверки строк и приращений out и ошибки базы с кодами letopis (LT…), неверными
|
|
68
|
-
* значениями (22…) и пределами размера (54…). Сбой окружения (блокировки, отмена запроса, связь) — не причина
|
|
69
|
-
* выводить арендатора: пачка откатывается целиком, повтор начнётся с того же курсора.
|
|
70
|
-
*/
|
|
71
|
-
/** «1 прохода», «2 проходов», «21 прохода» — число проходов в родительном падеже («в течение …»). */
|
|
72
|
-
function passesGen(n) {
|
|
73
|
-
return `${n} ${n % 10 === 1 && n % 100 !== 11 ? 'прохода' : 'проходов'}`;
|
|
74
|
-
}
|
|
75
|
-
/** Ожидание чужой блокировки строки дольше lock_timeout (55P03): сбой окружения, проход повторяется. */
|
|
76
|
-
function lockWait(e) {
|
|
77
|
-
return e?.code === '55P03';
|
|
78
|
-
}
|
|
79
|
-
function tenantFault(e) {
|
|
80
|
-
if (e instanceof LetopisError)
|
|
81
|
-
return true;
|
|
82
|
-
return /^(LT|22|54)/.test(String(e?.code ?? ''));
|
|
83
|
-
}
|
|
84
|
-
/** Запись результатов группы одного арендатора: операции копятся и уходят одним вызовом derived_apply. */
|
|
85
|
-
class Out {
|
|
86
|
-
p;
|
|
87
|
-
q;
|
|
88
|
-
tenant;
|
|
89
|
-
ops = [];
|
|
90
|
-
/**
|
|
91
|
-
* Приращения неотправленных inc по ключу свёртки (Projector.foldKey; решение точки Б): каждый вызов out — отдельная
|
|
92
|
-
* правка строки в базе, и свёртка сводит правки строки к одной на пачку. upsert, delete и get индекс сбрасывают:
|
|
93
|
-
* правки одной строки уходят в базу в своём порядке.
|
|
94
|
-
*/
|
|
95
|
-
folds = new Map();
|
|
96
|
-
/** Итоги записи группы: в отчёт идут, только если группа записана. */
|
|
97
|
-
stats = { created: 0, updated: 0, deleted: 0, unchanged: 0 };
|
|
98
|
-
/** Ошибки базы при записи и чтении результатов группы и отказы out по строке и приращениям. */
|
|
99
|
-
errors = [];
|
|
100
|
-
/** Первая ошибка базы: транзакция группы прервана, даже если handle поймал ошибку и не бросил её. */
|
|
101
|
-
aborted = undefined;
|
|
102
|
-
constructor(p, q, tenant) {
|
|
103
|
-
this.p = p;
|
|
104
|
-
this.q = q;
|
|
105
|
-
this.tenant = tenant;
|
|
106
|
-
}
|
|
107
|
-
/** Ошибку дали база или проверки out, а не код handle. */
|
|
108
|
-
owns(e) {
|
|
109
|
-
return this.errors.includes(e);
|
|
110
|
-
}
|
|
111
|
-
/** Отказ out по строке или приращениям: его причина — данные, по которым handle их построил. */
|
|
112
|
-
fault(e) {
|
|
113
|
-
this.errors.push(e);
|
|
114
|
-
return e;
|
|
115
|
-
}
|
|
116
|
-
check(cls, tenant) {
|
|
117
|
-
if (tenant === this.tenant && !this.p.intoOf(tenant, cls)) {
|
|
118
|
-
throw new LetopisError('derived_class', `проектор ${this.p.name}: класс ${cls} не входит в into`);
|
|
119
|
-
}
|
|
120
|
-
if (typeof tenant !== 'string' || tenant !== this.tenant) {
|
|
121
|
-
throw new LetopisError('tenant_denied', `проектор ${this.p.name}: арендатор ${String(tenant)} — не арендатор изменений`);
|
|
122
|
-
}
|
|
123
|
-
return tenant;
|
|
124
|
-
}
|
|
125
|
-
rowOf(keyOrId) {
|
|
126
|
-
if (typeof keyOrId === 'string')
|
|
127
|
-
return { $id: keyOrId };
|
|
128
|
-
if (!keyOrId || typeof keyOrId !== 'object') {
|
|
129
|
-
throw this.fault(new LetopisError('invalid_data', `проектор ${this.p.name}: нужен ключ строки или id (арендатор ${this.tenant})`));
|
|
130
|
-
}
|
|
131
|
-
return keyOrId;
|
|
132
|
-
}
|
|
133
|
-
/** Запрос к базе: ошибка запоминается — по ней откатывается группа и выбывает арендатор. */
|
|
134
|
-
async db(fn) {
|
|
135
|
-
try {
|
|
136
|
-
return await fn();
|
|
137
|
-
}
|
|
138
|
-
catch (e) {
|
|
139
|
-
this.errors.push(e);
|
|
140
|
-
this.aborted ??= e;
|
|
141
|
-
throw e;
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
upsert(cls, row, opts) {
|
|
145
|
-
const tenant = this.check(cls, opts?.tenant);
|
|
146
|
-
this.folds.clear();
|
|
147
|
-
this.ops.push({ op: 'upsert', class: cls, tenant, row: this.rowOf(row) });
|
|
148
|
-
}
|
|
149
|
-
inc(cls, row, deltas, opts) {
|
|
150
|
-
const tenant = this.check(cls, opts?.tenant);
|
|
151
|
-
for (const [k, v] of Object.entries(deltas ?? {})) {
|
|
152
|
-
if (typeof v !== 'number' || !Number.isFinite(v)) {
|
|
153
|
-
throw this.fault(new LetopisError('invalid_data', `проектор ${this.p.name}: приращение поля ${k} — не число (арендатор ${tenant})`));
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
const r = this.rowOf(row);
|
|
157
|
-
const key = this.p.foldKey(tenant, cls, r);
|
|
158
|
-
const acc = key === null ? undefined : this.folds.get(key);
|
|
159
|
-
if (acc) {
|
|
160
|
-
for (const [k, v] of Object.entries(deltas ?? {}))
|
|
161
|
-
acc[k] = (acc[k] ?? 0) + v;
|
|
162
|
-
return;
|
|
163
|
-
}
|
|
164
|
-
const d = { ...(deltas ?? {}) };
|
|
165
|
-
this.ops.push({ op: 'inc', class: cls, tenant, row: r, deltas: d });
|
|
166
|
-
if (key !== null)
|
|
167
|
-
this.folds.set(key, d);
|
|
168
|
-
}
|
|
169
|
-
/**
|
|
170
|
-
* Строка для delete и get. У класса с ключом её называют полями ключа: id такой строки — v5 от ключа, и по одному id
|
|
171
|
-
* (строкой или { $id }) база искала бы строку с пустым ключом — get отвечал бы null, а delete выводил арендатора с
|
|
172
|
-
* id_mismatch. Это ошибка кода handle, а не данных: invalid_data сразу, пачка откатывается целиком.
|
|
173
|
-
*/
|
|
174
|
-
target(cls, tenant, keyOrId) {
|
|
175
|
-
const row = this.rowOf(keyOrId);
|
|
176
|
-
const key = this.p.keyOf(tenant, cls);
|
|
177
|
-
if (key.length && Object.keys(row).every((n) => n === '$id')) {
|
|
178
|
-
throw new LetopisError('invalid_data', `проектор ${this.p.name}: у класса ${cls} есть ключ (${key.join(', ')}) — строку называют полями ключа, например { ${key.map((k) => `${k}: …`).join(', ')} }, а не id`);
|
|
179
|
-
}
|
|
180
|
-
return row;
|
|
181
|
-
}
|
|
182
|
-
delete(cls, keyOrId, opts) {
|
|
183
|
-
const tenant = this.check(cls, opts?.tenant);
|
|
184
|
-
const row = this.target(cls, tenant, keyOrId);
|
|
185
|
-
this.folds.clear();
|
|
186
|
-
this.ops.push({ op: 'delete', class: cls, tenant, row });
|
|
187
|
-
}
|
|
188
|
-
async get(cls, keyOrId, opts) {
|
|
189
|
-
const tenant = this.check(cls, opts?.tenant);
|
|
190
|
-
const row = this.target(cls, tenant, keyOrId);
|
|
191
|
-
await this.flush();
|
|
192
|
-
const S = this.p.env.S;
|
|
193
|
-
const [r] = await this.db(() => this.q.unsafe(`select ${rowJson('e')} as row from ${S}.entity e
|
|
194
|
-
where e.id = ${S}.derived_id($1, $2::uuid, $3::text::jsonb) and e.class = $1 and e.tenant = $2::uuid`, [cls, tenant, JSON.stringify(row)]));
|
|
195
|
-
return r ? toRow(r.row) : null;
|
|
196
|
-
}
|
|
197
|
-
async flush() {
|
|
198
|
-
if (!this.ops.length)
|
|
199
|
-
return;
|
|
200
|
-
const ops = this.ops;
|
|
201
|
-
this.ops = [];
|
|
202
|
-
this.folds.clear();
|
|
203
|
-
const [r] = await this.db(() => this.q.unsafe(`select ${this.p.env.S}.derived_apply($1, $2::text::jsonb) as r`, [this.p.name, JSON.stringify(ops)]));
|
|
204
|
-
const s = r.r;
|
|
205
|
-
this.stats.created += s.created;
|
|
206
|
-
this.stats.updated += s.updated;
|
|
207
|
-
this.stats.deleted += s.deleted;
|
|
208
|
-
this.stats.unchanged += s.unchanged;
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
export class Projector {
|
|
212
|
-
env;
|
|
213
|
-
def;
|
|
214
|
-
name;
|
|
215
|
-
/** Области арендаторов проектора: свои семейства from и into. */
|
|
216
|
-
scopes = new Map();
|
|
217
|
-
from = [];
|
|
218
|
-
/** Арендаторы проектора: каждый класс into у них помечен его derivedBy (и, с опцией tenants, они в списке). */
|
|
219
|
-
tenants = [];
|
|
220
|
-
/** Самый короткий ярус all политик хранения источников арендатора, мс (нет в карте — предела нет). */
|
|
221
|
-
lim = new Map();
|
|
222
|
-
/** Источники арендатора без журнала (history: false): их изменений проектор не увидит — арендатор выбывает. */
|
|
223
|
-
blind = new Map();
|
|
224
|
-
stale = true;
|
|
225
|
-
/** Когда состав читался в последний раз: без LISTEN сигнала о новых классах нет (план 2.0, 7.4 №6). */
|
|
226
|
-
resolvedAt = 0;
|
|
227
|
-
/** Участники, которых чекпоинт ещё не знает: им начальная перестройка пачками (tenantWork). */
|
|
228
|
-
newcomers = [];
|
|
229
|
-
/** В чекпоинте есть начатая перестройка арендатора пачками (по последней пачке; до первой — проверить). */
|
|
230
|
-
tenantsPending = true;
|
|
231
|
-
running = false;
|
|
232
|
-
stopped = false;
|
|
233
|
-
wake = null;
|
|
234
|
-
off = null;
|
|
235
|
-
batch;
|
|
236
|
-
pollMs;
|
|
237
|
-
lockMs;
|
|
238
|
-
lockRetries;
|
|
239
|
-
/** Проходов подряд, в которых запись арендатора упиралась в lockTimeout (lockRetries). */
|
|
240
|
-
lockHits = new Map();
|
|
241
|
-
only;
|
|
242
|
-
/**
|
|
243
|
-
* Перечитать состав: по сигналу классов, а без LISTEN (сигналов нет) — не реже раза в 5 с, как реестр подключения в
|
|
244
|
-
* режиме none, и в начале run() (план 2.0, 7.4 №6): иначе арендатор, вступивший после запуска, не участвовал бы.
|
|
245
|
-
*/
|
|
246
|
-
due() {
|
|
247
|
-
return this.stale || (!this.env.onSignal && Date.now() - this.resolvedAt >= 5000);
|
|
248
|
-
}
|
|
249
|
-
constructor(env, name, def) {
|
|
250
|
-
this.env = env;
|
|
251
|
-
this.def = def;
|
|
252
|
-
if (typeof name !== 'string' || !NAME.test(name)) {
|
|
253
|
-
throw new LetopisError('invalid_data', `проектор: имя ${String(name)} — строчные латинские буквы, цифры и _.:- (до 64), оно же derivedBy классов`);
|
|
254
|
-
}
|
|
255
|
-
const names = (x) => Array.isArray(x) && x.length > 0 && x.every((n) => typeof n === 'string' && n.length > 0);
|
|
256
|
-
if (!def || !names(def.from) || !names(def.into))
|
|
257
|
-
throw new LetopisError('invalid_data', `проектор ${name}: from и into — непустые списки классов`);
|
|
258
|
-
if (!Number.isInteger(def.version) || def.version < 1)
|
|
259
|
-
throw new LetopisError('invalid_data', `проектор ${name}: version — целое число от 1`);
|
|
260
|
-
if (typeof def.handle !== 'function')
|
|
261
|
-
throw new LetopisError('invalid_data', `проектор ${name}: нужна функция handle(changes, out)`);
|
|
262
|
-
if (def.onExpired !== undefined && def.onExpired !== 'rebuild' && def.onExpired !== 'error') {
|
|
263
|
-
throw new LetopisError('invalid_data', `проектор ${name}: onExpired — 'rebuild' или 'error'`);
|
|
264
|
-
}
|
|
265
|
-
if (def.tenants !== undefined && !(Array.isArray(def.tenants) && def.tenants.length > 0 && def.tenants.every((x) => typeof x === 'string' && UUID.test(x)))) {
|
|
266
|
-
throw new LetopisError('invalid_data', `проектор ${name}: tenants — непустой список id арендаторов`);
|
|
267
|
-
}
|
|
268
|
-
this.name = name;
|
|
269
|
-
this.only = def.tenants ? new Set(def.tenants.map((x) => x.toLowerCase())) : null;
|
|
270
|
-
this.batch = def.batch ?? 500;
|
|
271
|
-
this.pollMs = def.pollMs ?? 1000;
|
|
272
|
-
this.lockMs = def.lockTimeout ?? 5000;
|
|
273
|
-
if (!Number.isInteger(this.lockMs) || this.lockMs < 0)
|
|
274
|
-
throw new LetopisError('invalid_data', `проектор ${name}: lockTimeout — целое число мс, 0 и больше`);
|
|
275
|
-
this.lockRetries = def.lockRetries ?? 0;
|
|
276
|
-
if (!Number.isInteger(this.lockRetries) || this.lockRetries < 0)
|
|
277
|
-
throw new LetopisError('invalid_data', `проектор ${name}: lockRetries — целое число, 0 и больше`);
|
|
278
|
-
if (!Number.isInteger(this.batch) || this.batch < 1 || this.batch > 10_000)
|
|
279
|
-
throw new LetopisError('invalid_data', `проектор ${name}: batch — от 1 до 10 000`);
|
|
280
|
-
if (env.onSignal) {
|
|
281
|
-
this.off = env.onSignal((s) => {
|
|
282
|
-
if (s.kind === 'class' && s.cls === 'Class')
|
|
283
|
-
this.stale = true;
|
|
284
|
-
if (s.kind === 'all')
|
|
285
|
-
this.stale = true;
|
|
286
|
-
if (wakes(s, this.from.length ? new Set([...this.from, '#reset']) : null, null))
|
|
287
|
-
this.wake?.();
|
|
288
|
-
});
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
/**
|
|
292
|
-
* Догнать журнал и следовать за ним до stop(); { once: true } — только догнать. Первый запуск (проекции
|
|
293
|
-
* ещё нет) начинается с перестройки пачками, прерванная перестройка пачками продолжается; так же, пачками,
|
|
294
|
-
* перестраивает onExpired: 'rebuild' (решение точки Б: горизонт подписок кластера стоит не дольше пачки).
|
|
295
|
-
*/
|
|
296
|
-
async run(opts = {}) {
|
|
297
|
-
if (this.running)
|
|
298
|
-
throw new LetopisError('invalid_query', `проектор ${this.name} уже запущен в этом процессе`);
|
|
299
|
-
this.running = true;
|
|
300
|
-
this.stopped = false;
|
|
301
|
-
if (!this.env.onSignal)
|
|
302
|
-
this.stale = true;
|
|
303
|
-
const rep = emptyReport();
|
|
304
|
-
try {
|
|
305
|
-
for (;;) {
|
|
306
|
-
if (this.stopped)
|
|
307
|
-
return rep;
|
|
308
|
-
let r;
|
|
309
|
-
try {
|
|
310
|
-
r = await this.step(rep);
|
|
311
|
-
}
|
|
312
|
-
catch (e) {
|
|
313
|
-
if (lockWait(e)) {
|
|
314
|
-
rep.skipped++;
|
|
315
|
-
r = 'busy';
|
|
316
|
-
}
|
|
317
|
-
else if (e instanceof LetopisError && e.code === 'cursor_expired' && this.def.onExpired === 'rebuild') {
|
|
318
|
-
r = 'rebuild';
|
|
319
|
-
}
|
|
320
|
-
else
|
|
321
|
-
throw e;
|
|
322
|
-
}
|
|
323
|
-
// курсор истёк у части арендаторов: с onExpired: 'rebuild' они перестраиваются сами, пачками (доработки после
|
|
324
|
-
// точки Б). Состояние — в чекпоинте, поэтому перестройку продолжает run() любого процесса, а занятая
|
|
325
|
-
// блокировка — только повтор на следующем проходе; ошибка данных выводит арендатора с её кодом, без повторов.
|
|
326
|
-
// Начатую перестройку арендатора пачками — и прерванную rebuild({ tenant, chunked: true }) — продвигает run()
|
|
327
|
-
// и без onExpired (ревью безопасности №3): иначе хвост бессрочно пропускал бы изменения арендатора с id выше
|
|
328
|
-
// последней пачки. Начинает перестройку выбывших по ярусу только onExpired: 'rebuild'. Блокировку проектора держит
|
|
329
|
-
// другой процесс (busy) — перестройку продвинет он: чтение чекпоинта ждало бы его пачку
|
|
330
|
-
const auto = this.def.onExpired === 'rebuild';
|
|
331
|
-
try {
|
|
332
|
-
if (r !== 'rebuild' && r !== 'busy' && (auto || this.tenantsPending) && (await this.tenantWork(rep, auto)))
|
|
333
|
-
r = 'more';
|
|
334
|
-
// пачку перестройки держит другой процесс того же проектора — подождать и продолжить
|
|
335
|
-
if (r === 'rebuild')
|
|
336
|
-
r = (await this.rebuildNow(rep, 'auto')) === 'busy' ? 'busy' : 'more';
|
|
337
|
-
}
|
|
338
|
-
catch (e) {
|
|
339
|
-
if (!lockWait(e))
|
|
340
|
-
throw e;
|
|
341
|
-
rep.skipped++;
|
|
342
|
-
r = 'busy';
|
|
343
|
-
}
|
|
344
|
-
if (r === 'more')
|
|
345
|
-
continue;
|
|
346
|
-
if (opts.once)
|
|
347
|
-
return rep;
|
|
348
|
-
await this.sleep();
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
|
-
finally {
|
|
352
|
-
this.running = false;
|
|
353
|
-
}
|
|
354
|
-
}
|
|
355
|
-
/**
|
|
356
|
-
* Перестроить проекцию с текущего состояния: атомарно (одна транзакция: до фиксации читатели видят
|
|
357
|
-
* прежние строки, но горизонт подписок стоит) или { chunked: true } — пачками: производные строки
|
|
358
|
-
* удаляются сразу, до конца перестройки данные неполные (status() — rebuilding); прерванную перестройку
|
|
359
|
-
* продолжает следующий запуск. Полная перестройка начинает всех участников заново: отметки выбывших снимаются,
|
|
360
|
-
* а чьи данные снова не проходят — выбывают снова. { tenant: id } — только этот арендатор, одной транзакцией:
|
|
361
|
-
* его производные строки удаляются и строятся из его текущих строк источников, отметка выбывшего снимается.
|
|
362
|
-
* { tenant, chunked: true } — этот арендатор пачками, пока хвост журнала идёт для всех (доработки после точки Б):
|
|
363
|
-
* его производные строки удаляются сразу и до конца перестройки неполны; блокировку, занятую run() другого процесса,
|
|
364
|
-
* перестройка пережидает.
|
|
365
|
-
*/
|
|
366
|
-
async rebuild(opts = {}) {
|
|
367
|
-
const rep = emptyReport();
|
|
368
|
-
if (opts.tenant !== undefined) {
|
|
369
|
-
if (typeof opts.tenant !== 'string' || !UUID.test(opts.tenant))
|
|
370
|
-
throw new LetopisError('invalid_data', `проектор ${this.name}: rebuild({ tenant }) — id арендатора`);
|
|
371
|
-
const tenant = opts.tenant.toLowerCase();
|
|
372
|
-
if (!opts.chunked) {
|
|
373
|
-
await this.rebuildTenant(rep, tenant);
|
|
374
|
-
return rep;
|
|
375
|
-
}
|
|
376
|
-
await this.startTenant(rep, tenant, false);
|
|
377
|
-
const started = rep.rebuilds;
|
|
378
|
-
for (;;) {
|
|
379
|
-
// занятая строка (lockTimeout) — как занятая блокировка проектора: подождать и повторить пачку
|
|
380
|
-
const r = await this.tenantChunk(rep, tenant).catch((e) => {
|
|
381
|
-
if (!lockWait(e))
|
|
382
|
-
throw e;
|
|
383
|
-
rep.skipped++;
|
|
384
|
-
return 'busy';
|
|
385
|
-
});
|
|
386
|
-
if (r === 'busy')
|
|
387
|
-
await new Promise((res) => setTimeout(res, 20));
|
|
388
|
-
if (r !== 'done')
|
|
389
|
-
continue;
|
|
390
|
-
// ошибка данных арендатора — как у перестройки одной транзакцией: отказ, арендатор выбывший; чекпоинт держит
|
|
391
|
-
// пачка run() другого процесса дольше lockTimeout — подождать
|
|
392
|
-
const f = (await this.untilFree(rep, () => this.tx(async (q) => this.state(q)))).failed?.[tenant];
|
|
393
|
-
if (f)
|
|
394
|
-
throw new LetopisError(f.code, f.message);
|
|
395
|
-
// последнюю пачку мог записать run() другого процесса (он продвигает начатые перестройки): перестройка этого
|
|
396
|
-
// вызова всё равно завершена
|
|
397
|
-
if (rep.rebuilds === started)
|
|
398
|
-
rep.rebuilds++;
|
|
399
|
-
return rep;
|
|
400
|
-
}
|
|
401
|
-
}
|
|
402
|
-
await this.rebuildNow(rep, Boolean(opts.chunked));
|
|
403
|
-
return rep;
|
|
404
|
-
}
|
|
405
|
-
async status() {
|
|
406
|
-
return this.tx(async (q) => {
|
|
407
|
-
if (this.due())
|
|
408
|
-
await this.resolve(q);
|
|
409
|
-
const [r] = await q.unsafe(`select s.meta from ${this.env.S}.secret s
|
|
410
|
-
where s.kind = 'PROJECTOR' and s.ident = $1 and s.account = $2::uuid and s.deleted_at is null`, [this.name, SYSTEM_ID]);
|
|
411
|
-
const meta = (r?.meta ?? {});
|
|
412
|
-
const failed = meta.failed ?? {};
|
|
413
|
-
let lag = 0;
|
|
414
|
-
if (meta.cursor) {
|
|
415
|
-
const p = dec(meta.cursor);
|
|
416
|
-
const [l] = await q.unsafe(`select coalesce(extract(epoch from clock_timestamp() - min(l.at)) * 1000, 0)::float8 as ms
|
|
417
|
-
from ${this.env.S}.log l where (l.tx, l.seq) > ($1::text::xid8, $2::bigint) and l.class = any ($3::text[]) and l.tenant = any ($5::uuid[])
|
|
418
|
-
and ($4::text is null or not pg_visible_in_snapshot(l.tx, $4::text::pg_snapshot))`, [p.tx, p.seq, [...this.from, '#reset'], p.snap ?? null, this.tenants.filter((x) => !failed[x])]);
|
|
419
|
-
lag = Number(l?.ms ?? 0);
|
|
420
|
-
}
|
|
421
|
-
return {
|
|
422
|
-
name: this.name,
|
|
423
|
-
state: !meta.cursor ? 'new' : meta.state === 'rebuilding' ? 'rebuilding' : 'running',
|
|
424
|
-
version: meta.version ?? null,
|
|
425
|
-
cursor: meta.cursor ?? null,
|
|
426
|
-
at: meta.at ?? null,
|
|
427
|
-
lag,
|
|
428
|
-
failed,
|
|
429
|
-
rebuildingTenants: Object.entries(meta.retenant ?? {}).filter(([, v]) => !v.done).map(([t]) => t).sort(),
|
|
430
|
-
};
|
|
431
|
-
});
|
|
432
|
-
}
|
|
433
|
-
/** Остановить run() после текущей пачки. */
|
|
434
|
-
stop() {
|
|
435
|
-
this.stopped = true;
|
|
436
|
-
this.wake?.();
|
|
437
|
-
}
|
|
438
|
-
/** stop() и отписка от сигналов. */
|
|
439
|
-
close() {
|
|
440
|
-
this.stop();
|
|
441
|
-
this.off?.();
|
|
442
|
-
this.off = null;
|
|
443
|
-
}
|
|
444
|
-
// ---------------------------------------------------------------------------
|
|
445
|
-
/** Транзакция под ролью владельца на отдельном соединении пула. */
|
|
446
|
-
async tx(fn) {
|
|
447
|
-
const q = await this.env.sql.reserve();
|
|
448
|
-
let failure = null;
|
|
449
|
-
try {
|
|
450
|
-
await q.unsafe('begin isolation level read committed');
|
|
451
|
-
await q.unsafe(`set local role ${ident(this.env.ownerRole)}`);
|
|
452
|
-
await q.unsafe(`set local timezone = 'UTC'`);
|
|
453
|
-
// чужая блокировка строки (транзакция любого арендатора держит итог или цель) не держит пачку всех арендаторов
|
|
454
|
-
// дольше lockTimeout (хвост ревью безопасности №3): 55P03 — проход пропускается, курсор остаётся
|
|
455
|
-
if (this.lockMs > 0)
|
|
456
|
-
await q.unsafe(`set local lock_timeout = ${this.lockMs}`);
|
|
457
|
-
const out = await fn(q);
|
|
458
|
-
await q.unsafe('commit');
|
|
459
|
-
return out;
|
|
460
|
-
}
|
|
461
|
-
catch (e) {
|
|
462
|
-
// обрыв: rollback и возврат мёртвого соединения в пул роняли процесс (план 2.0, 7.4 №7)
|
|
463
|
-
failure = e;
|
|
464
|
-
await rollbackAfter(q, e);
|
|
465
|
-
throw fromDbError(e);
|
|
466
|
-
}
|
|
467
|
-
finally {
|
|
468
|
-
release(q, failure);
|
|
469
|
-
}
|
|
470
|
-
}
|
|
471
|
-
async lock(q) {
|
|
472
|
-
// строка-замок таблицы secret (lock_try, ревью безопасности №3, №2): посторонняя роль её не займёт
|
|
473
|
-
const [r] = await q.unsafe(`select ${this.env.S}.lock_try($1) as ok`, [`${this.env.schema}:projector:${this.name}`]);
|
|
474
|
-
return Boolean(r?.ok);
|
|
475
|
-
}
|
|
476
|
-
async state(q) {
|
|
477
|
-
const [r] = await q.unsafe(`select ${this.env.S}.projector_state($1) as m`, [this.name]);
|
|
478
|
-
return (r?.m ?? {});
|
|
479
|
-
}
|
|
480
|
-
async advance(q, expected, meta) {
|
|
481
|
-
// источники — для purge базы: надгробие, которое проектор ещё не прочёл, она не сотрёт (ревью безопасности №3, №5)
|
|
482
|
-
const [r] = await q.unsafe(`select ${this.env.S}.projector_advance($1, $2, $3::text::jsonb) as ok`, [this.name, expected ?? null, JSON.stringify({ ...meta, from: this.def.from })]);
|
|
483
|
-
if (!r?.ok)
|
|
484
|
-
throw new LetopisError('invalid_query', `проектор ${this.name}: чекпоинт изменился во время пачки`);
|
|
485
|
-
}
|
|
486
|
-
async now(q) {
|
|
487
|
-
// ISO, как время версий в строках (jsonb): Date.parse его понимает
|
|
488
|
-
const [r] = await q.unsafe(`select to_json(clock_timestamp()) #>> '{}' as t`);
|
|
489
|
-
return String(r.t);
|
|
490
|
-
}
|
|
491
|
-
/** Отметка выбывшего: источники арендатора не ведут журнал. */
|
|
492
|
-
blindFailure(tenant, cursor) {
|
|
493
|
-
return { cursor, code: 'invalid_class', message: this.blindError(tenant).message.replace(/^letopis: [a-z_]+: /, '') };
|
|
494
|
-
}
|
|
495
|
-
blindError(tenant) {
|
|
496
|
-
return new LetopisError('invalid_class', `проектор ${this.name}: у арендатора ${tenant} источники ${(this.blind.get(tenant) ?? []).join(', ')} не ведут журнал (history: false) — проектор не увидит их изменений`);
|
|
497
|
-
}
|
|
498
|
-
versionError(meta) {
|
|
499
|
-
return new LetopisError('rebuild_required', `проектор ${this.name}: проекция построена версией ${String(meta.version)}, а проектор — версии ${this.def.version}: нужна перестройка rebuild()`);
|
|
500
|
-
}
|
|
501
|
-
/**
|
|
502
|
-
* Арендаторы и классы по строкам Class всех арендаторов (реестр администраторского подключения без сессии
|
|
503
|
-
* видит только System). Проектор работает с арендаторами, у которых каждый класс into помечен его
|
|
504
|
-
* derivedBy (с опцией tenants — только из списка); изменения остальных арендаторов пропускаются — иначе
|
|
505
|
-
* любой арендатор, заведя одноимённый класс без пометки, остановил бы проектор для всех. Источники — семейства
|
|
506
|
-
* from у этих арендаторов, кроме собственных результатов.
|
|
507
|
-
*/
|
|
508
|
-
async resolve(q) {
|
|
509
|
-
const S = this.env.S;
|
|
510
|
-
const rev = await schemaRevision(q, this.env.schema);
|
|
511
|
-
if (rev !== DDL_REVISION) {
|
|
512
|
-
throw new LetopisError('invalid_data', `проектор ${this.name}: движок схемы ревизии ${String(rev)}, библиотека — ${DDL_REVISION}: обновите схему up({ upgrade: true })`);
|
|
513
|
-
}
|
|
514
|
-
const wanted = [...new Set(this.def.into)];
|
|
515
|
-
const tenants = (await q.unsafe(`select x.tenant::text as t from ${S}.entity x
|
|
516
|
-
where x.class = 'Class' and x.data->>'name' = any ($1::text[]) and x.data->'resolved'->>'derivedBy' = $2
|
|
517
|
-
group by x.tenant having count(distinct x.data->>'name') = $3`, [wanted, this.name, wanted.length]))
|
|
518
|
-
.map((r) => r.t).filter((x) => !this.only || this.only.has(x));
|
|
519
|
-
if (!tenants.length) {
|
|
520
|
-
throw new LetopisError('derived_class', `проектор ${this.name}: ни у одного арендатора${this.only ? ' из списка tenants' : ''} классы ${wanted.join(', ')} не помечены derivedBy: '${this.name}'`);
|
|
521
|
-
}
|
|
522
|
-
// области — по арендатору: у каждого свои семейства from и into (имена классов у арендаторов свои)
|
|
523
|
-
const scopes = new Map(tenants.map((x) => [x, { from: new Set(), into: new Set(), keys: new Map() }]));
|
|
524
|
-
const rows = await q.unsafe(`select x.tenant::text as t, x.data->>'name' as n, x.data->'resolved'->'key' as key,
|
|
525
|
-
x.data->'resolved'->'ancestors' ?| $2::text[] as is_into, x.data->'resolved'->'ancestors' ?| $1::text[] as is_from
|
|
526
|
-
from ${S}.entity x
|
|
527
|
-
where x.class = 'Class' and x.tenant = any ($3::uuid[]) and x.data->'resolved'->'ancestors' ?| ($1::text[] || $2::text[])`, [this.def.from, this.def.into, tenants]);
|
|
528
|
-
for (const r of rows) {
|
|
529
|
-
const sc = scopes.get(r.t);
|
|
530
|
-
// результат проектора источником не бывает: свой вывод он не читает
|
|
531
|
-
if (r.is_into) {
|
|
532
|
-
sc.into.add(r.n);
|
|
533
|
-
sc.keys.set(r.n, Array.isArray(r.key) ? r.key : []);
|
|
534
|
-
}
|
|
535
|
-
else if (r.is_from)
|
|
536
|
-
sc.from.add(r.n);
|
|
537
|
-
}
|
|
538
|
-
const from = [...new Set([...scopes.values()].flatMap((s) => [...s.from]))];
|
|
539
|
-
if (!from.length)
|
|
540
|
-
throw new LetopisError('invalid_class', `проектор ${this.name}: классов ${this.def.from.join(', ')} нет у арендаторов проектора`);
|
|
541
|
-
// ярус all политики хранения и журнал источников — по арендатору (ревью безопасности №2): это описание его классов,
|
|
542
|
-
// и короткий ярус или выключенный журнал одного арендатора не касается остальных
|
|
543
|
-
const lim = new Map();
|
|
544
|
-
const blind = new Map();
|
|
545
|
-
const pol = await q.unsafe(`select x.tenant::text as t, x.data->>'name' as n, x.data->'resolved'->>'history' = 'false' as off,
|
|
546
|
-
(extract(epoch from ${S}.history_iv(${S}.history_policy(x.data->'resolved')->>'all')) * 1000)::float8 as ms
|
|
547
|
-
from ${S}.entity x
|
|
548
|
-
where x.class = 'Class' and x.data->>'name' = any ($1::text[]) and x.tenant = any ($2::uuid[])`, [from, tenants]);
|
|
549
|
-
for (const r of pol) {
|
|
550
|
-
const t = r.t;
|
|
551
|
-
if (!scopes.get(t).from.has(r.n))
|
|
552
|
-
continue;
|
|
553
|
-
if (r.off)
|
|
554
|
-
blind.set(t, [...(blind.get(t) ?? []), r.n]);
|
|
555
|
-
if (r.ms != null)
|
|
556
|
-
lim.set(t, Math.min(lim.get(t) ?? Infinity, Number(r.ms)));
|
|
557
|
-
}
|
|
558
|
-
this.lim = lim;
|
|
559
|
-
this.blind = blind;
|
|
560
|
-
this.from = from;
|
|
561
|
-
this.scopes = scopes;
|
|
562
|
-
this.tenants = tenants;
|
|
563
|
-
this.stale = false;
|
|
564
|
-
this.resolvedAt = Date.now();
|
|
565
|
-
}
|
|
566
|
-
/** Одна пачка журнала. */
|
|
567
|
-
async step(rep) {
|
|
568
|
-
return this.tx(async (q) => {
|
|
569
|
-
if (!(await this.lock(q))) {
|
|
570
|
-
rep.skipped++;
|
|
571
|
-
return 'busy';
|
|
572
|
-
}
|
|
573
|
-
if (this.due())
|
|
574
|
-
await this.resolve(q);
|
|
575
|
-
const meta = await this.state(q);
|
|
576
|
-
if (!meta.cursor || meta.state === 'rebuilding')
|
|
577
|
-
return 'rebuild';
|
|
578
|
-
if (meta.version !== this.def.version)
|
|
579
|
-
throw this.versionError(meta);
|
|
580
|
-
this.tenantsPending = Object.values(meta.retenant ?? {}).some((v) => !v.done);
|
|
581
|
-
// участники, которых чекпоинт не знает (вступили после проекции): начальную перестройку пачками начнёт tenantWork,
|
|
582
|
-
// их изменения до неё учтёт она же (план 2.0, 7.4 №5)
|
|
583
|
-
const known = new Set(meta.tenants ?? this.tenants);
|
|
584
|
-
this.newcomers = this.tenants.filter((x) => !known.has(x) && !meta.failed?.[x] && !meta.retenant?.[x]);
|
|
585
|
-
if (this.newcomers.length)
|
|
586
|
-
this.tenantsPending = true;
|
|
587
|
-
const cur = dec(meta.cursor);
|
|
588
|
-
const rows = await q.unsafe(`select l.tx::text as tx, l.seq::text as seq, ${rowJson('l')} as row
|
|
589
|
-
from ${this.env.S}.watch_read($1::text::xid8, $2::bigint, null, $3::text[], $4::int, $5::text::pg_snapshot, true) l`, [cur.tx, cur.seq, [...this.from, '#reset'], this.batch, cur.snap ?? null]);
|
|
590
|
-
const failed = { ...(meta.failed ?? {}) };
|
|
591
|
-
for (const t of this.blind.keys())
|
|
592
|
-
failed[t] ??= this.blindFailure(t, meta.cursor);
|
|
593
|
-
// время курсора проверяет сам проектор, а не watch_read: без изменений после курсора прореживать было нечего, и
|
|
594
|
-
// простоявший без событий проектор не истекает. Ярус all — по арендатору (ревью безопасности №2): истёк у всех
|
|
595
|
-
// участников — cursor_expired, у части — выбывают только они, иначе короткий ярус классов одного арендатора
|
|
596
|
-
// останавливал бы проектор для всех
|
|
597
|
-
// перестроенный арендатор считает ярус от начала своей перестройки, пока курсор её не догнал: изменения до неё
|
|
598
|
-
// учтены перестройкой, и прореженные из них ему не нужны (иначе отстающий проектор выводил бы его снова)
|
|
599
|
-
if (rows.length && meta.at) {
|
|
600
|
-
const at = Date.parse(meta.at);
|
|
601
|
-
const ageOf = (x) => Date.now() - Math.max(at, meta.since?.[x] ? Date.parse(meta.since[x]) : at);
|
|
602
|
-
const live = this.tenants.filter((x) => !failed[x]);
|
|
603
|
-
const old = live.filter((x) => (this.lim.get(x) ?? Infinity) < ageOf(x));
|
|
604
|
-
if (old.length && old.length === live.length) {
|
|
605
|
-
throw new LetopisError('cursor_expired', `проектор ${this.name}: курсор старше яруса all политики хранения источников — изменения могли проредиться, нужна перестройка rebuild()`);
|
|
606
|
-
}
|
|
607
|
-
for (const x of old) {
|
|
608
|
-
failed[x] = { cursor: meta.cursor, code: 'cursor_expired',
|
|
609
|
-
message: `проектор ${this.name}: курсор старше яруса all политики хранения источников арендатора ${x} — его изменения могли проредиться, нужна перестройка rebuild({ tenant })` };
|
|
610
|
-
}
|
|
611
|
-
}
|
|
612
|
-
let pos = cur;
|
|
613
|
-
let changes = [];
|
|
614
|
-
const away = new Set();
|
|
615
|
-
for (const r of rows) {
|
|
616
|
-
const snap = keepSnap(cur.snap, r.tx);
|
|
617
|
-
pos = { tx: r.tx, seq: r.seq, ...(snap ? { snap } : {}) };
|
|
618
|
-
const row = toRow(r.row);
|
|
619
|
-
if (this.newcomers.includes(row.tenant))
|
|
620
|
-
continue;
|
|
621
|
-
const leaves = this.leaves(row, failed);
|
|
622
|
-
if (!leaves && !this.ours(row, failed))
|
|
623
|
-
continue;
|
|
624
|
-
if (meta.chunks && seen(meta.chunks, row.id, pos.tx))
|
|
625
|
-
continue;
|
|
626
|
-
// после rebuild({ tenant }): транзакция видна в снимке перестройки арендатора — изменение уже учтено
|
|
627
|
-
const rs = meta.resync?.[row.tenant];
|
|
628
|
-
if (rs && visible(pos.tx, rs))
|
|
629
|
-
continue;
|
|
630
|
-
// перестройка арендатора пачками: выше последней пачки — прочтёт пачка; в пройденной — если транзакция видна в
|
|
631
|
-
// её снимке, изменение уже учтено
|
|
632
|
-
const rt = meta.retenant?.[row.tenant];
|
|
633
|
-
if (rt && !rt.done && (rt.last === null || row.id > rt.last))
|
|
634
|
-
continue;
|
|
635
|
-
if (rt && seen(rt.chunks, row.id, pos.tx))
|
|
636
|
-
continue;
|
|
637
|
-
const c = change(row, enc(pos));
|
|
638
|
-
if (leaves)
|
|
639
|
-
away.add(c);
|
|
640
|
-
changes.push(c);
|
|
641
|
-
}
|
|
642
|
-
if (away.size)
|
|
643
|
-
changes = await this.leaving(q, changes, away);
|
|
644
|
-
if (changes.length) {
|
|
645
|
-
if (this.def.withBefore)
|
|
646
|
-
await this.fillBefore(q, changes);
|
|
647
|
-
await this.apply(q, changes, rep, failed);
|
|
648
|
-
}
|
|
649
|
-
const caught = rows.length < this.batch;
|
|
650
|
-
// состав — известные нынешние участники: выбывший из участников, вернувшись, снова новичок
|
|
651
|
-
const next = withFailed({ ...meta, cursor: enc(pos), tenants: this.tenants.filter((x) => known.has(x)) }, failed);
|
|
652
|
-
if (!rows.length) {
|
|
653
|
-
// пусто: отметить время прохода, но не чаще раза в минуту — иначе каждый опрос писал бы чекпоинт
|
|
654
|
-
if (meta.at && Date.now() - Date.parse(meta.at) < 60_000)
|
|
655
|
-
return 'empty';
|
|
656
|
-
next.at = await this.now(q);
|
|
657
|
-
}
|
|
658
|
-
else {
|
|
659
|
-
// догнавший проектор не стареет: cursor_expired считается по времени курсора
|
|
660
|
-
next.at = caught ? await this.now(q) : rows[rows.length - 1].row.at;
|
|
661
|
-
}
|
|
662
|
-
if (next.chunks && next.chunks.every((c) => BigInt(pos.tx) >= BigInt(c.snap.split(':')[1])))
|
|
663
|
-
delete next.chunks;
|
|
664
|
-
if (next.resync) {
|
|
665
|
-
// снимок перестройки арендатора не нужен, когда курсор дошёл до его xmax
|
|
666
|
-
const left = Object.entries(next.resync).filter(([, s]) => BigInt(pos.tx) < BigInt(s.split(':')[1]));
|
|
667
|
-
if (left.length)
|
|
668
|
-
next.resync = Object.fromEntries(left);
|
|
669
|
-
else
|
|
670
|
-
delete next.resync;
|
|
671
|
-
}
|
|
672
|
-
if (next.retenant) {
|
|
673
|
-
// выбывший посреди своей перестройки — без производных строк: пачки построили их лишь частично (ревью
|
|
674
|
-
// безопасности №3, №6)
|
|
675
|
-
const partial = Object.entries(next.retenant).filter(([t, v]) => failed[t] && !v.done).map(([t]) => t);
|
|
676
|
-
if (partial.length)
|
|
677
|
-
await this.clear(q, partial);
|
|
678
|
-
// перестройка арендатора пачками: выбывший — без неё; законченная — пока курсор не прошёл снимки её пачек
|
|
679
|
-
const left = Object.entries(next.retenant).filter(([t, v]) => !failed[t]
|
|
680
|
-
&& !(v.done && v.chunks.every((c) => BigInt(pos.tx) >= BigInt(c.snap.split(':')[1]))));
|
|
681
|
-
if (left.length)
|
|
682
|
-
next.retenant = Object.fromEntries(left);
|
|
683
|
-
else
|
|
684
|
-
delete next.retenant;
|
|
685
|
-
}
|
|
686
|
-
if (next.since) {
|
|
687
|
-
const left = Object.entries(next.since).filter(([, t]) => Date.parse(next.at) < Date.parse(t));
|
|
688
|
-
if (left.length)
|
|
689
|
-
next.since = Object.fromEntries(left);
|
|
690
|
-
else
|
|
691
|
-
delete next.since;
|
|
692
|
-
}
|
|
693
|
-
await this.advance(q, meta.cursor, next);
|
|
694
|
-
rep.batches++;
|
|
695
|
-
rep.changes += changes.length;
|
|
696
|
-
return caught ? 'empty' : 'more';
|
|
697
|
-
});
|
|
698
|
-
}
|
|
699
|
-
/** Прошлые версии одним запросом: последняя версия того же id с меньшим номером, кроме служебных записей. */
|
|
700
|
-
async previous(q, list) {
|
|
701
|
-
const out = new Map();
|
|
702
|
-
if (!list.length)
|
|
703
|
-
return out;
|
|
704
|
-
const rows = await q.unsafe(`select c.i, ${rowJson('l')} as row
|
|
705
|
-
from unnest($1::int[], $2::uuid[], $3::int[]) c(i, id, rev)
|
|
706
|
-
cross join lateral (
|
|
707
|
-
select x.* from ${this.env.S}.log x
|
|
708
|
-
where x.id = c.id and x.rev < c.rev and x.op not in ('purge', 'trim', 'reset')
|
|
709
|
-
order by x.rev desc limit 1) l`, [list.map((_, i) => i), list.map((c) => c.id), list.map((c) => c.rev)]);
|
|
710
|
-
for (const r of rows)
|
|
711
|
-
out.set(list[r.i], toRow(r.row));
|
|
712
|
-
return out;
|
|
713
|
-
}
|
|
714
|
-
/**
|
|
715
|
-
* Прошлые версии (id, rev − 1 и раньше) — всегда того же id. У первой версии нового id после reclass и rekey прошлой
|
|
716
|
-
* версии нет (null): прежние данные вычитает надгробие прежнего id. Прежде её before была последняя версия прежнего
|
|
717
|
-
* id, и свёртка вычитала её дважды — через неё и через надгробие. Прошлая версия вне семейства from арендатора —
|
|
718
|
-
* тоже null: строка вошла в семейство сменой класса без смены id, и в проекции её не было (ревью безопасности №3, №4).
|
|
719
|
-
*/
|
|
720
|
-
async fillBefore(q, changes) {
|
|
721
|
-
const need = changes.filter((c) => !SPECIAL.has(c.op));
|
|
722
|
-
for (const c of need)
|
|
723
|
-
c.before = null;
|
|
724
|
-
for (const [c, row] of await this.previous(q, need)) {
|
|
725
|
-
if (row.op !== 'delete' && this.scopes.get(c.tenant)?.from.has(row.class))
|
|
726
|
-
c.before = row;
|
|
727
|
-
}
|
|
728
|
-
}
|
|
729
|
-
/**
|
|
730
|
-
* Смена класса без смены id (класс без ключа: сырой update set class, reclass()), уводящая строку из семейства from:
|
|
731
|
-
* журнал пишет версию уже другого класса, а для проекции это удаление (ревью безопасности №3, №4). Изменение — op
|
|
732
|
-
* delete с $deleted, строка — последняя версия в семействе, номер, время и автор — смены класса. Прошлая версия вне
|
|
733
|
-
* семейства арендатора — изменения нет.
|
|
734
|
-
*/
|
|
735
|
-
async leaving(q, changes, away) {
|
|
736
|
-
const prev = await this.previous(q, [...away]);
|
|
737
|
-
const out = [];
|
|
738
|
-
for (const c of changes) {
|
|
739
|
-
if (!away.has(c)) {
|
|
740
|
-
out.push(c);
|
|
741
|
-
continue;
|
|
742
|
-
}
|
|
743
|
-
const p = prev.get(c);
|
|
744
|
-
if (!p || p.op === 'delete' || !this.scopes.get(c.tenant)?.from.has(p.class))
|
|
745
|
-
continue;
|
|
746
|
-
const { agent: _agent, reason: _reason, moved: _moved, ...last } = p;
|
|
747
|
-
out.push(change({ ...last, rev: c.rev, at: c.at, author: c.author, op: 'delete', $deleted: true,
|
|
748
|
-
...(c.agent !== undefined ? { agent: c.agent } : {}), ...(c.reason !== undefined ? { reason: c.reason } : {}) }, c.cursor));
|
|
749
|
-
}
|
|
750
|
-
return out;
|
|
751
|
-
}
|
|
752
|
-
/**
|
|
753
|
-
* handle и запись результатов в транзакции пачки — группами по арендатору (ревью безопасности №2). failed —
|
|
754
|
-
* отметки выбывших этой пачки: каждая группа идёт под точкой сохранения, и ошибка данных арендатора (tenantFault:
|
|
755
|
-
* отказ базы при записи или чтении его результатов, отказ out по строке или приращениям) откатывает только группу,
|
|
756
|
-
* а арендатор попадает в failed (курсор — первого изменения группы, у перестройки — start). Исключение handle,
|
|
757
|
-
* нарушение границ out и сбой окружения пачку не спасают: откатывается всё. failed null (rebuild({ tenant })) — без
|
|
758
|
-
* точек сохранения: любая ошибка откатывает перестройку.
|
|
759
|
-
*/
|
|
760
|
-
async apply(q, changes, rep, failed, start = '') {
|
|
761
|
-
const groups = new Map();
|
|
762
|
-
for (const c of changes) {
|
|
763
|
-
const g = groups.get(c.tenant);
|
|
764
|
-
if (g)
|
|
765
|
-
g.push(c);
|
|
766
|
-
else
|
|
767
|
-
groups.set(c.tenant, [c]);
|
|
768
|
-
}
|
|
769
|
-
for (const [tenant, list] of groups) {
|
|
770
|
-
const out = new Out(this, q, tenant);
|
|
771
|
-
if (failed)
|
|
772
|
-
await q.unsafe('savepoint letopis_projector');
|
|
773
|
-
try {
|
|
774
|
-
await this.def.handle(list, out);
|
|
775
|
-
await out.flush();
|
|
776
|
-
// handle поймал ошибку базы и не бросил её: транзакция группы всё равно прервана
|
|
777
|
-
if (out.aborted !== undefined)
|
|
778
|
-
throw out.aborted;
|
|
779
|
-
}
|
|
780
|
-
catch (e) {
|
|
781
|
-
// причина — первая ошибка базы группы (следом за ней база отвечает лишь «транзакция прервана»)
|
|
782
|
-
const cause = out.aborted ?? e;
|
|
783
|
-
// строку арендатора держит чужая транзакция дольше lockTimeout (хвост ревью доработок после 1.2.0): отчёт
|
|
784
|
-
// называет арендатора; с lockRetries после стольких проходов подряд он выбывает, и пачка идёт для остальных,
|
|
785
|
-
// иначе откатывается вся пачка и проход повторяется
|
|
786
|
-
if (failed && lockWait(cause)) {
|
|
787
|
-
if (!rep.locked.includes(tenant))
|
|
788
|
-
rep.locked.push(tenant);
|
|
789
|
-
const hits = (this.lockHits.get(tenant) ?? 0) + 1;
|
|
790
|
-
if (this.lockRetries === 0 || hits < this.lockRetries) {
|
|
791
|
-
this.lockHits.set(tenant, hits);
|
|
792
|
-
throw e;
|
|
793
|
-
}
|
|
794
|
-
this.lockHits.delete(tenant);
|
|
795
|
-
await q.unsafe('rollback to savepoint letopis_projector');
|
|
796
|
-
await q.unsafe('release savepoint letopis_projector');
|
|
797
|
-
failed[tenant] = {
|
|
798
|
-
cursor: list[0].cursor || start,
|
|
799
|
-
code: '55P03',
|
|
800
|
-
message: `проектор ${this.name}: строку арендатора ${tenant} держит чужая транзакция — в течение ${passesGen(hits)} подряд дольше lockTimeout`,
|
|
801
|
-
};
|
|
802
|
-
continue;
|
|
803
|
-
}
|
|
804
|
-
if (!failed || !out.owns(e) || !tenantFault(cause))
|
|
805
|
-
throw e;
|
|
806
|
-
await q.unsafe('rollback to savepoint letopis_projector');
|
|
807
|
-
await q.unsafe('release savepoint letopis_projector');
|
|
808
|
-
const le = fromDbError(cause);
|
|
809
|
-
failed[tenant] = {
|
|
810
|
-
cursor: list[0].cursor || start,
|
|
811
|
-
code: le instanceof LetopisError ? le.code : String(le?.code ?? 'internal'),
|
|
812
|
-
message: String(le?.message ?? le).replace(/^letopis: [a-z_]+: /, ''),
|
|
813
|
-
};
|
|
814
|
-
continue;
|
|
815
|
-
}
|
|
816
|
-
if (failed)
|
|
817
|
-
await q.unsafe('release savepoint letopis_projector');
|
|
818
|
-
this.lockHits.delete(tenant);
|
|
819
|
-
rep.created += out.stats.created;
|
|
820
|
-
rep.updated += out.stats.updated;
|
|
821
|
-
rep.deleted += out.stats.deleted;
|
|
822
|
-
rep.unchanged += out.stats.unchanged;
|
|
823
|
-
}
|
|
824
|
-
}
|
|
825
|
-
/**
|
|
826
|
-
* Перестройка. 'auto' — путь run() (проекции ещё нет, onExpired: 'rebuild'): пачками, а прерванная перестройка
|
|
827
|
-
* пачками продолжается с места обрыва; блокировку держит другой процесс — 'busy' (run() подождёт), а не
|
|
828
|
-
* projector_busy, как у rebuild(). Иначе — как просили в rebuild().
|
|
829
|
-
*/
|
|
830
|
-
async rebuildNow(rep, chunked) {
|
|
831
|
-
if (chunked === 'auto') {
|
|
832
|
-
const st = await this.status();
|
|
833
|
-
return this.chunked(rep, st.state === 'rebuilding', { cursor: st.cursor });
|
|
834
|
-
}
|
|
835
|
-
if (chunked)
|
|
836
|
-
return this.chunked(rep, false);
|
|
837
|
-
await this.atomic(rep);
|
|
838
|
-
return 'done';
|
|
839
|
-
}
|
|
840
|
-
/** Строка текущего состояния как изменение перестройки; прошлой версии у неё нет. */
|
|
841
|
-
rebuildChange(raw) {
|
|
842
|
-
const c = change(toRow(raw), '', true);
|
|
843
|
-
if (this.def.withBefore)
|
|
844
|
-
c.before = null;
|
|
845
|
-
return c;
|
|
846
|
-
}
|
|
847
|
-
/** Изменение — из семейства from своего арендатора (сброс арендатора — тоже); выбывший арендатор пропускается. */
|
|
848
|
-
ours(row, failed) {
|
|
849
|
-
const sc = this.scopes.get(row.tenant);
|
|
850
|
-
return sc !== undefined && !failed?.[row.tenant] && (sc.from.has(row.class) || row.class === '#reset');
|
|
851
|
-
}
|
|
852
|
-
/** Смена класса без смены id, после которой строка вне семейства from арендатора, — возможно, уход из него (leaving). */
|
|
853
|
-
leaves(row, failed) {
|
|
854
|
-
const sc = this.scopes.get(row.tenant);
|
|
855
|
-
return sc !== undefined && !failed?.[row.tenant] && row.op === 'reclass' && !row.moved && !sc.from.has(row.class) && row.class !== '#reset';
|
|
856
|
-
}
|
|
857
|
-
/**
|
|
858
|
-
* Входит ли класс `cls` в классы `into` проектора у арендатора `tenant` — сами эти классы или их потомки. Служебный
|
|
859
|
-
* метод: им `out` проверяет, что `handle` пишет только в эти классы.
|
|
860
|
-
*/
|
|
861
|
-
intoOf(tenant, cls) {
|
|
862
|
-
return this.scopes.get(tenant)?.into.has(cls) ?? false;
|
|
863
|
-
}
|
|
864
|
-
/** Поля ключа класса into арендатора (пусто — класс без ключа). */
|
|
865
|
-
keyOf(tenant, cls) {
|
|
866
|
-
return this.scopes.get(tenant)?.keys.get(cls) ?? [];
|
|
867
|
-
}
|
|
868
|
-
/**
|
|
869
|
-
* Ключ свёртки inc: класс и строка из одних полей ключа класса или одного $id; иначе null. Строку с другими полями
|
|
870
|
-
* (данные, $tags, $owner) inc не складывает: её поля правят строку, и порядок таких правок важен.
|
|
871
|
-
*/
|
|
872
|
-
foldKey(tenant, cls, row) {
|
|
873
|
-
const key = this.scopes.get(tenant)?.keys.get(cls) ?? [];
|
|
874
|
-
const names = Object.keys(row).sort();
|
|
875
|
-
if (!names.length || !names.every((n) => n === '$id' || key.includes(n)))
|
|
876
|
-
return null;
|
|
877
|
-
return JSON.stringify([cls, names.map((n) => [n, row[n]])]);
|
|
878
|
-
}
|
|
879
|
-
busy() {
|
|
880
|
-
return new LetopisError('projector_busy', `проектор ${this.name} работает в другом процессе`);
|
|
881
|
-
}
|
|
882
|
-
/** Производные строки арендаторов tenants — только семейства into каждого из них (derived_clear по парам). */
|
|
883
|
-
async clear(q, tenants) {
|
|
884
|
-
const ts = [];
|
|
885
|
-
const cs = [];
|
|
886
|
-
for (const t of tenants) {
|
|
887
|
-
for (const c of this.scopes.get(t)?.into ?? []) {
|
|
888
|
-
ts.push(t);
|
|
889
|
-
cs.push(c);
|
|
890
|
-
}
|
|
891
|
-
}
|
|
892
|
-
await q.unsafe(`select ${this.env.S}.derived_clear($1, $2::uuid[], $3::text[])`, [this.name, ts, cs]);
|
|
893
|
-
}
|
|
894
|
-
/** Атомарная перестройка: одна транзакция, источники — курсором одним снимком. */
|
|
895
|
-
async atomic(rep) {
|
|
896
|
-
await this.tx(async (q) => {
|
|
897
|
-
if (!(await this.lock(q)))
|
|
898
|
-
throw this.busy();
|
|
899
|
-
await this.resolve(q);
|
|
900
|
-
const meta = await this.state(q);
|
|
901
|
-
await this.clear(q, this.tenants);
|
|
902
|
-
// курсор держит снимок своего объявления: pg_current_snapshot() в его запросе — этот снимок; строка s
|
|
903
|
-
// есть всегда, даже без источников
|
|
904
|
-
await q.unsafe(`declare letopis_rebuild no scroll cursor for
|
|
905
|
-
with s as (select pg_current_snapshot() as snap)
|
|
906
|
-
select s.snap::text as snap, pg_snapshot_xmin(s.snap)::text as xmin, case when e.id is null then null else ${rowJson('e')} end as row
|
|
907
|
-
from s left join ${this.env.S}.entity e on e.class = any ($1::text[]) and e.tenant = any ($2::uuid[])
|
|
908
|
-
order by e.id`, [this.from, this.tenants]);
|
|
909
|
-
let head = null;
|
|
910
|
-
// полная перестройка начинает всех участников заново: отметки выбывших — только её собственные; арендатор, чьи
|
|
911
|
-
// источники не ведут журнал, выбывает сразу
|
|
912
|
-
const failed = {};
|
|
913
|
-
for (const x of this.blind.keys())
|
|
914
|
-
failed[x] = this.blindFailure(x, '');
|
|
915
|
-
for (;;) {
|
|
916
|
-
const rows = await q.unsafe(`fetch ${this.batch} from letopis_rebuild`);
|
|
917
|
-
if (!rows.length)
|
|
918
|
-
break;
|
|
919
|
-
head ??= { tx: rows[0].xmin, seq: '0', snap: rows[0].snap };
|
|
920
|
-
const changes = rows.filter((r) => r.row).map((r) => this.rebuildChange(r.row)).filter((c) => this.ours(c.row, failed));
|
|
921
|
-
if (changes.length) {
|
|
922
|
-
await this.apply(q, changes, rep, failed, enc(head));
|
|
923
|
-
rep.changes += changes.length;
|
|
924
|
-
}
|
|
925
|
-
}
|
|
926
|
-
await q.unsafe('close letopis_rebuild');
|
|
927
|
-
for (const x of this.blind.keys())
|
|
928
|
-
failed[x].cursor = enc(head);
|
|
929
|
-
// выбывший посреди перестройки — без производных строк: прежние пачки построили их лишь частично
|
|
930
|
-
if (Object.keys(failed).length)
|
|
931
|
-
await this.clear(q, Object.keys(failed));
|
|
932
|
-
await this.advance(q, meta.cursor, withFailed({ cursor: enc(head), at: await this.now(q), version: this.def.version, state: 'running',
|
|
933
|
-
tenants: [...this.tenants] }, failed));
|
|
934
|
-
rep.rebuilds++;
|
|
935
|
-
});
|
|
936
|
-
}
|
|
937
|
-
/**
|
|
938
|
-
* Перестройка пачками: своя транзакция и свой снимок у каждой пачки источников. auto — путь run(): занятую другим
|
|
939
|
-
* процессом блокировку он пережидает ('busy'), а новую перестройку не начинает, если её уже начал или закончил
|
|
940
|
-
* другой процесс (курсор чекпоинта — не тот, что видел run()): тогда только продолжает начатую.
|
|
941
|
-
*/
|
|
942
|
-
async chunked(rep, resume, auto) {
|
|
943
|
-
const busy = () => {
|
|
944
|
-
if (!auto)
|
|
945
|
-
throw this.busy();
|
|
946
|
-
rep.skipped++;
|
|
947
|
-
return 'busy';
|
|
948
|
-
};
|
|
949
|
-
if (!resume) {
|
|
950
|
-
const r = await this.tx(async (q) => {
|
|
951
|
-
if (!(await this.lock(q)))
|
|
952
|
-
return busy();
|
|
953
|
-
await this.resolve(q);
|
|
954
|
-
const meta = await this.state(q);
|
|
955
|
-
if (auto && (meta.state === 'rebuilding' || (meta.cursor ?? null) !== auto.cursor))
|
|
956
|
-
return 'ok';
|
|
957
|
-
await this.clear(q, this.tenants);
|
|
958
|
-
const [h] = await q.unsafe(`select pg_snapshot_xmin(s)::text as xmin, s::text as snap from pg_current_snapshot() s`);
|
|
959
|
-
const start = enc({ tx: h.xmin, seq: '0', snap: h.snap });
|
|
960
|
-
// все участники заново: отметки выбывших снимаются; арендатор, чьи источники не ведут журнал, выбывает сразу
|
|
961
|
-
const failed = {};
|
|
962
|
-
for (const x of this.blind.keys())
|
|
963
|
-
failed[x] = this.blindFailure(x, start);
|
|
964
|
-
await this.advance(q, meta.cursor, withFailed({ cursor: start, at: await this.now(q), version: this.def.version, state: 'rebuilding',
|
|
965
|
-
rebuild: { start, last: null, chunks: [] } }, failed));
|
|
966
|
-
return 'ok';
|
|
967
|
-
});
|
|
968
|
-
if (r === 'busy')
|
|
969
|
-
return r;
|
|
970
|
-
}
|
|
971
|
-
for (;;) {
|
|
972
|
-
const r = await this.tx(async (q) => {
|
|
973
|
-
if (!(await this.lock(q)))
|
|
974
|
-
return busy();
|
|
975
|
-
if (this.due())
|
|
976
|
-
await this.resolve(q);
|
|
977
|
-
const meta = await this.state(q);
|
|
978
|
-
const rb = meta.rebuild;
|
|
979
|
-
if (meta.state !== 'rebuilding' || !rb)
|
|
980
|
-
return 'done';
|
|
981
|
-
const failed = { ...(meta.failed ?? {}) };
|
|
982
|
-
for (const x of this.blind.keys())
|
|
983
|
-
failed[x] ??= this.blindFailure(x, rb.start);
|
|
984
|
-
// пачка источников и её снимок — одним оператором; выбывших арендаторов пачка не читает
|
|
985
|
-
const rows = await q.unsafe(`with s as (select pg_current_snapshot()::text as snap)
|
|
986
|
-
select s.snap, case when e.id is null then null else ${rowJson('e')} end as row
|
|
987
|
-
from s left join lateral (select x.* from ${this.env.S}.entity x
|
|
988
|
-
where x.class = any ($1::text[]) and x.tenant = any ($4::uuid[]) and ($2::uuid is null or x.id > $2::uuid)
|
|
989
|
-
order by x.id limit $3) e on true`, [this.from, rb.last, this.batch, this.tenants.filter((x) => !failed[x])]);
|
|
990
|
-
const real = rows.filter((r) => r.row);
|
|
991
|
-
if (!real.length) {
|
|
992
|
-
await this.advance(q, meta.cursor, withFailed({ cursor: rb.start, at: await this.now(q), version: this.def.version, state: 'running',
|
|
993
|
-
chunks: [...rb.chunks, { upto: LAST_ID, snap: rows[0].snap }], tenants: [...this.tenants] }, failed));
|
|
994
|
-
rep.rebuilds++;
|
|
995
|
-
return 'done';
|
|
996
|
-
}
|
|
997
|
-
const changes = real.map((r) => this.rebuildChange(r.row)).filter((c) => this.ours(c.row, failed));
|
|
998
|
-
if (changes.length)
|
|
999
|
-
await this.apply(q, changes, rep, failed, rb.start);
|
|
1000
|
-
// выбывший в этой пачке — без производных строк: прежние пачки построили их лишь частично
|
|
1001
|
-
const fresh = Object.keys(failed).filter((x) => !meta.failed?.[x]);
|
|
1002
|
-
if (fresh.length)
|
|
1003
|
-
await this.clear(q, fresh);
|
|
1004
|
-
rep.changes += changes.length;
|
|
1005
|
-
// граница пачки — последний прочитанный id (а не последнее изменение: строки чужих семейств пропущены)
|
|
1006
|
-
const last = real[real.length - 1].row.id;
|
|
1007
|
-
await this.advance(q, meta.cursor, withFailed({ ...meta, rebuild: { start: rb.start, last, chunks: [...rb.chunks, { upto: last, snap: rows[0].snap }] } }, failed));
|
|
1008
|
-
return 'more';
|
|
1009
|
-
});
|
|
1010
|
-
if (r !== 'more')
|
|
1011
|
-
return r;
|
|
1012
|
-
}
|
|
1013
|
-
}
|
|
1014
|
-
/**
|
|
1015
|
-
* Перестройка одного арендатора (rebuild({ tenant })): одной транзакцией его производные строки удаляются и
|
|
1016
|
-
* строятся заново из его текущих строк источников, отметка выбывшего снимается. Снимок перестройки встаёт в
|
|
1017
|
-
* чекпоинт (meta.resync): изменения арендатора, чья транзакция в нём видна, хвост журнала пропускает — каждое
|
|
1018
|
-
* изменение применено ровно один раз. Ошибка записи откатывает перестройку: арендатор остаётся выбывшим.
|
|
1019
|
-
*/
|
|
1020
|
-
async rebuildTenant(rep, tenant) {
|
|
1021
|
-
await this.tx(async (q) => {
|
|
1022
|
-
if (!(await this.lock(q)))
|
|
1023
|
-
throw this.busy();
|
|
1024
|
-
await this.resolve(q);
|
|
1025
|
-
const meta = await this.state(q);
|
|
1026
|
-
if (!meta.cursor || meta.state === 'rebuilding') {
|
|
1027
|
-
throw new LetopisError('rebuild_required', `проектор ${this.name}: проекции ещё нет или идёт перестройка пачками — сначала run() или rebuild()`);
|
|
1028
|
-
}
|
|
1029
|
-
if (meta.version !== this.def.version)
|
|
1030
|
-
throw this.versionError(meta);
|
|
1031
|
-
const sc = this.scopes.get(tenant);
|
|
1032
|
-
if (!sc) {
|
|
1033
|
-
throw new LetopisError('tenant_denied', `проектор ${this.name}: арендатор ${tenant} не участник — у него не все классы ${this.def.into.join(', ')} помечены derivedBy: '${this.name}'${this.only ? ' или его нет в списке tenants' : ''}`);
|
|
1034
|
-
}
|
|
1035
|
-
if (this.blind.has(tenant))
|
|
1036
|
-
throw this.blindError(tenant);
|
|
1037
|
-
await this.clear(q, [tenant]);
|
|
1038
|
-
await q.unsafe(`declare letopis_rebuild no scroll cursor for
|
|
1039
|
-
with s as (select pg_current_snapshot() as snap)
|
|
1040
|
-
select s.snap::text as snap, case when e.id is null then null else ${rowJson('e')} end as row
|
|
1041
|
-
from s left join ${this.env.S}.entity e on e.class = any ($1::text[]) and e.tenant = $2::uuid
|
|
1042
|
-
order by e.id`, [[...sc.from], tenant]);
|
|
1043
|
-
let snap = null;
|
|
1044
|
-
for (;;) {
|
|
1045
|
-
const rows = await q.unsafe(`fetch ${this.batch} from letopis_rebuild`);
|
|
1046
|
-
if (!rows.length)
|
|
1047
|
-
break;
|
|
1048
|
-
snap ??= rows[0].snap;
|
|
1049
|
-
const changes = rows.filter((r) => r.row).map((r) => this.rebuildChange(r.row)).filter((c) => this.ours(c.row));
|
|
1050
|
-
if (changes.length) {
|
|
1051
|
-
await this.apply(q, changes, rep, null);
|
|
1052
|
-
rep.changes += changes.length;
|
|
1053
|
-
}
|
|
1054
|
-
}
|
|
1055
|
-
await q.unsafe('close letopis_rebuild');
|
|
1056
|
-
const failed = { ...(meta.failed ?? {}) };
|
|
1057
|
-
delete failed[tenant];
|
|
1058
|
-
const next = { ...meta, resync: { ...(meta.resync ?? {}), [tenant]: snap }, since: { ...(meta.since ?? {}), [tenant]: await this.now(q) } };
|
|
1059
|
-
if (next.retenant?.[tenant]) {
|
|
1060
|
-
const { [tenant]: _drop, ...rest } = next.retenant;
|
|
1061
|
-
void _drop;
|
|
1062
|
-
if (Object.keys(rest).length)
|
|
1063
|
-
next.retenant = rest;
|
|
1064
|
-
else
|
|
1065
|
-
delete next.retenant;
|
|
1066
|
-
}
|
|
1067
|
-
await this.advance(q, meta.cursor, withFailed(next, failed));
|
|
1068
|
-
rep.rebuilds++;
|
|
1069
|
-
});
|
|
1070
|
-
}
|
|
1071
|
-
/** Предусловия перестройки одного арендатора (как у rebuild({ tenant })). */
|
|
1072
|
-
tenantAdmit(meta, tenant) {
|
|
1073
|
-
if (!meta.cursor || meta.state === 'rebuilding') {
|
|
1074
|
-
throw new LetopisError('rebuild_required', `проектор ${this.name}: проекции ещё нет или идёт перестройка пачками — сначала run() или rebuild()`);
|
|
1075
|
-
}
|
|
1076
|
-
if (meta.version !== this.def.version)
|
|
1077
|
-
throw this.versionError(meta);
|
|
1078
|
-
const sc = this.scopes.get(tenant);
|
|
1079
|
-
if (!sc) {
|
|
1080
|
-
throw new LetopisError('tenant_denied', `проектор ${this.name}: арендатор ${tenant} не участник — у него не все классы ${this.def.into.join(', ')} помечены derivedBy: '${this.name}'${this.only ? ' или его нет в списке tenants' : ''}`);
|
|
1081
|
-
}
|
|
1082
|
-
if (this.blind.has(tenant))
|
|
1083
|
-
throw this.blindError(tenant);
|
|
1084
|
-
return sc;
|
|
1085
|
-
}
|
|
1086
|
-
/**
|
|
1087
|
-
* Начало перестройки арендатора пачками: его производные строки удаляются, отметка выбывшего снимается, в чекпоинт
|
|
1088
|
-
* встают пустая карта пачек и время начала. auto — путь run(): занятая блокировка — 'busy', а не projector_busy.
|
|
1089
|
-
*/
|
|
1090
|
-
async startTenant(rep, tenant, auto) {
|
|
1091
|
-
for (;;) {
|
|
1092
|
-
// строку арендатора (его производные строки удаляются) держит чужая транзакция дольше lockTimeout — как занятая
|
|
1093
|
-
// блокировка проектора: run() повторит на следующем проходе, rebuild() подождёт (хвост ревью доработок после 1.2.0:
|
|
1094
|
-
// rebuild({ tenant, chunked: true }) отдавал сырую 55P03)
|
|
1095
|
-
const r = await this.tx(async (q) => {
|
|
1096
|
-
if (!(await this.lock(q)))
|
|
1097
|
-
return 'busy';
|
|
1098
|
-
await this.resolve(q);
|
|
1099
|
-
const meta = await this.state(q);
|
|
1100
|
-
if (auto) {
|
|
1101
|
-
// автоматический путь: арендатор, который с последнего прохода перестал подходить (не участник, источник без
|
|
1102
|
-
// журнала), выбывает с причиной, а не останавливает run() для всех (автоматическое ревью коммита 2b229d2)
|
|
1103
|
-
if (!meta.cursor || meta.state === 'rebuilding' || meta.version !== this.def.version)
|
|
1104
|
-
return 'skip';
|
|
1105
|
-
if (!this.scopes.has(tenant) || this.blind.has(tenant)) {
|
|
1106
|
-
const failed = { ...(meta.failed ?? {}) };
|
|
1107
|
-
failed[tenant] = this.scopes.has(tenant) ? this.blindFailure(tenant, meta.cursor)
|
|
1108
|
-
: { cursor: meta.cursor, code: 'tenant_denied', message: `проектор ${this.name}: арендатор ${tenant} больше не участник` };
|
|
1109
|
-
await this.advance(q, meta.cursor, withFailed(meta, failed));
|
|
1110
|
-
return 'skip';
|
|
1111
|
-
}
|
|
1112
|
-
}
|
|
1113
|
-
this.tenantAdmit(meta, tenant);
|
|
1114
|
-
await this.clear(q, [tenant]);
|
|
1115
|
-
const failed = { ...(meta.failed ?? {}) };
|
|
1116
|
-
delete failed[tenant];
|
|
1117
|
-
const now = await this.now(q);
|
|
1118
|
-
const resync = { ...(meta.resync ?? {}) };
|
|
1119
|
-
delete resync[tenant];
|
|
1120
|
-
const next = { ...meta, retenant: { ...(meta.retenant ?? {}), [tenant]: { last: null, chunks: [] } },
|
|
1121
|
-
since: { ...(meta.since ?? {}), [tenant]: now }, resync, tenants: [...new Set([...(meta.tenants ?? this.tenants), tenant])] };
|
|
1122
|
-
if (!Object.keys(resync).length)
|
|
1123
|
-
delete next.resync;
|
|
1124
|
-
await this.advance(q, meta.cursor, withFailed(next, failed));
|
|
1125
|
-
return 'ok';
|
|
1126
|
-
}).catch((e) => {
|
|
1127
|
-
if (!lockWait(e))
|
|
1128
|
-
throw e;
|
|
1129
|
-
if (!auto)
|
|
1130
|
-
rep.skipped++;
|
|
1131
|
-
return 'busy';
|
|
1132
|
-
});
|
|
1133
|
-
if (r !== 'busy' || auto) {
|
|
1134
|
-
if (r === 'busy')
|
|
1135
|
-
rep.skipped++;
|
|
1136
|
-
return r;
|
|
1137
|
-
}
|
|
1138
|
-
await new Promise((res) => setTimeout(res, 20));
|
|
1139
|
-
}
|
|
1140
|
-
}
|
|
1141
|
-
/**
|
|
1142
|
-
* Пачка перестройки арендатора: своя транзакция и свой снимок. Ошибка данных арендатора — он выбывает с её кодом,
|
|
1143
|
-
* его частично построенные строки удаляются, перестройка снимается; источников больше нет — перестройка закончена.
|
|
1144
|
-
*/
|
|
1145
|
-
async tenantChunk(rep, tenant) {
|
|
1146
|
-
return this.tx(async (q) => {
|
|
1147
|
-
if (!(await this.lock(q))) {
|
|
1148
|
-
rep.skipped++;
|
|
1149
|
-
return 'busy';
|
|
1150
|
-
}
|
|
1151
|
-
if (this.due())
|
|
1152
|
-
await this.resolve(q);
|
|
1153
|
-
const meta = await this.state(q);
|
|
1154
|
-
const rt = meta.retenant?.[tenant];
|
|
1155
|
-
if (!rt || rt.done || meta.failed?.[tenant] || meta.state === 'rebuilding')
|
|
1156
|
-
return 'done';
|
|
1157
|
-
const sc = this.scopes.get(tenant);
|
|
1158
|
-
const failed = { ...(meta.failed ?? {}) };
|
|
1159
|
-
if (!sc || this.blind.has(tenant)) {
|
|
1160
|
-
failed[tenant] = sc ? this.blindFailure(tenant, meta.cursor) : { cursor: meta.cursor, code: 'tenant_denied', message: `арендатор ${tenant} больше не участник проектора ${this.name}` };
|
|
1161
|
-
}
|
|
1162
|
-
else {
|
|
1163
|
-
const rows = await q.unsafe(`with s as (select pg_current_snapshot()::text as snap)
|
|
1164
|
-
select s.snap, case when e.id is null then null else ${rowJson('e')} end as row
|
|
1165
|
-
from s left join lateral (select x.* from ${this.env.S}.entity x
|
|
1166
|
-
where x.class = any ($1::text[]) and x.tenant = $4::uuid and ($2::uuid is null or x.id > $2::uuid)
|
|
1167
|
-
order by x.id limit $3) e on true`, [[...sc.from], rt.last, this.batch, tenant]);
|
|
1168
|
-
const real = rows.filter((r) => r.row);
|
|
1169
|
-
if (!real.length) {
|
|
1170
|
-
await this.advance(q, meta.cursor, { ...meta, retenant: { ...meta.retenant,
|
|
1171
|
-
[tenant]: { last: rt.last, chunks: [...rt.chunks, { upto: LAST_ID, snap: rows[0].snap }], done: true } } });
|
|
1172
|
-
rep.rebuilds++;
|
|
1173
|
-
return 'done';
|
|
1174
|
-
}
|
|
1175
|
-
const changes = real.map((r) => this.rebuildChange(r.row)).filter((c) => this.ours(c.row, failed));
|
|
1176
|
-
if (changes.length)
|
|
1177
|
-
await this.apply(q, changes, rep, failed, meta.cursor);
|
|
1178
|
-
rep.changes += changes.length;
|
|
1179
|
-
if (!failed[tenant]) {
|
|
1180
|
-
const last = real[real.length - 1].row.id;
|
|
1181
|
-
await this.advance(q, meta.cursor, { ...meta, retenant: { ...meta.retenant, [tenant]: { last, chunks: [...rt.chunks, { upto: last, snap: rows[0].snap }] } } });
|
|
1182
|
-
return 'more';
|
|
1183
|
-
}
|
|
1184
|
-
}
|
|
1185
|
-
// выбыл посреди перестройки — без производных строк: прежние пачки построили их лишь частично
|
|
1186
|
-
await this.clear(q, [tenant]);
|
|
1187
|
-
const { [tenant]: _drop, ...rest } = meta.retenant ?? {};
|
|
1188
|
-
void _drop;
|
|
1189
|
-
const next = { ...meta };
|
|
1190
|
-
if (Object.keys(rest).length)
|
|
1191
|
-
next.retenant = rest;
|
|
1192
|
-
else
|
|
1193
|
-
delete next.retenant;
|
|
1194
|
-
await this.advance(q, meta.cursor, withFailed(next, failed));
|
|
1195
|
-
return 'done';
|
|
1196
|
-
});
|
|
1197
|
-
}
|
|
1198
|
-
/**
|
|
1199
|
-
* Перестройка арендаторов пачками — проход run(): с start (onExpired: 'rebuild') начинает её у каждого выбывшего по
|
|
1200
|
-
* cursor_expired, затем продвигает на пачку каждую начатую — автоматическую и прерванную rebuild({ tenant, chunked:
|
|
1201
|
-
* true }). true — работа ещё осталась.
|
|
1202
|
-
*/
|
|
1203
|
-
async tenantWork(rep, start) {
|
|
1204
|
-
const meta = await this.tx(async (q) => this.state(q));
|
|
1205
|
-
if (!meta.cursor || meta.state === 'rebuilding' || meta.version !== this.def.version)
|
|
1206
|
-
return false;
|
|
1207
|
-
let more = false;
|
|
1208
|
-
// новые участники — начальная перестройка пачками и без onExpired (план 2.0, 7.4 №5)
|
|
1209
|
-
const newcomers = this.newcomers;
|
|
1210
|
-
this.newcomers = [];
|
|
1211
|
-
for (const t of newcomers)
|
|
1212
|
-
if ((await this.startTenant(rep, t, true)) === 'ok')
|
|
1213
|
-
more = true;
|
|
1214
|
-
if (start) {
|
|
1215
|
-
for (const [t, f] of Object.entries(meta.failed ?? {})) {
|
|
1216
|
-
if (f.code !== 'cursor_expired' || this.blind.has(t) || !this.scopes.has(t))
|
|
1217
|
-
continue;
|
|
1218
|
-
if ((await this.startTenant(rep, t, true)) === 'ok')
|
|
1219
|
-
more = true;
|
|
1220
|
-
}
|
|
1221
|
-
}
|
|
1222
|
-
const fresh = start || newcomers.length ? await this.tx(async (q) => this.state(q)) : meta;
|
|
1223
|
-
for (const [t, v] of Object.entries(fresh.retenant ?? {})) {
|
|
1224
|
-
if (v.done)
|
|
1225
|
-
continue;
|
|
1226
|
-
if ((await this.tenantChunk(rep, t)) === 'more')
|
|
1227
|
-
more = true;
|
|
1228
|
-
}
|
|
1229
|
-
this.tenantsPending = more;
|
|
1230
|
-
return more;
|
|
1231
|
-
}
|
|
1232
|
-
/** Повторять fn, пока строку держит чужая транзакция дольше lockTimeout (55P03): пауза 20 мс, проход — в skipped. */
|
|
1233
|
-
async untilFree(rep, fn) {
|
|
1234
|
-
for (;;) {
|
|
1235
|
-
try {
|
|
1236
|
-
return await fn();
|
|
1237
|
-
}
|
|
1238
|
-
catch (e) {
|
|
1239
|
-
if (!lockWait(e))
|
|
1240
|
-
throw e;
|
|
1241
|
-
rep.skipped++;
|
|
1242
|
-
}
|
|
1243
|
-
await new Promise((res) => setTimeout(res, 20));
|
|
1244
|
-
}
|
|
1245
|
-
}
|
|
1246
|
-
/** Ждать сигнал источников, остановку или запасной опрос. */
|
|
1247
|
-
sleep() {
|
|
1248
|
-
return new Promise((resolve) => {
|
|
1249
|
-
const done = () => {
|
|
1250
|
-
clearTimeout(t);
|
|
1251
|
-
this.wake = null;
|
|
1252
|
-
resolve();
|
|
1253
|
-
};
|
|
1254
|
-
const t = setTimeout(done, this.pollMs);
|
|
1255
|
-
this.wake = done;
|
|
1256
|
-
});
|
|
1257
|
-
}
|
|
1258
|
-
}
|