747 lines
74 KiB
JavaScript
747 lines
74 KiB
JavaScript
function escapeHtml(value) {
|
||
return String(value)
|
||
.replaceAll('&', '&')
|
||
.replaceAll('<', '<')
|
||
.replaceAll('>', '>')
|
||
.replaceAll('"', '"')
|
||
.replaceAll("'", ''');
|
||
}
|
||
|
||
function paragraph(text) {
|
||
return '<p>' + text + '</p>';
|
||
}
|
||
|
||
function heading(text) {
|
||
return '<h2>' + text + '</h2>';
|
||
}
|
||
|
||
function codeBlock(lines) {
|
||
return '<pre><code>' + escapeHtml(Array.isArray(lines) ? lines.join('\n') : lines) + '</code></pre>';
|
||
}
|
||
|
||
function figure(src, alt, caption) {
|
||
return '<figure><img src="' + src + '" alt="' + alt + '" loading="lazy" /><figcaption>' + caption + '</figcaption></figure>';
|
||
}
|
||
|
||
function orderedList(items) {
|
||
return '<ol>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ol>';
|
||
}
|
||
|
||
function dataTable(caption, headers, rows) {
|
||
const head = '<thead><tr>' + headers.map((header) => '<th scope="col">' + header + '</th>').join('') + '</tr></thead>';
|
||
const body = '<tbody>' + rows.map((row) => '<tr>' + row.map((cell) => '<td>' + cell + '</td>').join('') + '</tr>').join('') + '</tbody>';
|
||
return '<div class="table-scroll"><table><caption>' + caption + '</caption>' + head + body + '</table></div>';
|
||
}
|
||
|
||
function sourceList(items) {
|
||
return '<ul>' + items.map((item) => '<li><a href="' + item.url + '" target="_blank" rel="noopener noreferrer">' + item.title + '</a> — ' + item.note + '</li>').join('') + '</ul>';
|
||
}
|
||
|
||
function plainText(content) {
|
||
return content
|
||
.replace(/<[^>]+>/g, ' ')
|
||
.replaceAll(' ', ' ')
|
||
.replaceAll('"', '"')
|
||
.replaceAll(''', "'")
|
||
.replaceAll('<', '<')
|
||
.replaceAll('>', '>')
|
||
.replaceAll('&', '&')
|
||
.replace(/\s+/g, ' ')
|
||
.trim();
|
||
}
|
||
|
||
function bodyText(content) {
|
||
return plainText(content.replace(/<h2>Проверяемые источники<\/h2>[\s\S]*?(?=<h2>|$)/, ''));
|
||
}
|
||
|
||
function createRevision(meta, bodyParts, sources) {
|
||
if (sources.length < 2) {
|
||
throw new Error(meta.slug + ': нужно минимум два первичных или официальных источника');
|
||
}
|
||
|
||
const contentHtml = bodyParts.join('\n') + '\n' + heading('Проверяемые источники') + '\n' + sourceList(sources);
|
||
const proseLength = bodyText(contentHtml).length;
|
||
|
||
if (proseLength < 5000 || proseLength > 15000) {
|
||
throw new Error(meta.slug + ': основной текст вне 5 000–15 000 знаков: ' + proseLength);
|
||
}
|
||
|
||
return { ...meta, contentHtml, proseLength };
|
||
}
|
||
|
||
const cloudEvents101 = {
|
||
title: 'CloudEvents Specification v1.0.1: release record',
|
||
url: 'https://github.com/cloudevents/spec/releases/tag/ce%40v1.0.1',
|
||
note: 'официальная карточка выпуска, опубликованного в декабре 2020 года; поля id, source и type служат словарём учебного конверта, но fixture не является реализацией CloudEvents',
|
||
};
|
||
|
||
const postgres13Isolation = {
|
||
title: 'PostgreSQL 13: Transaction Isolation',
|
||
url: 'https://www.postgresql.org/docs/13/transaction-iso.html',
|
||
note: 'версионная документация PostgreSQL 13, доступная к апрелю 2021 года; описывает границы локальной транзакции и необходимость retry при serialization failure',
|
||
};
|
||
|
||
const postgres13Insert = {
|
||
title: 'PostgreSQL 13: INSERT и ON CONFLICT',
|
||
url: 'https://www.postgresql.org/docs/13/sql-insert.html',
|
||
note: 'официальный синтаксис и семантика локального уникального ограничения; пример ниже не утверждает атомарность между несколькими сервисами',
|
||
};
|
||
|
||
const kafka27Producer = {
|
||
title: 'Apache Kafka 2.7.0: KafkaProducer API',
|
||
url: 'https://kafka.apache.org/27/javadoc/org/apache/kafka/clients/producer/KafkaProducer.html',
|
||
note: 'versioned API линии 2.7, существовавшей в апреле 2021 года; ограничивает идемпотентность producer одной session и не устраняет повторную отправку прикладным кодом',
|
||
};
|
||
|
||
export const trainingOrderAtPaid = Object.freeze({
|
||
id: 'order-417',
|
||
status: 'paid',
|
||
version: 2,
|
||
totalMinor: 1500,
|
||
});
|
||
|
||
export const trainingOrderPaidEvent = Object.freeze({
|
||
id: 'evt-order-417-paid-v2',
|
||
source: 'training/order-service',
|
||
type: 'training.order.paid',
|
||
subject: 'order-417',
|
||
orderVersion: 2,
|
||
});
|
||
|
||
function cloneTrainingOrder(order) {
|
||
return {
|
||
id: order.id,
|
||
status: order.status,
|
||
version: order.version,
|
||
totalMinor: order.totalMinor,
|
||
};
|
||
}
|
||
|
||
function assertOrder(order) {
|
||
if (!order || typeof order.id !== 'string' || !Number.isInteger(order.version)) {
|
||
throw new Error('training order requires an id and integer version');
|
||
}
|
||
if (!['paid', 'cancelled'].includes(order.status)) {
|
||
throw new Error('training order has an unsupported owner state');
|
||
}
|
||
}
|
||
|
||
function assertTrainingEvent(event) {
|
||
if (!event || typeof event.id !== 'string' || typeof event.source !== 'string' || typeof event.type !== 'string' || typeof event.subject !== 'string') {
|
||
throw new Error('training event requires id, source, type and subject');
|
||
}
|
||
if (!Number.isInteger(event.orderVersion) || event.orderVersion < 1) {
|
||
throw new Error('training event requires a positive orderVersion');
|
||
}
|
||
}
|
||
|
||
function eventKey(event) {
|
||
return event.source + '::' + event.id;
|
||
}
|
||
|
||
/**
|
||
* Это намеренно маленькая доменная компенсация. Она создаёт новое решение
|
||
* owner-а, а не отменяет предыдущую distributed transaction. В реальном
|
||
* приложении проверка входа, запись owner и publication имеют собственную
|
||
* границу хранения и интеграционные проверки.
|
||
*/
|
||
export function createControlledCompensation(order, compensationLedger, rejection) {
|
||
assertOrder(order);
|
||
|
||
if (!rejection || rejection.orderId !== order.id || rejection.orderVersion !== order.version) {
|
||
throw new Error('reservation rejection does not match the owner version');
|
||
}
|
||
if (rejection.reason !== 'training-reservation-rejected') {
|
||
throw new Error('only the documented training rejection can request compensation');
|
||
}
|
||
if (order.status !== 'paid') {
|
||
throw new Error('only a paid training order can be compensated');
|
||
}
|
||
|
||
const compensationKey = 'compensation:' + order.id + ':reservation:v' + order.version;
|
||
const recorded = compensationLedger.get(compensationKey);
|
||
if (recorded) {
|
||
return {
|
||
state: 'compensation-already-recorded',
|
||
compensationKey,
|
||
order: recorded.order,
|
||
event: recorded.event,
|
||
};
|
||
}
|
||
|
||
const compensatedOrder = {
|
||
...cloneTrainingOrder(order),
|
||
status: 'cancelled',
|
||
version: order.version + 1,
|
||
};
|
||
const event = {
|
||
id: 'evt-order-417-cancelled-v3',
|
||
source: 'training/order-service',
|
||
type: 'training.order.cancelled',
|
||
subject: order.id,
|
||
orderVersion: compensatedOrder.version,
|
||
compensationKey,
|
||
reason: rejection.reason,
|
||
};
|
||
|
||
compensationLedger.set(compensationKey, { order: compensatedOrder, event });
|
||
return {
|
||
state: 'compensation-recorded',
|
||
compensationKey,
|
||
order: compensatedOrder,
|
||
event,
|
||
};
|
||
}
|
||
|
||
function createTrainingProjection(orderId) {
|
||
return {
|
||
orderId,
|
||
version: 1,
|
||
state: 'accepted',
|
||
readyToShip: false,
|
||
appliedEventIds: new Set(),
|
||
deferredByVersion: new Map(),
|
||
evidence: [],
|
||
};
|
||
}
|
||
|
||
function projectionSnapshot(projection) {
|
||
return {
|
||
orderId: projection.orderId,
|
||
version: projection.version,
|
||
state: projection.state,
|
||
readyToShip: projection.readyToShip,
|
||
appliedEventIds: [...projection.appliedEventIds].sort(),
|
||
deferredVersions: [...projection.deferredByVersion.keys()].sort((left, right) => left - right),
|
||
evidence: projection.evidence.map((item) => ({ ...item })),
|
||
};
|
||
}
|
||
|
||
function applyNextTrainingEvent(projection, event) {
|
||
assertTrainingEvent(event);
|
||
const key = eventKey(event);
|
||
|
||
if (event.subject !== projection.orderId) {
|
||
throw new Error('training event was delivered to another projection');
|
||
}
|
||
|
||
if (projection.appliedEventIds.has(key)) {
|
||
return {
|
||
state: 'duplicate-event-suppressed',
|
||
version: projection.version,
|
||
eventId: event.id,
|
||
};
|
||
}
|
||
|
||
if (event.orderVersion === projection.version) {
|
||
projection.evidence.push({
|
||
kind: 'same-version-event-rejected',
|
||
receivedEventId: event.id,
|
||
version: event.orderVersion,
|
||
});
|
||
return {
|
||
state: 'same-version-event-rejected',
|
||
version: projection.version,
|
||
eventId: event.id,
|
||
};
|
||
}
|
||
|
||
if (event.orderVersion < projection.version) {
|
||
return {
|
||
state: 'stale-event-ignored',
|
||
version: projection.version,
|
||
eventId: event.id,
|
||
};
|
||
}
|
||
|
||
if (event.orderVersion > projection.version + 1) {
|
||
const deferred = projection.deferredByVersion.get(event.orderVersion);
|
||
if (deferred) {
|
||
if (eventKey(deferred) === key) {
|
||
return {
|
||
state: 'deferred-duplicate-suppressed',
|
||
version: projection.version,
|
||
eventId: event.id,
|
||
};
|
||
}
|
||
|
||
projection.evidence.push({
|
||
kind: 'deferred-version-conflict',
|
||
existingEventId: deferred.id,
|
||
receivedEventId: event.id,
|
||
version: event.orderVersion,
|
||
});
|
||
return {
|
||
state: 'deferred-version-conflict',
|
||
version: projection.version,
|
||
eventId: event.id,
|
||
};
|
||
}
|
||
|
||
projection.deferredByVersion.set(event.orderVersion, event);
|
||
projection.evidence.push({
|
||
kind: 'version-gap',
|
||
receivedEventId: event.id,
|
||
expectedVersion: projection.version + 1,
|
||
receivedVersion: event.orderVersion,
|
||
});
|
||
return {
|
||
state: 'deferred-version-gap',
|
||
expectedVersion: projection.version + 1,
|
||
receivedVersion: event.orderVersion,
|
||
eventId: event.id,
|
||
};
|
||
}
|
||
|
||
if (event.type === 'training.order.paid') {
|
||
projection.state = 'awaiting-reservation';
|
||
projection.readyToShip = false;
|
||
} else if (event.type === 'training.order.cancelled') {
|
||
projection.state = 'cancelled';
|
||
projection.readyToShip = false;
|
||
} else {
|
||
throw new Error('training projection received an unsupported event type');
|
||
}
|
||
|
||
projection.version = event.orderVersion;
|
||
projection.appliedEventIds.add(key);
|
||
projection.evidence.push({
|
||
kind: 'event-applied',
|
||
eventId: event.id,
|
||
version: event.orderVersion,
|
||
state: projection.state,
|
||
});
|
||
|
||
return {
|
||
state: 'event-applied',
|
||
version: projection.version,
|
||
projectionState: projection.state,
|
||
eventId: event.id,
|
||
};
|
||
}
|
||
|
||
function replayDeferredTrainingEvents(projection) {
|
||
const results = [];
|
||
let next = projection.deferredByVersion.get(projection.version + 1);
|
||
|
||
while (next) {
|
||
projection.deferredByVersion.delete(next.orderVersion);
|
||
results.push(applyNextTrainingEvent(projection, next));
|
||
next = projection.deferredByVersion.get(projection.version + 1);
|
||
}
|
||
|
||
return results;
|
||
}
|
||
|
||
function shippingIsAllowed(owner, projection) {
|
||
return owner.status === 'paid'
|
||
&& projection.state === 'reserved'
|
||
&& owner.version === projection.version
|
||
&& projection.readyToShip;
|
||
}
|
||
|
||
/**
|
||
* Детерминированная учебная fixture. Она использует Array, Map и Set в
|
||
* одном Node-процессе. Это не broker, database, HTTP service, transaction
|
||
* manager, real order или гарантия exactly-once.
|
||
*/
|
||
export function runDataConsistencyFixture() {
|
||
const ownerAtPaid = cloneTrainingOrder(trainingOrderAtPaid);
|
||
const compensationLedger = new Map();
|
||
const reservationRejection = {
|
||
orderId: ownerAtPaid.id,
|
||
orderVersion: ownerAtPaid.version,
|
||
reason: 'training-reservation-rejected',
|
||
};
|
||
const compensation = createControlledCompensation(ownerAtPaid, compensationLedger, reservationRejection);
|
||
const ownerAfterCompensation = compensation.order;
|
||
const duplicateCompensation = createControlledCompensation(ownerAtPaid, compensationLedger, reservationRejection);
|
||
|
||
const projection = createTrainingProjection(ownerAtPaid.id);
|
||
const cancelledArrivesFirst = applyNextTrainingEvent(projection, compensation.event);
|
||
const paidApplied = applyNextTrainingEvent(projection, trainingOrderPaidEvent);
|
||
const deferredReplayed = replayDeferredTrainingEvents(projection);
|
||
const duplicateCancelled = applyNextTrainingEvent(projection, compensation.event);
|
||
const duplicatePaid = applyNextTrainingEvent(projection, trainingOrderPaidEvent);
|
||
const sameVersionConflict = applyNextTrainingEvent(projection, {
|
||
...compensation.event,
|
||
id: 'evt-order-417-conflicting-v3',
|
||
type: 'training.order.paid',
|
||
});
|
||
|
||
const assertions = {
|
||
fixtureIsInMemoryOnly: compensationLedger instanceof Map
|
||
&& projection.appliedEventIds instanceof Set
|
||
&& projection.deferredByVersion instanceof Map,
|
||
compensationHasOneControlledKey: compensation.state === 'compensation-recorded'
|
||
&& compensationLedger.size === 1
|
||
&& duplicateCompensation.state === 'compensation-already-recorded',
|
||
ownerCreatesNewCompensationState: ownerAtPaid.status === 'paid'
|
||
&& ownerAtPaid.version === 2
|
||
&& ownerAfterCompensation.status === 'cancelled'
|
||
&& ownerAfterCompensation.version === 3,
|
||
outOfOrderEventDoesNotMutateProjection: cancelledArrivesFirst.state === 'deferred-version-gap'
|
||
&& cancelledArrivesFirst.expectedVersion === 2
|
||
&& projection.evidence[0].kind === 'version-gap',
|
||
paidEventAdvancesOneVersion: paidApplied.state === 'event-applied'
|
||
&& paidApplied.version === 2,
|
||
deferredCompensationReplaysAfterGap: deferredReplayed.length === 1
|
||
&& deferredReplayed[0].state === 'event-applied'
|
||
&& deferredReplayed[0].version === 3,
|
||
projectionNeverMovesBackward: projection.version === 3
|
||
&& projection.deferredByVersion.size === 0,
|
||
duplicateEventsCreateNoNewProjectionState: duplicateCancelled.state === 'duplicate-event-suppressed'
|
||
&& duplicatePaid.state === 'duplicate-event-suppressed'
|
||
&& projection.appliedEventIds.size === 2,
|
||
sameVersionConflictDoesNotMutateProjection: sameVersionConflict.state === 'same-version-event-rejected'
|
||
&& projection.state === 'cancelled'
|
||
&& projection.version === 3
|
||
&& projection.appliedEventIds.size === 2
|
||
&& projection.evidence.at(-1).kind === 'same-version-event-rejected',
|
||
cancelledOrderIsNotReadyToShip: projection.state === 'cancelled'
|
||
&& !projection.readyToShip
|
||
&& !shippingIsAllowed(ownerAfterCompensation, projection),
|
||
};
|
||
|
||
if (!Object.values(assertions).every(Boolean)) {
|
||
throw new Error('training data-consistency fixture violated a documented invariant');
|
||
}
|
||
|
||
return {
|
||
model: 'deterministic in-memory owner, event consumer and projection; not a broker, database, service or delivery guarantee',
|
||
ownerAtPaid,
|
||
ownerAfterCompensation,
|
||
reservationRejection,
|
||
deliveries: {
|
||
cancelledArrivesFirst,
|
||
paidApplied,
|
||
deferredReplayed,
|
||
duplicateCancelled,
|
||
duplicatePaid,
|
||
sameVersionConflict,
|
||
},
|
||
compensationLedger: [...compensationLedger.entries()].map(([key, value]) => ({
|
||
key,
|
||
order: value.order,
|
||
event: value.event,
|
||
})),
|
||
projection: projectionSnapshot(projection),
|
||
assertions,
|
||
};
|
||
}
|
||
|
||
const eventEnvelopeCode = [
|
||
'// Учебный конверт. Это не подключение к broker и не полная реализация CloudEvents.',
|
||
'const orderPaid = {',
|
||
" id: 'evt-order-417-paid-v2',",
|
||
" source: 'training/order-service',",
|
||
" type: 'training.order.paid',",
|
||
" subject: 'order-417',",
|
||
' orderVersion: 2,',
|
||
'};',
|
||
'',
|
||
'// source + id различает доставку, orderVersion — состояние одного owner.',
|
||
].join('\n');
|
||
|
||
const ownerContractCode = [
|
||
'const ownerContract = {',
|
||
" orderId: 'order-417',",
|
||
" owner: 'order-service',",
|
||
" state: 'paid',",
|
||
' version: 2,',
|
||
" shippingRule: 'only after matching reservation evidence',",
|
||
'};',
|
||
'',
|
||
'// Projection может временно отставать, но не может называть себя readyToShip без evidence.',
|
||
].join('\n');
|
||
|
||
const projectionGuardCode = [
|
||
'function applyEvent(projection, event) {',
|
||
' if (event.orderVersion > projection.version + 1) {',
|
||
" return { state: 'deferred-version-gap', expected: projection.version + 1 };",
|
||
' }',
|
||
' if (event.orderVersion === projection.version) {',
|
||
" return { state: 'same-version-event-rejected' };",
|
||
' }',
|
||
' if (event.orderVersion < projection.version) {',
|
||
" return { state: 'stale-event-ignored' };",
|
||
' }',
|
||
' projection.version = event.orderVersion;',
|
||
" projection.state = event.type === 'training.order.cancelled'",
|
||
" ? 'cancelled'",
|
||
" : 'awaiting-reservation';",
|
||
' return { state: ' + "'event-applied'" + ' };',
|
||
'}',
|
||
].join('\n');
|
||
|
||
const compensationCode = [
|
||
'function compensatePaidOrder(order, rejection, ledger) {',
|
||
" const key = 'compensation:' + order.id + ':reservation:v' + order.version;",
|
||
' if (ledger.has(key)) return { state: ' + "'already-recorded'" + ', key };',
|
||
" if (order.status !== 'paid' || rejection.reason !== 'training-reservation-rejected') {",
|
||
" return { state: 'manual-review' };",
|
||
' }',
|
||
" const next = { ...order, status: 'cancelled', version: order.version + 1 };",
|
||
' ledger.set(key, next);',
|
||
" return { state: 'compensation-recorded', order: next, key };",
|
||
'}',
|
||
].join('\n');
|
||
|
||
const sqlBoundaryCode = [
|
||
'-- Локальная граница одной базы. Не делает две базы атомарными.',
|
||
'BEGIN;',
|
||
"UPDATE orders SET status = 'cancelled', version = version + 1",
|
||
"WHERE id = 'order-417' AND status = 'paid' AND version = 2;",
|
||
'',
|
||
'INSERT INTO compensation_ledger (compensation_key, order_id, order_version)',
|
||
"VALUES ('compensation:order-417:reservation:v2', 'order-417', 2)",
|
||
'ON CONFLICT (compensation_key) DO NOTHING;',
|
||
'COMMIT;',
|
||
].join('\n');
|
||
|
||
const fixtureCode = [
|
||
'const fixture = runDataConsistencyFixture();',
|
||
'if (!Object.values(fixture.assertions).every(Boolean)) {',
|
||
" throw new Error('training consistency contract failed');",
|
||
'}',
|
||
'',
|
||
'console.log(fixture.deliveries);',
|
||
"// v3 сначала defer, v2 применяется, затем v3 replay; duplicate не меняет projection.",
|
||
].join('\n');
|
||
|
||
const evidenceCode = [
|
||
'const evidencePacket = {',
|
||
" orderId: 'order-417',",
|
||
" owner: { state: 'cancelled', version: 3 },",
|
||
" projection: { state: 'awaiting-reservation', version: 2 },",
|
||
" delayedEvent: 'evt-order-417-cancelled-v3',",
|
||
" compensationKey: 'compensation:order-417:reservation:v2',",
|
||
'};',
|
||
'',
|
||
'// Такой пакет позволяет проверять gap, а не угадывать по одному статусу.',
|
||
].join('\n');
|
||
|
||
const diagnosisCode = [
|
||
'function chooseTrainingAction(packet) {',
|
||
' if (packet.owner.version > packet.projection.version) {',
|
||
" return { action: 'find-or-replay-missing-version', automaticMutation: false };",
|
||
' }',
|
||
' if (packet.owner.state === ' + "'cancelled'" + ' && packet.projection.readyToShip) {',
|
||
" return { action: 'block-shipping-and-check-owner-evidence' };",
|
||
' }',
|
||
" return { action: 'compare-event-id-and-compensation-key' };",
|
||
'}',
|
||
].join('\n');
|
||
|
||
const reconciliationCode = [
|
||
'function mayShip(owner, projection) {',
|
||
" return owner.state === 'paid'",
|
||
" && projection.state === 'reserved'",
|
||
' && owner.version === projection.version',
|
||
' && projection.reservationEvidence === true;',
|
||
'}',
|
||
'',
|
||
'// Равенство статусов без версии и evidence не является разрешением на действие.',
|
||
].join('\n');
|
||
|
||
const practiceArticle = createRevision(
|
||
{
|
||
slug: 'editorial-2021-04-practice-data-consistency',
|
||
title: 'Согласованность данных между сервисами: начать с владельца заказа и инварианта',
|
||
categories: ['Архитектура', 'Данные', 'Практика'],
|
||
cover: '/assets/editorial/2021/data-consistency-state-machine-2021.svg',
|
||
excerpt: 'Если один сервис уже отменил заказ, а второй всё ещё ждёт резерв, проблема не в красивом названии eventual consistency. Нужны owner, версия, инвариант следующего действия и доказательство для отстающей проекции.',
|
||
readingMinutes: 15,
|
||
},
|
||
[
|
||
paragraph('Симптом простой: сервис заказов уже показывает <code>cancelled v3</code>, а сервис исполнения ещё хранит <code>awaiting-reservation v2</code>. Оба говорят об одном <code>order-417</code>, но в разных состояниях. Цена ошибки появляется, когда второй сервис делает следующий шаг по своему старому значению: готовит отгрузку, повторно просит резерв или сообщает пользователю не тот результат. Если сравнивать только строки статуса, спор быстро превращается в поиск «плохого сервиса». Нужны факты: кто владеет состоянием, какая версия уже принята и какое действие запрещено до сверки.'),
|
||
paragraph('В апреле 2021 года я бы не начинал с общей транзакции между двумя приложениями. Сначала фиксируется один owner для заказа. Он меняет доменное состояние и создаёт след изменения. Второй сервис строит свою проекцию и обязан показывать её как проекцию, а не как независимую истину. В этом тексте <code>order-service</code> владеет состоянием заказа, а <code>fulfillment</code> владеет только локальным состоянием исполнения. Все id, версии и деньги ниже учебные; fixture работает в памяти Node и не запускает broker, базу, реальные сервисы или заказы.'),
|
||
heading('Состояние начинается с владельца, а не с таблицы статусов'),
|
||
paragraph('Owner отвечает на вопрос, кто имеет право принять следующее доменное решение. Для <code>order-417</code> это сервис заказа: он может перевести <code>paid v2</code> в <code>cancelled v3</code>, если получил подтверждённую учебную причину отказа резерва. Сервис исполнения не переписывает заказ задним числом. Он принимает событие, хранит последнюю применённую версию и решает, можно ли выполнять свою локальную работу. Такая граница не делает данные мгновенно одинаковыми. Она делает различие объяснимым: известно, какая запись является источником решения и какая должна догнать её.'),
|
||
dataTable(
|
||
'Контракт одного заказа на границе двух учебных сервисов',
|
||
['Факт', 'Владелец', 'Доказательство', 'Что может сделать другой сервис'],
|
||
[
|
||
['<code>paid v2</code>', '<code>order-service</code>', 'order id, version 2, event id', 'построить проекцию <code>awaiting-reservation</code>, но не считать заказ отгруженным'],
|
||
['отказ учебного резерва', 'решение owner-а по входному reason', 'order id, исходная version, compensation key', 'передать evidence; не отменять заказ напрямую'],
|
||
['<code>cancelled v3</code>', '<code>order-service</code>', 'новая version и событие отмены', 'перевести свою проекцию в <code>cancelled</code> после применения gap-free версии'],
|
||
['локальная готовность к отгрузке', '<code>fulfillment</code>', 'совпавшая version и evidence резерва', 'разрешить следующий шаг только по этому локальному контракту'],
|
||
['временное расхождение', 'никто не «владеет» задержкой', 'owner version больше projection version', 'искать недостающую версию или отложенное событие, а не перетирать статус'],
|
||
],
|
||
),
|
||
paragraph('В таблице намеренно нет строки «все сервисы согласованы». Это не полезное состояние для проверки. Полезнее сформулировать инвариант действия: <strong>заказ нельзя отдавать на отгрузку, пока проекция исполнения не применит ту же версию owner-а и не имеет явного evidence успешного резерва</strong>. При <code>paid v2</code> у owner-а и <code>accepted v1</code> у projection расхождение допустимо, но <code>readyToShip</code> остаётся ложным. При <code>cancelled v3</code> оно также остаётся ложным. Так eventual consistency получает границу: разница версий допустима только пока не запускается необратимое или дорогое действие.'),
|
||
heading('Событие переносит наблюдение, а не владение'),
|
||
paragraph('Для сообщения достаточно назвать, о каком объекте и какой его версии идёт речь. Я использую <code>id</code>, <code>source</code>, <code>type</code>, <code>subject</code> и <code>orderVersion</code>. Первые четыре поля похожи на словарь CloudEvents 1.0.1, который уже существовал к апрелю 2021 года. Это не означает, что объект ниже совместим со всеми transport binding или что его можно отправить в выбранный broker без адаптера. Здесь он нужен, чтобы consumer мог объяснить, откуда пришёл факт, а не угадывать состояние по payload.'),
|
||
codeBlock(eventEnvelopeCode),
|
||
paragraph('Важно не смешать два ключа. <code>source + id</code> отвечает на вопрос о доставке конкретного сообщения: этот экземпляр уже применён или пришёл повторно. <code>orderVersion</code> отвечает на вопрос о последовательности состояния одного заказа. Два разных события могут иметь разные id, но быть неправильными для текущей проекции, если версия пропущена. И наоборот, один и тот же event id не описывает сам по себе доменный эффект. Такое разделение продолжает мартовскую тему про duplicate: ключ доставки нельзя выдавать за доказательство согласованности всей модели.'),
|
||
heading('Инвариант должен запрещать следующий шаг'),
|
||
paragraph('Фраза «данные в итоге сойдутся» не говорит обработчику, что делать сейчас. Инвариант должен быть проверяем в момент действия. В нашем учебном контракте <code>fulfillment</code> не может поставить <code>readyToShip</code>, если owner не находится в <code>paid</code>, версия не совпадает или нет evidence резерва. Это намеренно уже, чем «все поля одинаковы». Сервис исполнения может хранить свою полезную информацию: номер склада, попытку резерва, локальную ошибку. Но она не даёт права изменить owner state и не заменяет факт версии.'),
|
||
codeBlock(ownerContractCode),
|
||
paragraph('Проверка не требует распределённого lock. До следующего действия consumer читает собственную проекцию и сохранённый пакет evidence. Если она отстала, действие блокируется и запускается диагностический маршрут: найти missing event, дочитать owner или отправить запись на ручную проверку. Какая именно операция допустима, зависит от предметной области. В учебном примере это отгрузка; в другом месте это может быть письмо, выдача доступа или создание счёта. Общий только принцип: действие опирается на факты своего owner-а и явно заданную версию, а не на удачное совпадение текста статуса.'),
|
||
figure(
|
||
'/assets/editorial/2021/data-consistency-state-machine-2021.svg',
|
||
'Вертикальная схема состояния заказа: owner хранит paid версии 2, затем по контролируемому отказу создаёт cancelled версии 3; fulfillment сначала ждёт версию 2, откладывает пришедшую раньше версию 3, применяет версии по порядку и не разрешает отгрузку',
|
||
'Расхождение версий здесь видно как состояние ожидания, а не скрытая ошибка: consumer откладывает v3 до v2 и не получает права на следующий шаг.',
|
||
),
|
||
heading('Граница eventual consistency — это известное ожидание'),
|
||
paragraph('Слова eventual consistency полезны только после трёх уточнений. Первое: какая запись уже считается решением owner-а. Второе: какой consumer может временно не успеть за ней. Третье: какое действие запрещено в это окно. В примере owner уже записал <code>cancelled v3</code>, а projection ещё содержит <code>awaiting-reservation v2</code>. Это не повод подменять v2 вручную строкой <code>cancelled</code>: исчезнет evidence о том, что v3 была отложена из-за gap. Правильное действие — зафиксировать version gap, дождаться или найти v2, затем применить v3.'),
|
||
paragraph('Эта дисциплина особенно важна, когда сервисы ведут разные счётчики. Один счётчик может означать «оплата принята», второй — «резерв подтверждён». Они не обязаны совпадать в каждый момент и не обязаны иметь одинаковые имена. Ошибка начинается, если отчёт или обработчик складывает их как одно поле «заказы готовы». Для отчёта нужен владелец метрики и условие включения. Для команды нужен маленький список states, которые нельзя использовать как разрешение на действие без version и evidence.'),
|
||
heading('Локальная транзакция остаётся локальной'),
|
||
paragraph('PostgreSQL 13 описывает изоляцию и serialization failure внутри одной базы. Эта гарантия полезна, когда owner обновляет свой заказ и ledger компенсации в одном хранилище. Но она не переносится автоматически на отдельный consumer или broker. Например, уникальный <code>compensation_key</code> может сделать повтор локальной записи безопасным для одной таблицы; он не доказывает, что другое приложение получило событие или отменило внешний резерв. Поэтому SQL ниже показывает только границу одного owner-а.'),
|
||
codeBlock(sqlBoundaryCode),
|
||
paragraph('Если два изменения действительно должны быть атомарны, они должны находиться в одной описанной транзакционной границе. Если они находятся в разных сервисах, вместо ложного обещания атомарности нужны версия, источник события, отдельное повторяемое действие и компенсирующее решение владельца. Такой путь не «чинит сеть». Он делает видимым, какая часть уже подтверждена, какая ещё ждёт и что будет сделано, если внешнее условие не выполнилось.'),
|
||
heading('Короткий маршрут внедрения контракта'),
|
||
orderedList([
|
||
'Выбрать один owner для каждого доменного состояния. Записать, какой сервис имеет право менять его и какая запись считается доказательством.',
|
||
'Назвать следующее рискованное действие: отгрузка, доступ, письмо или счёт. Сформулировать для него инвариант с state, version и evidence.',
|
||
'Добавить к изменению owner-а стабильный event id, source, type, subject и версию объекта. Не выдавать этот конверт за готовый transport protocol.',
|
||
'В consumer хранить последнюю применённую версию и отдельную отметку уже обработанного event id. Version gap не применять «как получится».',
|
||
'Определить, какое условие создаёт compensation request, кто принимает это решение и какой key защищает повтор именно этого решения.',
|
||
'Собрать in-memory fixture с duplicate и gap, затем отдельно проверить выбранную базу, broker и внешнее действие в интеграционном окружении.',
|
||
'Отдельно описать, что видит пользователь и оператор во время gap. Статус без version и owner-а не должен быть единственным evidence.',
|
||
]),
|
||
heading('Источники ограничивают обещание'),
|
||
paragraph('CloudEvents даёт словарь контекстных атрибутов события, PostgreSQL 13 — конкретную семантику локальной изоляции и <code>INSERT ... ON CONFLICT</code>, а Kafka 2.7 прямо ограничивает область идемпотентности producer одной session и предупреждает о прикладных resend. Эти источники полезны не как готовый рецепт для текущего кода, а как границы формулировок. Ни один из них не превращает Array и Map из fixture в распределённую платформу и не обещает exactly-once для бизнес-эффекта.'),
|
||
paragraph('В этой статье не запускались database, message broker, HTTP, сторонний резерв, реальный сервис, browser, CI или production build. Нет измеренной задержки, реальных заказов и данных пользователей. Следующий проверяемый шаг — на одном тестовом заказе собрать owner version, event id, состояние projection и compensation key. Если по этим четырём фактам нельзя объяснить расхождение, сначала уточняем контракт, а не добавляем ещё один retry.'),
|
||
],
|
||
[cloudEvents101, postgres13Isolation, postgres13Insert, kafka27Producer],
|
||
);
|
||
|
||
const mechanismArticle = createRevision(
|
||
{
|
||
slug: 'editorial-2021-04-mechanism-data-consistency',
|
||
title: 'Согласованность между сервисами: почему event id не заменяет версию и компенсацию',
|
||
categories: ['Backend', 'Архитектура', 'Надёжность'],
|
||
cover: '/assets/editorial/2021/data-consistency-compensation-2021.svg',
|
||
excerpt: 'Повтор сообщения, пропущенная версия и отказ действия — три разные причины расхождения. Разбираем учебную state machine: consumer применяет версии по порядку, owner выпускает новое компенсирующее решение, а duplicate не создаёт второй state change.',
|
||
readingMinutes: 16,
|
||
},
|
||
[
|
||
paragraph('Симптом механизма выглядит как «случайный» порядок: consumer сначала получает <code>OrderCancelled v3</code>, потом <code>OrderPaid v2</code>, а затем ещё один из этих event. Цена прямого применения первого входа — проекция перескакивает через факт, который объясняет отмену. Цена игнорирования версии — позднее <code>v2</code> может вернуть состояние назад. Цена повторного вызова компенсации — два независимых решения по одному отказу. Слова duplicate, out-of-order и failed action описывают разные границы; одна проверка их не закрывает.'),
|
||
paragraph('Ниже я держу модель предельно маленькой. Owner уже имеет <code>paid v2</code>. Контролируемый учебный отказ резерва создаёт у него новое решение <code>cancelled v3</code> и один <code>compensationKey</code>. Затем отдельный consumer получает v3 раньше v2, откладывает её, применяет v2 и replay-ит отложенную v3. Повтор v2 или v3 не создаёт нового состояния. Это детерминированный Node-модуль с Map и Set. Он не реализует broker, database, HTTP, outbox, distributed transaction, реальный reserve API или exactly-once delivery.'),
|
||
heading('Три ключа отвечают на три разных вопроса'),
|
||
paragraph('Первый ключ — <code>eventKey = source + id</code>. Он нужен consumer-у, чтобы отличить уже применённую доставку от повтора того же конверта. Второй ключ — <code>orderVersion</code>. Он принадлежит owner-у одного заказа и говорит, какой переход должен быть следующим. Третий ключ — <code>compensationKey</code>. Он принадлежит одному компенсирующему решению, например «отменить paid v2 после подтверждённого отказа резерва». Если использовать один id для всех трёх задач, код станет короче только до первого повторного сообщения или повторной команды.'),
|
||
dataTable(
|
||
'Ключи учебной модели и их пределы',
|
||
['Ключ', 'Что защищает', 'Где хранится в модели', 'Чего не гарантирует'],
|
||
[
|
||
['<code>source + id</code>', 'повтор одного event', 'set appliedEventIds consumer-а', 'порядок разных событий и бизнес-эффект'],
|
||
['<code>orderVersion</code>', 'переходы одного owner state', 'owner и projection', 'глобальный порядок всех заказов или всех topic'],
|
||
['<code>compensationKey</code>', 'повтор одного решения owner-а', 'ledger owner-а', 'автоматическое удаление внешнего действия'],
|
||
['<code>reservationEvidence</code>', 'разрешение локального шага', 'projection исполнения', 'что owner всё ещё в том же state без сверки версии'],
|
||
['<code>reason</code>', 'основание компенсации', 'вход решения owner-а', 'что любой unknown failure можно безопасно компенсировать'],
|
||
],
|
||
),
|
||
paragraph('Стабильный id полезен, но сам по себе не делает обработчик идемпотентным в доменном смысле. Kafka 2.7 описывает idempotent producer отдельно и подчёркивает, что прикладные повторные отправки остаются отдельной проблемой. Поэтому я не называю set event id решением «ровно один раз». В fixture он только запрещает второй переход проекции по тому же event. Внешний платёж, письмо или резерв требуют своего effect key, владельца и доказательства результата. Это продолжение февральского и мартовского контрактов: key полезен только в той границе, где его реально проверяют.'),
|
||
heading('Gap не нужно превращать в отмену'),
|
||
paragraph('Когда consumer с текущей версией 1 видит <code>cancelled v3</code>, у него нет права сразу сделать вывод, что v2 неважна. Возможно, v2 содержит обязательное решение, schema change или причину последующей компенсации. В учебной модели v3 попадает в <code>deferredByVersion</code> вместе с записью evidence: ожидалась v2, пришла v3. Проекция остаётся на v1. Это важный результат: не было «частично применённой отмены», которую потом придётся угадывать при разборе.'),
|
||
codeBlock(projectionGuardCode),
|
||
paragraph('После доставки <code>paid v2</code> consumer применяет ровно следующий переход: <code>accepted v1 → awaiting-reservation v2</code>. Затем функция replay берёт v3 из отложенной коллекции и применяет её: <code>awaiting-reservation v2 → cancelled v3</code>. В этом месте порядок не «исправлен брокером». Его проверяет контракт projection. Если v2 так и не найдена, consumer не имеет автоматического перехода и должен удержать evidence либо направить запись в ручный маршрут. TTL, дополнительный retry или случайная сортировка payload не дают доказательства, что пропуск безопасен.'),
|
||
figure(
|
||
'/assets/editorial/2021/data-consistency-compensation-2021.svg',
|
||
'Вертикальная схема компенсации: owner paid версии 2 получает проверенный учебный отказ резерва, записывает один compensation key и создаёт cancelled версии 3; consumer откладывает v3 при gap, применяет v2, затем replay-ит v3, а повтор события не меняет проекцию',
|
||
'Компенсация показана как новое решение owner-а с собственной версией, а не как отмена уже разосланного факта во всех сервисах.',
|
||
),
|
||
heading('Компенсация — новое доменное решение'),
|
||
paragraph('Слово «компенсация» легко создаёт ложное впечатление, что можно отменить всю распределённую операцию одним rollback. Здесь оно означает другое: owner получает проверяемый reason, проверяет исходную версию и создаёт следующий state. <code>paid v2</code> не исчезает из истории; после него появляется <code>cancelled v3</code> с причиной и ключом решения. Consumer не должен самостоятельно получить техническое исключение и поставить owner-у отмену. Иначе неизвестно, кто подтвердил причину, как выбран повтор и почему два consumer-а не создали две отмены.'),
|
||
codeBlock(compensationCode),
|
||
paragraph('В функции есть узкое условие: известен именно <code>training-reservation-rejected</code> и version отказа совпадает с owner version. Для unknown timeout это плохой автоматический путь. Он может означать, что внешний резерв уже создан, но ответ потерян. В таком случае нужны отдельные evidence и ручное или явно описанное сверочное действие. Уменьшить правила до «любая ошибка отменяет заказ» можно только ценой новых ложных отмен. Поэтому controlled compensation сначала ограничивает вход, а уже потом меняет state.'),
|
||
heading('Локальная запись может быть повторяемой, но не распределённой'),
|
||
paragraph('В одном PostgreSQL-хранилище можно сочетать update owner-а с записью compensation ledger и уникальным <code>compensation_key</code>. <code>INSERT ... ON CONFLICT</code> задаёт поведение при конфликте уникального ограничения, а transaction isolation — границу конкурентной работы этой базы. Это полезный строительный блок. Он не даёт одной командой атомарно изменить другую базу, отправить message и отменить внешний резерв. Такой вывод важнее красивой диаграммы: каждая надежда должна быть привязана к месту, где она действительно проверяется.'),
|
||
codeBlock(sqlBoundaryCode),
|
||
paragraph('Если запись owner-а повторилась, ledger не позволяет создать вторую такую компенсацию. Если доставка <code>cancelled v3</code> повторилась, consumer set не применяет второй раз тот же event. Если пришла другая v3 с тем же <code>orderVersion</code>, это уже не duplicate того же конверта, а конфликт контракта: fixture возвращает <code>same-version-event-rejected</code>, оставляет projection на <code>cancelled v3</code> и записывает evidence. Реальная система всё равно должна иметь owner, правило разбора и отдельный test для такого конфликта. Важно не спрятать нерешённый случай за названием <code>ON CONFLICT</code>.'),
|
||
heading('Как fixture проверяет инвариант без инфраструктуры'),
|
||
paragraph('Fixture сначала создаёт <code>cancelled v3</code> через <code>createControlledCompensation()</code>; второй вызов с тем же входом получает <code>compensation-already-recorded</code>. Затем projection получает v3 первой, фиксирует gap и не меняет свой version. После v2 она применяет отложенную v3. В конце повторные v3 и v2 подавляются, а другая v3 отклоняется как same-version conflict; <code>shippingIsAllowed</code> возвращает <code>false</code>, потому что owner и projection находятся в cancelled state. Это не integration test, зато набор проверок делает условие изменения видимым.'),
|
||
codeBlock(fixtureCode),
|
||
paragraph('Здесь полезно заметить предел самого инварианта. Он говорит, что данная учебная projection не разрешает отгрузку после отмены и не двигается назад по version. Он не говорит, что любой настоящий сервис увидит события в этом порядке, что message никогда не пропадёт или что внешний резерв уже освобождён. После изменения fixture нужно тестировать реальные точки: запись owner-а, publish path, consumer storage, retry policy и внешний effect. Модель не заменяет эти проверки, но помогает сформулировать, чего именно от них ждать.'),
|
||
heading('Маршрут механизма без лишних обещаний'),
|
||
orderedList([
|
||
'Выписать state, которым владеет один сервис, и последовательную версию этого state. Не строить version из времени получения сообщения.',
|
||
'Отделить event id для duplicate от version объекта. Для обоих назвать место хранения и период жизни.',
|
||
'При version gap сохранять evidence и не менять projection до следующей допустимой версии. Явно решить, сколько и где она ждёт.',
|
||
'Определить узкий набор причин, для которых owner создаёт compensation. Unknown failure не переводить автоматически в отмену.',
|
||
'Защитить одно решение compensation key в локальной границе owner-а; отдельно защитить consumer от повторного event.',
|
||
'Проверить запрет рискованного действия: без равной версии и локального evidence оно не должно становиться доступным.',
|
||
'Только затем соединить этот контракт с конкретными database, broker и внешними API и написать интеграционные tests на их реальные ошибки.',
|
||
]),
|
||
heading('Историческая рамка и ограничения'),
|
||
paragraph('CloudEvents 1.0.1 позволяет говорить о контексте события без привязки к одному transport. PostgreSQL 13 даёт точную модель локальной транзакции, а Kafka 2.7 показывает, что даже producer idempotence имеет отдельные предпосылки и границы session. Эти документы существовали к апрелю 2021 года, но не создают один общий стандарт «согласованности между сервисами». Transactional outbox, saga и compensating action часто используются как названия паттернов; здесь они не выданы за единый протокол и не приписаны учебному коду как готовая platform capability.'),
|
||
paragraph('В пакете не запускались PostgreSQL, Kafka, queue, HTTP, внешний reserve API, два процесса, browser, CI, production build или deployment. Нет реального order, измеренной задержки и claims о стабильной distributed platform. Следующий шаг — выбрать одну реальную границу хранения и доказать в ней повторяемость owner update и ledger. После этого отдельно проверить, что consumer видит version gap как факт, а не как разрешение на произвольный rollback.'),
|
||
],
|
||
[cloudEvents101, postgres13Isolation, postgres13Insert, kafka27Producer],
|
||
);
|
||
|
||
const fieldArticle = createRevision(
|
||
{
|
||
slug: 'editorial-2021-04-field-data-consistency',
|
||
title: 'Разбор расхождения данных: как собрать evidence до компенсации между сервисами',
|
||
categories: ['Отладка', 'Данные', 'Архитектура'],
|
||
cover: '/assets/editorial/2021/data-consistency-diagnosis-2021.svg',
|
||
excerpt: 'Owner уже отменил заказ, а projection ещё ждёт резерв — это не повод вручную синхронизировать статусы. Нужен небольшой evidence packet: owner version, event id, версия projection, компенсационный ключ и запрет на следующий рискованный шаг.',
|
||
readingMinutes: 15,
|
||
},
|
||
[
|
||
paragraph('Симптом в поле обычно приходит коротким сообщением: «заказ отменён здесь, но всё ещё висит там». Цена ручного исправления тоже короткая: оператор меняет статус во второй системе, повторяет consumer, а потом не может объяснить, какой event был пропущен и успела ли компенсация сработать. Если рядом находится действие вроде отгрузки, доступа или возврата, такой разбор может создать второй эффект. Начинать нужно не с кнопки replay и не с общего вопроса «почему данные не синхронны», а с пакета evidence для одного object id.'),
|
||
paragraph('Возьмём учебный <code>order-417</code>. Owner <code>order-service</code> уже записал <code>cancelled v3</code>. Projection <code>fulfillment</code> пока хранит <code>awaiting-reservation v2</code>. Такое окно возможно, если v3 была получена раньше v2 и consumer корректно отложил её, либо если нужная доставка ещё не применена. В этой статье нет production incident и настоящих заказов: значения созданы fixture в памяти. Но порядок доказательств переносится в любой стек, потому что он не зависит от названия очереди или базы.'),
|
||
heading('Evidence packet должен уместиться в одну проверку'),
|
||
paragraph('Для первого разбора достаточно пяти связанных фактов. <code>orderId</code> связывает все записи. Owner state и version отвечают, какое решение уже принято. Projection state и version показывают, какую часть истории видел consumer. Event id и source позволяют проверить конкретную доставку. Compensation key говорит, было ли уже принято повторяемое решение по отказу. Если в пакете есть только два статуса, его нельзя отличить от разных объектов, старого snapshot или повторного message. Если в пакете нет версии, невозможно понять, что именно consumer должен догнать.'),
|
||
codeBlock(evidenceCode),
|
||
dataTable(
|
||
'Минимальная диагностика расхождения одного заказа',
|
||
['Наблюдение', 'Факт, который ищем', 'Безопасное действие', 'Что не делать'],
|
||
[
|
||
['owner v3, projection v2', 'есть ли event v3 и причина его задержки', 'сохранить gap и найти v3 delivery или replay по контракту', 'переписать projection строкой <code>cancelled</code>'],
|
||
['v3 пришла раньше v2', 'ожидалась ли v2 и сохранён ли deferred event', 'применить v2, затем replay v3', 'применить v3 поверх v1 без проверки'],
|
||
['один event id виден дважды', 'есть ли он в consumer ledger', 'подавить повтор и проверить отсутствие нового state transition', 'считать duplicate новой компенсацией'],
|
||
['другой event имеет уже занятую version', 'совпадают ли source, id и rule перехода', 'отклонить конфликт, сохранить evidence и передать в owner route', 'применить второй event по времени получения'],
|
||
['compensation key уже есть', 'совпадают ли order id, version и reason', 'показать существующее решение owner-а', 'создать вторую отмену по тому же входу'],
|
||
['owner cancelled, projection readyToShip', 'равны ли version и evidence резерва', 'сразу блокировать следующий шаг и собрать packet', 'доверять одному локальному флагу готовности'],
|
||
],
|
||
),
|
||
paragraph('Последняя строка — важная защита от «согласовали позже». Если owner уже отменил заказ, а projection готова к отгрузке, сначала блокируется рискованное действие. Затем собираются версии и event ids. Ручной update projection может быть частью утверждённого восстановления, но только после того, как сохранено доказательство, почему нормальный consumer не принёс этот state. Иначе следующий разбор начнётся с ещё более бедных данных: прежний gap уже будет затёрт.'),
|
||
heading('Сначала отличаем gap от другой причины'),
|
||
paragraph('Расхождение статусов не всегда означает задержку события. Projection может читать другой object id, отфильтровать событие по schema, записать version, но не записать локальный effect, или получить event из другого source. Поэтому diagnosis начинается с version sequence. Если owner version больше projection version, есть недостающий переход или отложенный вход. Если версии равны, но states противоречат, ищем правило трансформации и effect evidence; второй event с занятой version не применяем по времени получения. Если event id уже отмечен применённым, но projection не менялась, это отдельный дефект consumer-а: возможно, ledger записан раньше effect. Называть все три случая «eventual consistency» означает потерять следующий точный вопрос.'),
|
||
figure(
|
||
'/assets/editorial/2021/data-consistency-diagnosis-2021.svg',
|
||
'Вертикальная схема диагностики: от разных статусов одного order id через сбор owner версии, projection версии, event id и compensation key; version gap ведёт к поиску или контролируемому replay, конфликт одинаковой версии — к проверке трансформации, а рискованный следующий шаг блокируется до evidence',
|
||
'Диагностика держит порядок: сначала блокируем риск, затем сохраняем evidence, только после этого выбираем replay, correction или ручное решение.',
|
||
),
|
||
paragraph('Для <code>v3 раньше v2</code> безопасная реакция не «подождать немного». Consumer сохраняет v3 как deferred вместе с expected version 2. Это уже evidence: вход не исчез и не был принят как конечное состояние. После v2 можно replay-ить v3 и проверить, что version монотонно выросла. Если v2 не приходит по правилам вашего transport, нужен отдельный контракт fetch или manual route. Учебный модуль не выбирает между ними, потому что не запускает broker. Он показывает только, что пропуск нельзя замаскировать новым status update.'),
|
||
heading('Компенсация требует причины и версии'),
|
||
paragraph('В нашем примере <code>cancelled v3</code> возникает не от любого сбоя. Owner получает <code>training-reservation-rejected</code>, проверяет <code>orderId</code> и исходную version 2, затем записывает одну compensation запись. Это новое доменное решение, а не удаление <code>paid v2</code> из истории. Если внешний вызов вернул timeout, такого evidence недостаточно: резерв мог успеть состояться. Тогда автоматическая отмена будет предположением, которое может противоречить внешнему состоянию. В field-разборе это правило легко проверить: у причины есть тип, у входа есть версия, у решения есть ключ.'),
|
||
codeBlock(compensationCode),
|
||
paragraph('Повтор compensation key сам по себе не доказывает, что внешний эффект отменён. Он доказывает только, что owner второй раз не создал такое же решение в своей boundary. Дальше смотрим, кто владеет внешним действием, как он принимает отмену и какое evidence возвращает. В учебной fixture этого механизма нет. Поэтому карточка разборщика не должна закрываться текстом «компенсация запущена». Ей нужен следующий факт: новое owner state, событие версии 3, состояние consumer-а и отдельный результат внешнего шага, если он вообще был частью задачи.'),
|
||
heading('Duplicate и stale — не повод чистить ledger'),
|
||
paragraph('Повтор <code>evt-order-417-cancelled-v3</code> после его применения должен дать <code>duplicate-event-suppressed</code>. Повтор v2 после v3 тоже не должен возвращать projection назад. Другой event с уже занятой version должен дать <code>same-version-event-rejected</code>, а не переписать state. В fixture эти случаи проверяются отдельно, потому что duplicate относится к event id, stale — к меньшей version, а совпавшая version с другим event — к конфликту контракта. В реальном consumer хранение ledger и самой projection должно иметь свой транзакционный или иной проверяемый контракт. Если сначала пометить event applied, а потом потерять update проекции, появится сложный случай «ledger говорит да, состояние говорит нет». Его не исправляет удаление ledger без разбора: можно снова выполнить уже совершённый effect.'),
|
||
codeBlock(diagnosisCode),
|
||
paragraph('Функция не выполняет replay и не меняет данные. Она выбирает следующую проверку. Это полезнее автоматической команды в момент нехватки evidence. Когда owner version больше projection version, сначала ищем или контролируемо воспроизводим недостающий переход. Когда owner cancelled, а projection хочет продолжать, сначала блокируем действие. Когда версии равны, сравниваем event id, transform rule и compensation key. Так у каждого исхода появляется конкретный владелец следующего шага, а не один общий канал с надписью «синхронизация».'),
|
||
heading('Проверяем разрешение на следующее действие'),
|
||
paragraph('Инвариант поля должен быть виден не только в диаграмме. Перед отгрузкой, доступом или другим эффектом projection сравнивает owner state, свою version и локальное evidence. Равенство двух слов <code>paid</code> ничего не гарантирует: версия могла измениться между чтениями, резерв мог быть отменён, а projection могла быть построена из другого source. В учебном контракте разрешение появляется только при <code>paid</code>, равной версии, <code>reserved</code> и явном evidence. После <code>cancelled v3</code> функция возвращает false даже если старый UI-флаг ещё не очищен.'),
|
||
codeBlock(reconciliationCode),
|
||
paragraph('Это не совет внедрять одну универсальную проверку для всех сервисов. В одних доменах действие обратимо и его можно задержать. В других нужен manual decision, компенсация или другой business rule. Но формулировка «какой следующий шаг запрещён без evidence» почти всегда точнее, чем требование мгновенной одинаковости всех копий. Она позволяет объяснить пользователю временный status и не позволяет фоновому consumer-у совершить дорогой шаг только потому, что он увидел старую проекцию.'),
|
||
heading('Маршрут полевого разбора'),
|
||
orderedList([
|
||
'Остановить рискованное следующее действие для одного object id: отгрузку, доступ, списание или другой effect. Не чистить state и ledger до сохранения facts.',
|
||
'Собрать owner state и version, projection state и version, source + event id, compensation key и reason. Зафиксировать, откуда получен каждый факт.',
|
||
'Сравнить версии. При owner > projection искать missing или deferred event; при одинаковых версиях проверять transform rule и local effect evidence, а второй event с той же version не применять автоматически.',
|
||
'Проверить consumer ledger отдельно от projection. Повтор event id и stale version имеют разные причины и не должны иметь одну кнопку удаления.',
|
||
'Проверить компенсацию: известен ли reason, совпадает ли исходная version, есть ли только один key и какое новое owner state создано.',
|
||
'Выбрать действие по контракту: controlled replay после evidence, fetch missing transition, correction с журналом или manual decision. Не повторять unknown external failure вслепую.',
|
||
'После восстановления добавить fixture или интеграционный test именно для найденной границы: gap, duplicate, stale event, ledger/effect split или external uncertainty.',
|
||
]),
|
||
heading('Что проверено источниками, а что остаётся границей'),
|
||
paragraph('CloudEvents 1.0.1 помогает именовать контекст конкретного события, PostgreSQL 13 документирует локальную изоляцию и уникальные conflict paths, Kafka 2.7 ограничивает область producer idempotence. Из этих фактов не следует единая «правильная» реализация compensation. Паттерны data consistency существуют как инженерские приёмы, а не как один стандарт с владельцем. Поэтому статья не обещает, что version + Map гарантируют доставку, и не приписывает апрелю 2021 года зрелость готовой distributed platform.'),
|
||
paragraph('Здесь не выполнялись real database, broker, external reserve, HTTP, browser, CI, production build, deployment или screen reader. Fixture не содержит персональных данных, не измеряет lag и не имитирует реальную очередь. Следующий шаг — взять один тестовый object id и пройти маршрут с фактическими storage и transport: от owner update до ledger consumer-а и запрета следующего effect. Если нужного evidence нет, безопаснее оставить запись в разборе, чем синхронизировать статусы на глаз.'),
|
||
],
|
||
[cloudEvents101, postgres13Isolation, postgres13Insert, kafka27Producer],
|
||
);
|
||
|
||
export const revisions = [practiceArticle, mechanismArticle, fieldArticle]
|
||
.map(({ proseLength, ...revision }) => revision);
|
||
|
||
if (process.argv.includes('--print-revisions')) {
|
||
process.stdout.write(JSON.stringify(revisions));
|
||
} else if (process.argv.includes('--verify-fixture')) {
|
||
process.stdout.write(JSON.stringify(runDataConsistencyFixture(), null, 2) + '\n');
|
||
}
|