service-bridge 1.1.1-dev.29 → 1.1.1-dev.30
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/README.md +192 -0
- package/dist/index.js +454 -56
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -953,6 +953,198 @@ try {
|
|
|
953
953
|
|
|
954
954
|
---
|
|
955
955
|
|
|
956
|
+
## v2 Session API
|
|
957
|
+
|
|
958
|
+
`session_v2.ts` реализует новый Enterprise Session Protocol — Channel-based bidi stream с 8-состоянийным FSM, адаптивным heartbeat и кредитным управлением потоком. Симметричен с Go и Python SDK.
|
|
959
|
+
|
|
960
|
+
### Жизненный цикл сессии (8 состояний FSM)
|
|
961
|
+
|
|
962
|
+
```
|
|
963
|
+
connecting → handshaking → ready ↔ active
|
|
964
|
+
↘ suspended → (reconnect)
|
|
965
|
+
↘ draining → closed
|
|
966
|
+
↘ fenced (permanent)
|
|
967
|
+
```
|
|
968
|
+
|
|
969
|
+
| Состояние | Описание |
|
|
970
|
+
|-----------|----------|
|
|
971
|
+
| `connecting` | Устанавливается TCP/TLS соединение |
|
|
972
|
+
| `handshaking` | Отправлен Hello, ждём HelloAck |
|
|
973
|
+
| `ready` | HelloAck получен, команды не выполняются |
|
|
974
|
+
| `active` | Есть активные команды |
|
|
975
|
+
| `suspended` | Heartbeat пропущен 2+ раза |
|
|
976
|
+
| `draining` | Инициирован graceful shutdown |
|
|
977
|
+
| `fenced` | Сервер прислал GOAWAY_FENCED — сессия закрыта навсегда |
|
|
978
|
+
| `closed` | Соединение закрыто |
|
|
979
|
+
|
|
980
|
+
### Быстрый старт
|
|
981
|
+
|
|
982
|
+
```typescript
|
|
983
|
+
import { V2SessionClient, validateV2Config } from 'service-bridge';
|
|
984
|
+
|
|
985
|
+
const cfg = {
|
|
986
|
+
serverAddress: 'localhost:9090',
|
|
987
|
+
serviceName: 'my-worker',
|
|
988
|
+
instanceId: 'worker-1',
|
|
989
|
+
zone: 'us-east-1a',
|
|
990
|
+
transportMode: 'direct' as const,
|
|
991
|
+
maxInflight: 64,
|
|
992
|
+
};
|
|
993
|
+
|
|
994
|
+
validateV2Config(cfg);
|
|
995
|
+
const session = new V2SessionClient(cfg);
|
|
996
|
+
|
|
997
|
+
// Отправить Hello при подключении
|
|
998
|
+
const hello = session.getHelloFields();
|
|
999
|
+
|
|
1000
|
+
// Обработать HelloAck от сервера
|
|
1001
|
+
session.onHelloAck({
|
|
1002
|
+
sessionId: 'sess-abc',
|
|
1003
|
+
resumeToken: 'token-xyz',
|
|
1004
|
+
epoch: 1n,
|
|
1005
|
+
resumed: false,
|
|
1006
|
+
resumeFromSeq: 0n,
|
|
1007
|
+
replayedCommands: 0,
|
|
1008
|
+
reconciledResults: 0,
|
|
1009
|
+
heartbeatIntervalMs: 10_000,
|
|
1010
|
+
heartbeatTimeoutMs: 30_000,
|
|
1011
|
+
initialPermits: 64,
|
|
1012
|
+
maxPermits: 128,
|
|
1013
|
+
effectiveTransportMode: 'direct',
|
|
1014
|
+
});
|
|
1015
|
+
|
|
1016
|
+
console.log(session.state); // 'ready'
|
|
1017
|
+
|
|
1018
|
+
// Входящая команда
|
|
1019
|
+
const accepted = session.onCommandReceived(1n, 'cmd-001');
|
|
1020
|
+
if (!accepted) {
|
|
1021
|
+
// backpressure — permits = 0
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
// Команда выполнена
|
|
1025
|
+
session.onCommandCompleted(1n, 'cmd-001');
|
|
1026
|
+
```
|
|
1027
|
+
|
|
1028
|
+
### Адаптивный heartbeat (EWMA RTT)
|
|
1029
|
+
|
|
1030
|
+
```typescript
|
|
1031
|
+
import { AdaptiveHeartbeatV2 } from 'service-bridge';
|
|
1032
|
+
|
|
1033
|
+
const hb = new AdaptiveHeartbeatV2(10_000, 30_000);
|
|
1034
|
+
|
|
1035
|
+
// Получен pong
|
|
1036
|
+
hb.onPong(25); // rttMs
|
|
1037
|
+
|
|
1038
|
+
// Следующий интервал (адаптируется по EWMA RTT)
|
|
1039
|
+
const nextMs = hb.nextIntervalMs();
|
|
1040
|
+
|
|
1041
|
+
// Пропуск — ускоряем пинги
|
|
1042
|
+
const missCount = hb.onMiss();
|
|
1043
|
+
if (missCount >= 2) {
|
|
1044
|
+
// reconnect
|
|
1045
|
+
}
|
|
1046
|
+
```
|
|
1047
|
+
|
|
1048
|
+
Алгоритм: базовый интервал `intervalMs / 3`; при пропусках делится на `2^miss` (min 2s); при стабильном RTT < 50ms удваивается (max 30s).
|
|
1049
|
+
|
|
1050
|
+
### Кредитное управление потоком
|
|
1051
|
+
|
|
1052
|
+
```typescript
|
|
1053
|
+
import { FlowControlStateV2 } from 'service-bridge';
|
|
1054
|
+
|
|
1055
|
+
const fc = new FlowControlStateV2(64, 1, 128);
|
|
1056
|
+
|
|
1057
|
+
if (fc.tryConsume()) {
|
|
1058
|
+
// dispatch command
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
// Команда завершена — вернуть permit
|
|
1062
|
+
fc.release(1);
|
|
1063
|
+
|
|
1064
|
+
// Сервер прислал FlowControlUpdate
|
|
1065
|
+
fc.setWindow(32);
|
|
1066
|
+
```
|
|
1067
|
+
|
|
1068
|
+
### Reconnect и resume
|
|
1069
|
+
|
|
1070
|
+
`BackoffV2` реализует экспоненциальный backoff с full jitter (base=100ms, max=30s). При переподключении `getHelloFields()` автоматически включает `resumeToken`, `epoch`, `lastReceivedSeq`, `lastSentSeq`, `completedCommandIds` — сервер продолжит сессию с нужной позиции.
|
|
1071
|
+
|
|
1072
|
+
```typescript
|
|
1073
|
+
import { BackoffV2 } from 'service-bridge';
|
|
1074
|
+
|
|
1075
|
+
const backoff = new BackoffV2();
|
|
1076
|
+
|
|
1077
|
+
while (true) {
|
|
1078
|
+
if (backoff.isCircuitOpen()) break; // 10+ сбоев подряд
|
|
1079
|
+
|
|
1080
|
+
const delayMs = backoff.next();
|
|
1081
|
+
await new Promise(r => setTimeout(r, delayMs));
|
|
1082
|
+
|
|
1083
|
+
try {
|
|
1084
|
+
// reconnect...
|
|
1085
|
+
backoff.reset();
|
|
1086
|
+
} catch {
|
|
1087
|
+
backoff.recordFail();
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
```
|
|
1091
|
+
|
|
1092
|
+
### ConfigPush — динамическая конфигурация транспорта
|
|
1093
|
+
|
|
1094
|
+
Сервер может в любой момент прислать `ConfigPush` с новыми правилами маршрутизации:
|
|
1095
|
+
|
|
1096
|
+
```typescript
|
|
1097
|
+
session.onConfigPush({
|
|
1098
|
+
defaultMode: 'direct',
|
|
1099
|
+
serviceOverrides: {
|
|
1100
|
+
'payment-svc': { mode: 'proxy', fallbackPolicy: 'fallback_to_direct' },
|
|
1101
|
+
},
|
|
1102
|
+
functionOverrides: {
|
|
1103
|
+
'payment-svc/charge': { mode: 'proxy', timeoutMs: 5000 },
|
|
1104
|
+
},
|
|
1105
|
+
});
|
|
1106
|
+
|
|
1107
|
+
// Разрешить транспорт для функции
|
|
1108
|
+
const mode = session.resolveTransportMode('payment-svc/charge'); // 'proxy'
|
|
1109
|
+
```
|
|
1110
|
+
|
|
1111
|
+
### Все события сессии
|
|
1112
|
+
|
|
1113
|
+
| Метод | Описание |
|
|
1114
|
+
|-------|----------|
|
|
1115
|
+
| `getHelloFields()` | Поля для отправки Hello (первый + resume) |
|
|
1116
|
+
| `onHelloAck(ack)` | Обработка HelloAck от сервера |
|
|
1117
|
+
| `onCommandReceived(seq, id)` | Входящая команда; возвращает `false` при backpressure |
|
|
1118
|
+
| `onCommandCompleted(seq, id)` | Команда выполнена; освобождает permit |
|
|
1119
|
+
| `onPermitGrant(n)` | Сервер добавил `n` permits |
|
|
1120
|
+
| `onFlowControlUpdate(size, reason)` | Сервер изменил размер окна |
|
|
1121
|
+
| `onPong(rttMs)` | Получен pong; обновляет EWMA |
|
|
1122
|
+
| `onHeartbeatMiss()` | Таймаут pong; возвращает `true` → `suspended` |
|
|
1123
|
+
| `onDrain(reason, deadlineMs)` | Инициировать graceful drain |
|
|
1124
|
+
| `onGoaway(code, reason)` | GoawaySignal от сервера |
|
|
1125
|
+
| `onConfigPush(config)` | Применить новую конфигурацию транспорта |
|
|
1126
|
+
| `resolveTransportMode(fnName)` | Получить режим транспорта для функции |
|
|
1127
|
+
| `stop()` | Немедленно закрыть сессию |
|
|
1128
|
+
|
|
1129
|
+
### Экспортируемые классы и типы
|
|
1130
|
+
|
|
1131
|
+
| Символ | Тип | Описание |
|
|
1132
|
+
|--------|-----|----------|
|
|
1133
|
+
| `V2SessionClient` | class | Главный клиент сессии |
|
|
1134
|
+
| `AdaptiveHeartbeatV2` | class | EWMA RTT heartbeat controller |
|
|
1135
|
+
| `FlowControlStateV2` | class | Кредитное управление потоком |
|
|
1136
|
+
| `BackoffV2` | class | Exponential backoff + circuit |
|
|
1137
|
+
| `PositionTrackerV2` | class | Трекер seq/completed IDs |
|
|
1138
|
+
| `ConfigPushStateV2` | class | Менеджер динамической конфигурации |
|
|
1139
|
+
| `validateV2Config` | function | Валидация конфига; бросает `Error` |
|
|
1140
|
+
| `V2Config` | interface | Конфигурация сессии |
|
|
1141
|
+
| `SessionStateV2` | type | Союз 8 состояний FSM |
|
|
1142
|
+
| `TransportMode` | type | `'direct' \| 'proxy'` |
|
|
1143
|
+
| `HelloAckV2` | interface | Данные HelloAck от сервера |
|
|
1144
|
+
| `TransportConfigV2` | interface | ConfigPush payload |
|
|
1145
|
+
|
|
1146
|
+
---
|
|
1147
|
+
|
|
956
1148
|
## FAQ
|
|
957
1149
|
|
|
958
1150
|
**How does ServiceBridge handle service failures?**
|
package/dist/index.js
CHANGED
|
@@ -12,6 +12,312 @@ import * as protobuf from "protobufjs";
|
|
|
12
12
|
// sdk/src/generated/servicebridge-package-definition.ts
|
|
13
13
|
var DESCRIPTOR_SET_BASE64 = "Cuk9ChNzZXJ2aWNlYnJpZGdlLnByb3RvEg1zZXJ2aWNlYnJpZGdlIk8KDUhhbmRsZVJlcXVlc3QSCgoCZm4YASABKAkSDwoHcGF5bG9hZBgCIAEoDBIQCgh0cmFjZV9pZBgDIAEoCRIPCgdzcGFuX2lkGAQgASgJIkAKDkhhbmRsZVJlc3BvbnNlEg4KBm91dHB1dBgBIAEoDBIPCgdzdWNjZXNzGAIgASgIEg0KBWVycm9yGAMgASgJIt8BChVEZWxpdmVyTWVzc2FnZVJlcXVlc3QSEgoKbWVzc2FnZV9pZBgBIAEoCRISCgpncm91cF9uYW1lGAIgASgJEg0KBXRvcGljGAMgASgJEg8KB3BheWxvYWQYBCABKAwSGAoHaGVhZGVycxgFIAMoCzIHSGVhZGVycxIQCgh0cmFjZV9pZBgGIAEoCRIWCg5wYXJlbnRfc3Bhbl9pZBgHIAEoCRIPCgdhdHRlbXB0GAggASgFGikKB0hlYWRlcnMSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgJOgI4ASLSAQoOUHVibGlzaFJlcXVlc3QSDQoFdG9waWMYASABKAkSDwoHcGF5bG9hZBgCIAEoDBIYCgdoZWFkZXJzGAMgAygLMgdIZWFkZXJzEhAKCHRyYWNlX2lkGAQgASgJEhYKDnBhcmVudF9zcGFuX2lkGAUgASgJEhgKEHByb2R1Y2VyX3NlcnZpY2UYBiABKAkSFwoPaWRlbXBvdGVuY3lfa2V5GAcgASgJGikKB0hlYWRlcnMSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgJOgI4ASIlCg9QdWJsaXNoUmVzcG9uc2USEgoKbWVzc2FnZV9pZBgBIAEoCSJYCgtEZWxpdmVyeUFjaxILCgNhY2sYASABKAgSFgoOcmV0cnlfYWZ0ZXJfbXMYAiABKAUSFQoNcmVqZWN0X3JlYXNvbhgDIAEoCRINCgVlcnJvchgEIAEoCSIuChRXYXRjaFJlZ2lzdHJ5UmVxdWVzdBIWCg5mdW5jdGlvbl9uYW1lcxgBIAMoCSIoChVMb29rdXBGdW5jdGlvblJlcXVlc3QSDwoHZm5fbmFtZRgBIAEoCSJgChZMb29rdXBGdW5jdGlvblJlc3BvbnNlEg0KBWZvdW5kGAEgASgIEhYKDmNhbm9uaWNhbF9uYW1lGAIgASgJEh8KCWVuZHBvaW50cxgDIAEoCzIMRW5kcG9pbnRMaXN0IoQBCg9SZWdpc3RyeU1lc3NhZ2USIQoEa2luZBgBIAEoDjITUmVnaXN0cnlNZXNzYWdlS2luZBIQCghyZXZpc2lvbhgCIAEoAxIeCgRmdWxsGAMgASgLMhBSZWdpc3RyeVNuYXBzaG90EhwKBWRlbHRhGAQgASgLMg1SZWdpc3RyeURlbHRhInQKDVJlZ2lzdHJ5RGVsdGESGAoHdXBzZXJ0cxgBIAMoCzIHVXBzZXJ0cxIQCghyZW1vdmFscxgCIAMoCRo3CgdVcHNlcnRzEgsKA2tleRgBIAEoCRIbCgV2YWx1ZRgCIAEoCzIMRW5kcG9pbnRMaXN0OgI4ASJaCg5Xb3JrZXJFbmRwb2ludBIQCghlbmRwb2ludBgBIAEoCRIRCgl0cmFuc3BvcnQYAiABKAkSEwoLaW5zdGFuY2VfaWQYAyABKAkSDgoGd2VpZ2h0GAQgASgFIlkKEFJlZ2lzdHJ5U25hcHNob3QSEAoDcnBjGAEgAygLMgNScGMaMwoDUnBjEgsKA2tleRgBIAEoCRIbCgV2YWx1ZRgCIAEoCzIMRW5kcG9pbnRMaXN0OgI4ASKHAQoMRW5kcG9pbnRMaXN0EiEKCWVuZHBvaW50cxgBIAMoCzIOV29ya2VyRW5kcG9pbnQSGQoRaW5wdXRfc2NoZW1hX2pzb24YAiABKAkSGgoSb3V0cHV0X3NjaGVtYV9qc29uGAMgASgJEhcKD2FsbG93ZWRfY2FsbGVycxgEIAMoCUoECAUQByK+AQoWUmVwb3J0Q2FsbFN0YXJ0UmVxdWVzdBIQCgh0cmFjZV9pZBgBIAEoCRIPCgdzcGFuX2lkGAIgASgJEgoKAmZuGAMgASgJEhQKDHNlcnZpY2VfbmFtZRgEIAEoCRISCgpzdGFydGVkX2F0GAUgASgDEg0KBWlucHV0GAYgASgMEg8KB2F0dGVtcHQYByABKAUSFgoOcGFyZW50X3NwYW5faWQYCCABKAkSEwoLaW5zdGFuY2VfaWQYCSABKAki5gEKEVJlcG9ydENhbGxSZXF1ZXN0EhAKCHRyYWNlX2lkGAEgASgJEg8KB3NwYW5faWQYAiABKAkSCgoCZm4YAyABKAkSFAoMc2VydmljZV9uYW1lGAQgASgJEhIKCnN0YXJ0ZWRfYXQYBSABKAMSEwoLZHVyYXRpb25fbXMYBiABKAMSDwoHc3VjY2VzcxgHIAEoCBINCgVlcnJvchgIIAEoCRINCgVpbnB1dBgJIAEoDBIOCgZvdXRwdXQYCiABKAwSDwoHYXR0ZW1wdBgLIAEoBRITCgtpbnN0YW5jZV9pZBgMIAEoCSIUChJSZXBvcnRDYWxsUmVzcG9uc2Ui3AEKCExvZ0VudHJ5EhAKCHRyYWNlX2lkGAEgASgJEg8KB3NwYW5faWQYAiABKAkSFAoMc2VydmljZV9uYW1lGAMgASgJEg0KBWxldmVsGAQgASgJEg8KB21lc3NhZ2UYBSABKAkSFAoMdGltZXN0YW1wX25zGAYgASgDEh4KCmF0dHJpYnV0ZXMYByADKAsyCkF0dHJpYnV0ZXMSEwoLaW5zdGFuY2VfaWQYCCABKAkaLAoKQXR0cmlidXRlcxILCgNrZXkYASABKAkSDQoFdmFsdWUYAiABKAk6AjgBIi0KEFJlcG9ydExvZ1JlcXVlc3QSGQoHZW50cmllcxgBIAMoCzIITG9nRW50cnkiEwoRUmVwb3J0TG9nUmVzcG9uc2UiwQEKF1JlZ2lzdGVyRnVuY3Rpb25SZXF1ZXN0Eg8KB2ZuX25hbWUYASABKAkSFAoMc2VydmljZV9uYW1lGAIgASgJEhMKC2luc3RhbmNlX2lkGAMgASgJEhAKCGVuZHBvaW50GAQgASgJEg4KBndlaWdodBgFIAEoBRIZChFpbnB1dF9zY2hlbWFfanNvbhgGIAEoCRIaChJvdXRwdXRfc2NoZW1hX2pzb24YByABKAkSEQoJdHJhbnNwb3J0GAggASgJIhoKGFJlZ2lzdGVyRnVuY3Rpb25SZXNwb25zZSKRAQocUmVnaXN0ZXJDb25zdW1lckdyb3VwUmVxdWVzdBIMCgRuYW1lGAEgASgJEg8KB3BhdHRlcm4YAiABKAkSDAoEbW9kZRgDIAEoCRIZChFyZXRyeV9wb2xpY3lfanNvbhgEIAEoCRIOCgZhY3RpdmUYBiABKAgSEwoLZmlsdGVyX2V4cHIYByABKAlKBAgFEAUiHwodUmVnaXN0ZXJDb25zdW1lckdyb3VwUmVzcG9uc2UikAEKGlJlZ2lzdGVyR3JvdXBNZW1iZXJSZXF1ZXN0EhIKCmdyb3VwX25hbWUYASABKAkSFAoMc2VydmljZV9uYW1lGAIgASgJEhMKC2luc3RhbmNlX2lkGAMgASgJEhAKCGVuZHBvaW50GAQgASgJEg4KBndlaWdodBgFIAEoBRIRCgl0cmFuc3BvcnQYBiABKAkiHQobUmVnaXN0ZXJHcm91cE1lbWJlclJlc3BvbnNlItUBChBIZWFydGJlYXRSZXF1ZXN0EhQKDHNlcnZpY2VfbmFtZRgBIAEoCRITCgtpbnN0YW5jZV9pZBgCIAEoCRIQCghlbmRwb2ludBgDIAEoCRITCgtncm91cF9uYW1lcxgEIAMoCRIWCg5mdW5jdGlvbl9uYW1lcxgFIAMoCRIRCgl0cmFuc3BvcnQYBiABKAkSFwoLY3B1X3BlcmNlbnQYByABKAJCAEgAEhIKBnJhbV9tYhgIIAEoBUIASAFCDQoLX2NwdVBlcmNlbnRCCAoGX3JhbU1iIhMKEUhlYXJ0YmVhdFJlc3BvbnNlIugBChtSZWdpc3Rlckh0dHBFbmRwb2ludFJlcXVlc3QSFAoMc2VydmljZV9uYW1lGAEgASgJEg4KBm1ldGhvZBgCIAEoCRIVCg1yb3V0ZV9wYXR0ZXJuGAMgASgJEhsKE3JlcXVlc3Rfc2NoZW1hX2pzb24YBCABKAkSHAoUcmVzcG9uc2Vfc2NoZW1hX2pzb24YBSABKAkSEwoLaW5zdGFuY2VfaWQYBiABKAkSEAoIZW5kcG9pbnQYByABKAkSEQoJdHJhbnNwb3J0GAggASgJEhcKD2FsbG93ZWRfY2FsbGVycxgJIAMoCSJCChxSZWdpc3Rlckh0dHBFbmRwb2ludFJlc3BvbnNlEgoKAm9rGAEgASgIEhYKDmNhbm9uaWNhbF9uYW1lGAIgASgJIkkKHEhlYXJ0YmVhdEh0dHBFbmRwb2ludFJlcXVlc3QSFAoMc2VydmljZV9uYW1lGAEgASgJEhMKC2luc3RhbmNlX2lkGAIgASgJIisKHUhlYXJ0YmVhdEh0dHBFbmRwb2ludFJlc3BvbnNlEgoKAm9rGAEgASgIImQKIVByb3Zpc2lvbldvcmtlckNlcnRpZmljYXRlUmVxdWVzdBIUCgxzZXJ2aWNlX25hbWUYASABKAkSFgoOcHVibGljX2tleV9wZW0YAiABKAkSEQoJZXh0cmFfaXBzGAMgAygJIkYKIlByb3Zpc2lvbldvcmtlckNlcnRpZmljYXRlUmVzcG9uc2USEAoIY2VydF9wZW0YASABKAkSDgoGY2FfcGVtGAIgASgJIlUKEldvcmtlclNlc3Npb25IZWxsbxIUCgxzZXJ2aWNlX25hbWUYASABKAkSEwoLaW5zdGFuY2VfaWQYAiABKAkSFAoMbWF4X2luZmxpZ2h0GAMgASgFImIKEFdvcmtlclJQQ0NvbW1hbmQSCgoCZm4YASABKAkSDwoHcGF5bG9hZBgCIAEoDBIQCgh0cmFjZV9pZBgDIAEoCRIPCgdzcGFuX2lkGAQgASgJEg4KBnJ1bl9pZBgFIAEoCSLsAQoSV29ya2VyRXZlbnRDb21tYW5kEhIKCm1lc3NhZ2VfaWQYASABKAkSEgoKZ3JvdXBfbmFtZRgCIAEoCRINCgV0b3BpYxgDIAEoCRIPCgdwYXlsb2FkGAQgASgMEhgKB2hlYWRlcnMYBSADKAsyB0hlYWRlcnMSEAoIdHJhY2VfaWQYBiABKAkSFgoOcGFyZW50X3NwYW5faWQYByABKAkSDwoHYXR0ZW1wdBgIIAEoBRIOCgZydW5faWQYCSABKAkaKQoHSGVhZGVycxILCgNrZXkYASABKAkSDQoFdmFsdWUYAiABKAk6AjgBIqcBChRXb3JrZXJTZXNzaW9uQ29tbWFuZBISCgpjb21tYW5kX2lkGAEgASgJEh8KBGtpbmQYAiABKA4yEVdvcmtlckNvbW1hbmRLaW5kEh0KA3JwYxgDIAEoCzIQV29ya2VyUlBDQ29tbWFuZBIhCgVldmVudBgEIAEoCzISV29ya2VyRXZlbnRDb21tYW5kEhgKEGRlYWRsaW5lX3VuaXhfbXMYBSABKAMinAEKGldvcmtlclNlc3Npb25Db21tYW5kUmVzdWx0EhIKCmNvbW1hbmRfaWQYASABKAkSDwoHc3VjY2VzcxgCIAEoCBIOCgZvdXRwdXQYAyABKAwSDQoFZXJyb3IYBCABKAkSCwoDYWNrGAUgASgIEhYKDnJldHJ5X2FmdGVyX21zGAYgASgFEhUKDXJlamVjdF9yZWFzb24YByABKAkiLgoRV29ya2VyU2Vzc2lvblBpbmcSGQoRdGltZXN0YW1wX3VuaXhfbXMYASABKAMiLgoRV29ya2VyU2Vzc2lvblBvbmcSGQoRdGltZXN0YW1wX3VuaXhfbXMYASABKAMinwEKFFdvcmtlclNlc3Npb25SZXF1ZXN0EiMKBWhlbGxvGAEgASgLMhJXb3JrZXJTZXNzaW9uSGVsbG9IABI0Cg5jb21tYW5kX3Jlc3VsdBgCIAEoCzIaV29ya2VyU2Vzc2lvbkNvbW1hbmRSZXN1bHRIABIhCgRwaW5nGAMgASgLMhFXb3JrZXJTZXNzaW9uUGluZ0gAQgkKB3BheWxvYWQibgoVV29ya2VyU2Vzc2lvblJlc3BvbnNlEicKB2NvbW1hbmQYASABKAsyFFdvcmtlclNlc3Npb25Db21tYW5kSAASIQoEcG9uZxgCIAEoCzIRV29ya2VyU2Vzc2lvblBvbmdIAEIJCgdwYXlsb2FkIl8KF1JlZ2lzdGVyV29ya2Zsb3dSZXF1ZXN0EgwKBG5hbWUYASABKAkSEgoKZGVmaW5pdGlvbhgCIAEoCRIMCgRvcHRzGAMgASgJEhQKDHNlcnZpY2VfbmFtZRgEIAEoCSImChhSZWdpc3RlcldvcmtmbG93UmVzcG9uc2USCgoCaWQYASABKAkiRwoSUnVuV29ya2Zsb3dSZXF1ZXN0EgwKBG5hbWUYASABKAkSDQoFaW5wdXQYAiABKAwSFAoMc2VydmljZV9uYW1lGAMgASgJIjcKE1J1bldvcmtmbG93UmVzcG9uc2USDgoGcnVuX2lkGAEgASgJEhAKCHRyYWNlX2lkGAIgASgJIioKGENhbmNlbFdvcmtmbG93UnVuUmVxdWVzdBIOCgZydW5faWQYASABKAkiPgoZQ2FuY2VsV29ya2Zsb3dSdW5SZXNwb25zZRIRCgljYW5jZWxsZWQYASABKAgSDgoGcnVuX2lkGAIgASgJIr0BChJSZWdpc3RlckpvYlJlcXVlc3QSEQoJY3Jvbl9leHByGAEgASgJEhAKCHRpbWV6b25lGAIgASgJEhYKDm1pc2ZpcmVfcG9saWN5GAMgASgJEhMKC3RhcmdldF90eXBlGAQgASgJEhIKCnRhcmdldF9yZWYYBSABKAkSEAoIZGVsYXlfbXMYBiABKAUSFAoMc2VydmljZV9uYW1lGAcgASgJEhkKEXJldHJ5X3BvbGljeV9qc29uGAggASgJIiEKE1JlZ2lzdGVySm9iUmVzcG9uc2USCgoCaWQYASABKAkiQAoTQXBwZW5kU3RyZWFtUmVxdWVzdBIOCgZydW5faWQYASABKAkSCwoDa2V5GAIgASgJEgwKBGRhdGEYAyABKAwiFgoUQXBwZW5kU3RyZWFtUmVzcG9uc2UiRQoPV2F0Y2hSdW5SZXF1ZXN0Eg4KBnJ1bl9pZBgBIAEoCRILCgNrZXkYAiABKAkSFQoNZnJvbV9zZXF1ZW5jZRgDIAEoAyJlCg5SdW5TdHJlYW1DaHVuaxIMCgR0eXBlGAEgASgJEgsKA2tleRgDIAEoCRIMCgRkYXRhGAQgASgMEhAKCHNlcXVlbmNlGAUgASgDEhIKCnJ1bl9zdGF0dXMYBiABKAlKBAgCEAIqKgoTUmVnaXN0cnlNZXNzYWdlS2luZBIICgRGVUxMEAASCQoFREVMVEEQASp0ChFXb3JrZXJDb21tYW5kS2luZBIjCh9XT1JLRVJfQ09NTUFORF9LSU5EX1VOU1BFQ0lGSUVEEAASGwoXV09SS0VSX0NPTU1BTkRfS0lORF9SUEMQARIdChlXT1JLRVJfQ09NTUFORF9LSU5EX0VWRU5UEAIykw8KDVNlcnZpY2VCcmlkZ2USYwoQUmVnaXN0ZXJGdW5jdGlvbhImLnNlcnZpY2VicmlkZ2UuUmVnaXN0ZXJGdW5jdGlvblJlcXVlc3QaJy5zZXJ2aWNlYnJpZGdlLlJlZ2lzdGVyRnVuY3Rpb25SZXNwb25zZRJyChVSZWdpc3RlckNvbnN1bWVyR3JvdXASKy5zZXJ2aWNlYnJpZGdlLlJlZ2lzdGVyQ29uc3VtZXJHcm91cFJlcXVlc3QaLC5zZXJ2aWNlYnJpZGdlLlJlZ2lzdGVyQ29uc3VtZXJHcm91cFJlc3BvbnNlEmwKE1JlZ2lzdGVyR3JvdXBNZW1iZXISKS5zZXJ2aWNlYnJpZGdlLlJlZ2lzdGVyR3JvdXBNZW1iZXJSZXF1ZXN0Giouc2VydmljZWJyaWRnZS5SZWdpc3Rlckdyb3VwTWVtYmVyUmVzcG9uc2USTgoJSGVhcnRiZWF0Eh8uc2VydmljZWJyaWRnZS5IZWFydGJlYXRSZXF1ZXN0GiAuc2VydmljZWJyaWRnZS5IZWFydGJlYXRSZXNwb25zZRJvChRSZWdpc3Rlckh0dHBFbmRwb2ludBIqLnNlcnZpY2VicmlkZ2UuUmVnaXN0ZXJIdHRwRW5kcG9pbnRSZXF1ZXN0Gisuc2VydmljZWJyaWRnZS5SZWdpc3Rlckh0dHBFbmRwb2ludFJlc3BvbnNlEnIKFUhlYXJ0YmVhdEh0dHBFbmRwb2ludBIrLnNlcnZpY2VicmlkZ2UuSGVhcnRiZWF0SHR0cEVuZHBvaW50UmVxdWVzdBosLnNlcnZpY2VicmlkZ2UuSGVhcnRiZWF0SHR0cEVuZHBvaW50UmVzcG9uc2USgQEKGlByb3Zpc2lvbldvcmtlckNlcnRpZmljYXRlEjAuc2VydmljZWJyaWRnZS5Qcm92aXNpb25Xb3JrZXJDZXJ0aWZpY2F0ZVJlcXVlc3QaMS5zZXJ2aWNlYnJpZGdlLlByb3Zpc2lvbldvcmtlckNlcnRpZmljYXRlUmVzcG9uc2USYgoRT3BlbldvcmtlclNlc3Npb24SIy5zZXJ2aWNlYnJpZGdlLldvcmtlclNlc3Npb25SZXF1ZXN0GiQuc2VydmljZWJyaWRnZS5Xb3JrZXJTZXNzaW9uUmVzcG9uc2UoATABEmMKEFJlZ2lzdGVyV29ya2Zsb3cSJi5zZXJ2aWNlYnJpZGdlLlJlZ2lzdGVyV29ya2Zsb3dSZXF1ZXN0Gicuc2VydmljZWJyaWRnZS5SZWdpc3RlcldvcmtmbG93UmVzcG9uc2USVAoLUnVuV29ya2Zsb3cSIS5zZXJ2aWNlYnJpZGdlLlJ1bldvcmtmbG93UmVxdWVzdBoiLnNlcnZpY2VicmlkZ2UuUnVuV29ya2Zsb3dSZXNwb25zZRJmChFDYW5jZWxXb3JrZmxvd1J1bhInLnNlcnZpY2VicmlkZ2UuQ2FuY2VsV29ya2Zsb3dSdW5SZXF1ZXN0Giguc2VydmljZWJyaWRnZS5DYW5jZWxXb3JrZmxvd1J1blJlc3BvbnNlElQKC1JlZ2lzdGVySm9iEiEuc2VydmljZWJyaWRnZS5SZWdpc3RlckpvYlJlcXVlc3QaIi5zZXJ2aWNlYnJpZGdlLlJlZ2lzdGVySm9iUmVzcG9uc2USVgoNV2F0Y2hSZWdpc3RyeRIjLnNlcnZpY2VicmlkZ2UuV2F0Y2hSZWdpc3RyeVJlcXVlc3QaHi5zZXJ2aWNlYnJpZGdlLlJlZ2lzdHJ5TWVzc2FnZTABEl0KDkxvb2t1cEZ1bmN0aW9uEiQuc2VydmljZWJyaWRnZS5Mb29rdXBGdW5jdGlvblJlcXVlc3QaJS5zZXJ2aWNlYnJpZGdlLkxvb2t1cEZ1bmN0aW9uUmVzcG9uc2USWwoPUmVwb3J0Q2FsbFN0YXJ0EiUuc2VydmljZWJyaWRnZS5SZXBvcnRDYWxsU3RhcnRSZXF1ZXN0GiEuc2VydmljZWJyaWRnZS5SZXBvcnRDYWxsUmVzcG9uc2USUQoKUmVwb3J0Q2FsbBIgLnNlcnZpY2VicmlkZ2UuUmVwb3J0Q2FsbFJlcXVlc3QaIS5zZXJ2aWNlYnJpZGdlLlJlcG9ydENhbGxSZXNwb25zZRJOCglSZXBvcnRMb2cSHy5zZXJ2aWNlYnJpZGdlLlJlcG9ydExvZ1JlcXVlc3QaIC5zZXJ2aWNlYnJpZGdlLlJlcG9ydExvZ1Jlc3BvbnNlEkgKB1B1Ymxpc2gSHS5zZXJ2aWNlYnJpZGdlLlB1Ymxpc2hSZXF1ZXN0Gh4uc2VydmljZWJyaWRnZS5QdWJsaXNoUmVzcG9uc2USVwoMQXBwZW5kU3RyZWFtEiIuc2VydmljZWJyaWRnZS5BcHBlbmRTdHJlYW1SZXF1ZXN0GiMuc2VydmljZWJyaWRnZS5BcHBlbmRTdHJlYW1SZXNwb25zZRJLCghXYXRjaFJ1bhIeLnNlcnZpY2VicmlkZ2UuV2F0Y2hSdW5SZXF1ZXN0Gh0uc2VydmljZWJyaWRnZS5SdW5TdHJlYW1DaHVuazABMlwKE1NlcnZpY2VCcmlkZ2VXb3JrZXISRQoGSGFuZGxlEhwuc2VydmljZWJyaWRnZS5IYW5kbGVSZXF1ZXN0Gh0uc2VydmljZWJyaWRnZS5IYW5kbGVSZXNwb25zZUItWitnaXRodWIuY29tL3NlcnZpY2UtYnJpZGdlL2dvL2ludGVybmFsL3BiO3BiYgZwcm90bzM=";
|
|
14
14
|
|
|
15
|
+
// sdk/src/session_v2.ts
|
|
16
|
+
class AdaptiveHeartbeatV2 {
|
|
17
|
+
intervalMs;
|
|
18
|
+
timeoutMs;
|
|
19
|
+
missCount = 0;
|
|
20
|
+
rttEwma = 0;
|
|
21
|
+
alpha = 0.3;
|
|
22
|
+
constructor(intervalMs, timeoutMs) {
|
|
23
|
+
this.intervalMs = intervalMs;
|
|
24
|
+
this.timeoutMs = timeoutMs;
|
|
25
|
+
}
|
|
26
|
+
nextIntervalMs() {
|
|
27
|
+
let base = this.intervalMs / 3;
|
|
28
|
+
if (this.missCount > 0) {
|
|
29
|
+
const shift = Math.min(this.missCount, 4);
|
|
30
|
+
base = base / 2 ** shift;
|
|
31
|
+
} else if (this.rttEwma > 0 && this.rttEwma < 50) {
|
|
32
|
+
base = Math.min(base * 2, 30000);
|
|
33
|
+
}
|
|
34
|
+
return Math.max(2000, Math.min(30000, base));
|
|
35
|
+
}
|
|
36
|
+
onPong(rttMs) {
|
|
37
|
+
this.rttEwma = this.alpha * rttMs + (1 - this.alpha) * this.rttEwma;
|
|
38
|
+
this.missCount = 0;
|
|
39
|
+
}
|
|
40
|
+
onMiss() {
|
|
41
|
+
return ++this.missCount;
|
|
42
|
+
}
|
|
43
|
+
resetMiss() {
|
|
44
|
+
this.missCount = 0;
|
|
45
|
+
}
|
|
46
|
+
getTimeoutMs() {
|
|
47
|
+
return this.timeoutMs;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
class FlowControlStateV2 {
|
|
52
|
+
permits;
|
|
53
|
+
window;
|
|
54
|
+
minW;
|
|
55
|
+
maxW;
|
|
56
|
+
constructor(initial, min, max) {
|
|
57
|
+
this.permits = initial;
|
|
58
|
+
this.window = initial;
|
|
59
|
+
this.minW = min;
|
|
60
|
+
this.maxW = max;
|
|
61
|
+
}
|
|
62
|
+
tryConsume() {
|
|
63
|
+
if (this.permits <= 0)
|
|
64
|
+
return false;
|
|
65
|
+
this.permits--;
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
release(n) {
|
|
69
|
+
this.permits += n;
|
|
70
|
+
}
|
|
71
|
+
available() {
|
|
72
|
+
return this.permits;
|
|
73
|
+
}
|
|
74
|
+
setWindow(n) {
|
|
75
|
+
this.window = Math.max(this.minW, Math.min(this.maxW, n));
|
|
76
|
+
this.permits = this.window;
|
|
77
|
+
}
|
|
78
|
+
getWindow() {
|
|
79
|
+
return this.window;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
class BackoffV2 {
|
|
84
|
+
attempt = 0;
|
|
85
|
+
baseMs = 100;
|
|
86
|
+
maxMs = 30000;
|
|
87
|
+
circuitFails = 0;
|
|
88
|
+
next() {
|
|
89
|
+
this.attempt++;
|
|
90
|
+
const calculated = this.baseMs * 2 ** Math.min(this.attempt, 10);
|
|
91
|
+
const capped = Math.min(calculated, this.maxMs);
|
|
92
|
+
const jittered = Math.random() * capped;
|
|
93
|
+
return Math.max(this.baseMs, jittered);
|
|
94
|
+
}
|
|
95
|
+
reset() {
|
|
96
|
+
this.attempt = 0;
|
|
97
|
+
this.circuitFails = 0;
|
|
98
|
+
}
|
|
99
|
+
recordFail() {
|
|
100
|
+
this.circuitFails++;
|
|
101
|
+
}
|
|
102
|
+
isCircuitOpen() {
|
|
103
|
+
return this.circuitFails >= 10;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
class PositionTrackerV2 {
|
|
108
|
+
lastReceivedSeq = BigInt(0);
|
|
109
|
+
lastSentSeq = BigInt(0);
|
|
110
|
+
completedIds = new Set;
|
|
111
|
+
maxCompleted = 512;
|
|
112
|
+
recordReceived(seq) {
|
|
113
|
+
if (seq > this.lastReceivedSeq)
|
|
114
|
+
this.lastReceivedSeq = seq;
|
|
115
|
+
}
|
|
116
|
+
recordSent(seq) {
|
|
117
|
+
if (seq > this.lastSentSeq)
|
|
118
|
+
this.lastSentSeq = seq;
|
|
119
|
+
}
|
|
120
|
+
markCompleted(commandId) {
|
|
121
|
+
this.completedIds.add(commandId);
|
|
122
|
+
if (this.completedIds.size > this.maxCompleted) {
|
|
123
|
+
const first = this.completedIds.values().next().value;
|
|
124
|
+
if (first !== undefined)
|
|
125
|
+
this.completedIds.delete(first);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
getResumeState(token, epoch) {
|
|
129
|
+
return {
|
|
130
|
+
resumeToken: token,
|
|
131
|
+
epoch,
|
|
132
|
+
lastReceivedSeq: this.lastReceivedSeq,
|
|
133
|
+
lastSentSeq: this.lastSentSeq,
|
|
134
|
+
completedCommandIds: Array.from(this.completedIds)
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
class ConfigPushStateV2 {
|
|
140
|
+
defaultMode = "direct";
|
|
141
|
+
serviceOverrides = {};
|
|
142
|
+
functionOverrides = {};
|
|
143
|
+
apply(config) {
|
|
144
|
+
if (config.defaultMode)
|
|
145
|
+
this.defaultMode = config.defaultMode;
|
|
146
|
+
if (config.serviceOverrides) {
|
|
147
|
+
Object.assign(this.serviceOverrides, config.serviceOverrides);
|
|
148
|
+
}
|
|
149
|
+
if (config.functionOverrides) {
|
|
150
|
+
Object.assign(this.functionOverrides, config.functionOverrides);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
resolveMode(fnName) {
|
|
154
|
+
const fnOverride = this.functionOverrides[fnName];
|
|
155
|
+
if (fnOverride?.mode)
|
|
156
|
+
return fnOverride.mode;
|
|
157
|
+
const svcName = fnName.includes("/") ? fnName.split("/")[0] : fnName;
|
|
158
|
+
const svcOverride = this.serviceOverrides[svcName];
|
|
159
|
+
if (svcOverride?.mode)
|
|
160
|
+
return svcOverride.mode;
|
|
161
|
+
return this.defaultMode;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
class V2SessionClient {
|
|
166
|
+
config;
|
|
167
|
+
_state = "connecting";
|
|
168
|
+
resumeToken = "";
|
|
169
|
+
epoch = BigInt(0);
|
|
170
|
+
position;
|
|
171
|
+
flowCtrl;
|
|
172
|
+
heartbeat;
|
|
173
|
+
backoff;
|
|
174
|
+
configPush;
|
|
175
|
+
_draining = false;
|
|
176
|
+
_stopped = false;
|
|
177
|
+
constructor(config) {
|
|
178
|
+
this.config = {
|
|
179
|
+
serverAddress: config.serverAddress,
|
|
180
|
+
serviceName: config.serviceName,
|
|
181
|
+
instanceId: config.instanceId,
|
|
182
|
+
zone: config.zone ?? "",
|
|
183
|
+
transportMode: config.transportMode ?? "direct",
|
|
184
|
+
maxInflight: config.maxInflight ?? 128,
|
|
185
|
+
minInflight: config.minInflight ?? 1,
|
|
186
|
+
sdkVersion: config.sdkVersion ?? "node-v2.0.0",
|
|
187
|
+
capabilities: config.capabilities ?? [],
|
|
188
|
+
tlsEnabled: config.tlsEnabled ?? false,
|
|
189
|
+
certPem: config.certPem ?? "",
|
|
190
|
+
keyPem: config.keyPem ?? "",
|
|
191
|
+
caPem: config.caPem ?? ""
|
|
192
|
+
};
|
|
193
|
+
this.position = new PositionTrackerV2;
|
|
194
|
+
this.flowCtrl = new FlowControlStateV2(this.config.maxInflight, this.config.minInflight, this.config.maxInflight);
|
|
195
|
+
this.heartbeat = new AdaptiveHeartbeatV2(1e4, 30000);
|
|
196
|
+
this.backoff = new BackoffV2;
|
|
197
|
+
this.configPush = new ConfigPushStateV2;
|
|
198
|
+
}
|
|
199
|
+
get state() {
|
|
200
|
+
return this._state;
|
|
201
|
+
}
|
|
202
|
+
get isDraining() {
|
|
203
|
+
return this._draining;
|
|
204
|
+
}
|
|
205
|
+
get isStopped() {
|
|
206
|
+
return this._stopped;
|
|
207
|
+
}
|
|
208
|
+
getHelloFields() {
|
|
209
|
+
const rs = this.position.getResumeState(this.resumeToken, this.epoch);
|
|
210
|
+
return {
|
|
211
|
+
identity: {
|
|
212
|
+
serviceName: this.config.serviceName,
|
|
213
|
+
instanceId: this.config.instanceId,
|
|
214
|
+
transport: this.config.transportMode
|
|
215
|
+
},
|
|
216
|
+
maxInflight: this.config.maxInflight,
|
|
217
|
+
minInflight: this.config.minInflight,
|
|
218
|
+
resumeToken: rs.resumeToken,
|
|
219
|
+
epoch: rs.epoch,
|
|
220
|
+
lastReceivedSeq: rs.lastReceivedSeq,
|
|
221
|
+
lastSentSeq: rs.lastSentSeq,
|
|
222
|
+
completedCommandIds: rs.completedCommandIds,
|
|
223
|
+
sdkVersion: this.config.sdkVersion,
|
|
224
|
+
capabilities: this.config.capabilities,
|
|
225
|
+
zone: this.config.zone,
|
|
226
|
+
transportMode: this.config.transportMode
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
onHelloAck(ack) {
|
|
230
|
+
this.resumeToken = ack.resumeToken;
|
|
231
|
+
this.epoch = ack.epoch;
|
|
232
|
+
this._state = "ready";
|
|
233
|
+
this.flowCtrl.setWindow(ack.initialPermits);
|
|
234
|
+
this.heartbeat = new AdaptiveHeartbeatV2(ack.heartbeatIntervalMs, ack.heartbeatTimeoutMs);
|
|
235
|
+
this.backoff.reset();
|
|
236
|
+
console.log(`[session-v2] hello_ack session=${ack.sessionId} resumed=${ack.resumed} epoch=${ack.epoch}`);
|
|
237
|
+
}
|
|
238
|
+
onCommandReceived(seq, commandId) {
|
|
239
|
+
this.position.recordReceived(seq);
|
|
240
|
+
if (!this.flowCtrl.tryConsume()) {
|
|
241
|
+
console.warn(`[session-v2] no permits for command ${commandId}, backpressure`);
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
if (this._state === "ready")
|
|
245
|
+
this._state = "active";
|
|
246
|
+
return true;
|
|
247
|
+
}
|
|
248
|
+
onCommandCompleted(seq, commandId) {
|
|
249
|
+
this.position.markCompleted(commandId);
|
|
250
|
+
this.position.recordSent(seq);
|
|
251
|
+
this.flowCtrl.release(1);
|
|
252
|
+
}
|
|
253
|
+
onPermitGrant(additional) {
|
|
254
|
+
this.flowCtrl.release(additional);
|
|
255
|
+
}
|
|
256
|
+
onFlowControlUpdate(newWindowSize, reason) {
|
|
257
|
+
this.flowCtrl.setWindow(newWindowSize);
|
|
258
|
+
console.log(`[session-v2] flow_control_update window=${newWindowSize} reason=${reason}`);
|
|
259
|
+
}
|
|
260
|
+
onPong(rttMs) {
|
|
261
|
+
this.heartbeat.onPong(rttMs);
|
|
262
|
+
if (this._state === "suspended") {
|
|
263
|
+
this._state = "ready";
|
|
264
|
+
console.log("[session-v2] heartbeat resumed, state→ready");
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
onHeartbeatMiss() {
|
|
268
|
+
const count = this.heartbeat.onMiss();
|
|
269
|
+
if (count >= 2 && (this._state === "ready" || this._state === "active")) {
|
|
270
|
+
this._state = "suspended";
|
|
271
|
+
console.warn(`[session-v2] heartbeat miss ${count}, state→suspended`);
|
|
272
|
+
return true;
|
|
273
|
+
}
|
|
274
|
+
return false;
|
|
275
|
+
}
|
|
276
|
+
onDrain(reason, deadlineMs) {
|
|
277
|
+
if (!this._draining) {
|
|
278
|
+
this._draining = true;
|
|
279
|
+
this._state = "draining";
|
|
280
|
+
console.log(`[session-v2] drain initiated reason=${reason} deadline=${deadlineMs}ms`);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
onGoaway(code, reason) {
|
|
284
|
+
console.log(`[session-v2] goaway code=${code} reason=${reason}`);
|
|
285
|
+
switch (code) {
|
|
286
|
+
case "GOAWAY_FENCED":
|
|
287
|
+
this._state = "fenced";
|
|
288
|
+
this.stop();
|
|
289
|
+
break;
|
|
290
|
+
case "GOAWAY_GRACEFUL_SHUTDOWN":
|
|
291
|
+
this._state = "closed";
|
|
292
|
+
break;
|
|
293
|
+
default:
|
|
294
|
+
this._state = "closed";
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
onConfigPush(config) {
|
|
298
|
+
this.configPush.apply(config);
|
|
299
|
+
console.log("[session-v2] config push applied");
|
|
300
|
+
}
|
|
301
|
+
resolveTransportMode(fnName) {
|
|
302
|
+
return this.configPush.resolveMode(fnName);
|
|
303
|
+
}
|
|
304
|
+
stop() {
|
|
305
|
+
this._stopped = true;
|
|
306
|
+
this._state = "closed";
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
function validateV2Config(cfg) {
|
|
310
|
+
if (!cfg.serverAddress)
|
|
311
|
+
throw new Error("servicebridge: serverAddress is required");
|
|
312
|
+
if (!cfg.serviceName)
|
|
313
|
+
throw new Error("servicebridge: serviceName is required");
|
|
314
|
+
if (!cfg.instanceId)
|
|
315
|
+
throw new Error("servicebridge: instanceId is required");
|
|
316
|
+
if (cfg.transportMode && cfg.transportMode !== "direct" && cfg.transportMode !== "proxy") {
|
|
317
|
+
throw new Error(`servicebridge: transportMode must be 'direct' or 'proxy', got '${cfg.transportMode}'`);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
15
321
|
// sdk/src/grpc-client.ts
|
|
16
322
|
function outboundIP() {
|
|
17
323
|
return new Promise((resolve) => {
|
|
@@ -599,6 +905,8 @@ function servicebridge(url, serviceKey, serviceOrOpts = {}, maybeGlobalOpts = {}
|
|
|
599
905
|
let workerSessionStream = null;
|
|
600
906
|
let workerSessionReconnectTimer = null;
|
|
601
907
|
let workerSessionPingTimer = null;
|
|
908
|
+
let workerSessionPositionTimer = null;
|
|
909
|
+
let v2Session = null;
|
|
602
910
|
let serveState = null;
|
|
603
911
|
let heartbeatTimer = null;
|
|
604
912
|
let registrationSyncPromise = null;
|
|
@@ -657,7 +965,7 @@ function servicebridge(url, serviceKey, serviceOrOpts = {}, maybeGlobalOpts = {}
|
|
|
657
965
|
span_id: op.spanId,
|
|
658
966
|
parent_span_id: op.parentSpanId,
|
|
659
967
|
fn: op.fn,
|
|
660
|
-
started_at:
|
|
968
|
+
started_at: op.startedAt,
|
|
661
969
|
input: op.inputBuf,
|
|
662
970
|
attempt: op.attempt
|
|
663
971
|
}
|
|
@@ -671,7 +979,7 @@ function servicebridge(url, serviceKey, serviceOrOpts = {}, maybeGlobalOpts = {}
|
|
|
671
979
|
parent_span_id: op.parentSpanId,
|
|
672
980
|
fn: op.fn,
|
|
673
981
|
service_name: service,
|
|
674
|
-
started_at:
|
|
982
|
+
started_at: op.startedAt,
|
|
675
983
|
input: op.inputBuf,
|
|
676
984
|
attempt: op.attempt,
|
|
677
985
|
instance_id: serveState?.instanceId ?? ""
|
|
@@ -684,8 +992,8 @@ function servicebridge(url, serviceKey, serviceOrOpts = {}, maybeGlobalOpts = {}
|
|
|
684
992
|
trace_id: op.traceId,
|
|
685
993
|
span_id: op.spanId,
|
|
686
994
|
fn: op.fn,
|
|
687
|
-
started_at:
|
|
688
|
-
duration_ms:
|
|
995
|
+
started_at: op.startedAt,
|
|
996
|
+
duration_ms: op.durationMs,
|
|
689
997
|
success: op.success,
|
|
690
998
|
error: op.error ?? "",
|
|
691
999
|
input: op.inputBuf,
|
|
@@ -701,8 +1009,8 @@ function servicebridge(url, serviceKey, serviceOrOpts = {}, maybeGlobalOpts = {}
|
|
|
701
1009
|
span_id: op.spanId,
|
|
702
1010
|
fn: op.fn,
|
|
703
1011
|
service_name: service,
|
|
704
|
-
started_at:
|
|
705
|
-
duration_ms:
|
|
1012
|
+
started_at: op.startedAt,
|
|
1013
|
+
duration_ms: op.durationMs,
|
|
706
1014
|
success: op.success,
|
|
707
1015
|
error: op.error ?? "",
|
|
708
1016
|
input: op.inputBuf,
|
|
@@ -1187,7 +1495,7 @@ function servicebridge(url, serviceKey, serviceOrOpts = {}, maybeGlobalOpts = {}
|
|
|
1187
1495
|
});
|
|
1188
1496
|
});
|
|
1189
1497
|
}
|
|
1190
|
-
async function
|
|
1498
|
+
async function reconcileRegistrations(reason) {
|
|
1191
1499
|
if (!serveState)
|
|
1192
1500
|
return;
|
|
1193
1501
|
if (registrationSyncPromise) {
|
|
@@ -1195,50 +1503,44 @@ function servicebridge(url, serviceKey, serviceOrOpts = {}, maybeGlobalOpts = {}
|
|
|
1195
1503
|
}
|
|
1196
1504
|
const state = serveState;
|
|
1197
1505
|
registrationSyncPromise = (async () => {
|
|
1198
|
-
const
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
}
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
name: groupName,
|
|
1218
|
-
pattern: entry.pattern,
|
|
1219
|
-
mode: "shared",
|
|
1220
|
-
retry_policy_json: entry.opts.retryPolicyJson ?? "{}",
|
|
1221
|
-
active: true,
|
|
1222
|
-
filter_expr: entry.opts.filterExpr ?? ""
|
|
1223
|
-
}, meta, unaryDeadlineOptions(), (err) => err ? reject(normalizeServiceError(err, `register-group:${reason}:${groupName}`)) : resolve());
|
|
1224
|
-
});
|
|
1225
|
-
await new Promise((resolve, reject) => {
|
|
1226
|
-
stub.RegisterGroupMember({
|
|
1227
|
-
group_name: groupName,
|
|
1506
|
+
const functions = Array.from(fnHandlers.entries()).map(([fn, fnEntry]) => ({
|
|
1507
|
+
fn_name: fn,
|
|
1508
|
+
weight: Math.max(1, state.opts.weight ?? 1),
|
|
1509
|
+
input_schema_json: fnEntry.opts.schema?.input ? JSON.stringify(fnEntry.opts.schema.input) : "",
|
|
1510
|
+
output_schema_json: fnEntry.opts.schema?.output ? JSON.stringify(fnEntry.opts.schema.output) : "",
|
|
1511
|
+
allowed_callers: fnEntry.opts.allowedCallers ?? []
|
|
1512
|
+
}));
|
|
1513
|
+
const consumerGroups = Array.from(eventHandlers.values()).map((entry) => ({
|
|
1514
|
+
name: entry.groupName,
|
|
1515
|
+
pattern: entry.pattern,
|
|
1516
|
+
mode: "shared",
|
|
1517
|
+
retry_policy_json: entry.opts.retryPolicyJson ?? "{}",
|
|
1518
|
+
active: true,
|
|
1519
|
+
filter_expr: entry.opts.filterExpr ?? ""
|
|
1520
|
+
}));
|
|
1521
|
+
const metrics = getProcessMetrics();
|
|
1522
|
+
await new Promise((resolve, reject) => {
|
|
1523
|
+
stub.Reconcile({
|
|
1524
|
+
identity: {
|
|
1228
1525
|
service_name: service,
|
|
1229
1526
|
instance_id: state.instanceId,
|
|
1230
1527
|
endpoint: state.endpoint,
|
|
1231
|
-
transport: state.transport
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1528
|
+
transport: state.transport
|
|
1529
|
+
},
|
|
1530
|
+
functions,
|
|
1531
|
+
consumer_groups: consumerGroups,
|
|
1532
|
+
transport_mode: "TRANSPORT_MODE_DIRECT",
|
|
1533
|
+
zone: "",
|
|
1534
|
+
metrics: {
|
|
1535
|
+
cpu_percent: metrics.cpuPercent ?? null,
|
|
1536
|
+
ram_mb: metrics.ramMb ?? null
|
|
1537
|
+
}
|
|
1538
|
+
}, meta, unaryDeadlineOptions(), (err) => err ? reject(normalizeServiceError(err, `reconcile:${reason}`)) : resolve());
|
|
1539
|
+
});
|
|
1237
1540
|
registeredGroups.clear();
|
|
1238
|
-
for (const
|
|
1239
|
-
registeredGroups.add(groupName);
|
|
1541
|
+
for (const entry of eventHandlers.values()) {
|
|
1542
|
+
registeredGroups.add(entry.groupName);
|
|
1240
1543
|
}
|
|
1241
|
-
await sendHeartbeat();
|
|
1242
1544
|
})().finally(() => {
|
|
1243
1545
|
registrationSyncPromise = null;
|
|
1244
1546
|
});
|
|
@@ -1271,7 +1573,7 @@ function servicebridge(url, serviceKey, serviceOrOpts = {}, maybeGlobalOpts = {}
|
|
|
1271
1573
|
isOnline = false;
|
|
1272
1574
|
ensureChannelWatch();
|
|
1273
1575
|
} else if (isRegistryResyncRequiredError(err)) {
|
|
1274
|
-
await
|
|
1576
|
+
await reconcileRegistrations("heartbeat-resync");
|
|
1275
1577
|
} else {
|
|
1276
1578
|
reportSDKError("heartbeat", err);
|
|
1277
1579
|
}
|
|
@@ -1437,6 +1739,10 @@ function servicebridge(url, serviceKey, serviceOrOpts = {}, maybeGlobalOpts = {}
|
|
|
1437
1739
|
clearInterval(workerSessionPingTimer);
|
|
1438
1740
|
workerSessionPingTimer = null;
|
|
1439
1741
|
}
|
|
1742
|
+
if (workerSessionPositionTimer) {
|
|
1743
|
+
clearInterval(workerSessionPositionTimer);
|
|
1744
|
+
workerSessionPositionTimer = null;
|
|
1745
|
+
}
|
|
1440
1746
|
if (workerSessionReconnectTimer) {
|
|
1441
1747
|
clearTimeout(workerSessionReconnectTimer);
|
|
1442
1748
|
workerSessionReconnectTimer = null;
|
|
@@ -1505,6 +1811,10 @@ function servicebridge(url, serviceKey, serviceOrOpts = {}, maybeGlobalOpts = {}
|
|
|
1505
1811
|
result.error = normalizeUnknownErrorMessage(err);
|
|
1506
1812
|
}
|
|
1507
1813
|
stream.write({ command_result: result });
|
|
1814
|
+
if (v2Session) {
|
|
1815
|
+
v2Session.onCommandCompleted(BigInt(0), commandId);
|
|
1816
|
+
stream.write({ permit_release: { released: 1 } });
|
|
1817
|
+
}
|
|
1508
1818
|
}
|
|
1509
1819
|
function scheduleWorkerSessionReconnect(delayMs = 1000) {
|
|
1510
1820
|
if (stopped || !serveState || workerSessionReconnectTimer)
|
|
@@ -1517,22 +1827,57 @@ function servicebridge(url, serviceKey, serviceOrOpts = {}, maybeGlobalOpts = {}
|
|
|
1517
1827
|
});
|
|
1518
1828
|
}, delayMs);
|
|
1519
1829
|
}
|
|
1830
|
+
function adaptConfigPush(cp) {
|
|
1831
|
+
const tc = cp?.transport_config;
|
|
1832
|
+
if (!tc)
|
|
1833
|
+
return {};
|
|
1834
|
+
const defaultModeRaw = tc.default_mode;
|
|
1835
|
+
return {
|
|
1836
|
+
defaultMode: defaultModeRaw === "TRANSPORT_MODE_PROXY" ? "proxy" : "direct",
|
|
1837
|
+
serviceOverrides: tc.service_overrides,
|
|
1838
|
+
functionOverrides: tc.function_overrides
|
|
1839
|
+
};
|
|
1840
|
+
}
|
|
1520
1841
|
async function openWorkerSession(initial) {
|
|
1521
1842
|
if (!serveState) {
|
|
1522
1843
|
throw new Error("serve() state is not initialized");
|
|
1523
1844
|
}
|
|
1524
1845
|
if (workerSessionStream)
|
|
1525
1846
|
return;
|
|
1847
|
+
if (!v2Session) {
|
|
1848
|
+
v2Session = new V2SessionClient({
|
|
1849
|
+
serverAddress: target,
|
|
1850
|
+
serviceName: service,
|
|
1851
|
+
instanceId: serveState.instanceId,
|
|
1852
|
+
transportMode: "direct",
|
|
1853
|
+
maxInflight: serveState.maxInFlight,
|
|
1854
|
+
minInflight: 1
|
|
1855
|
+
});
|
|
1856
|
+
}
|
|
1526
1857
|
const stream = stub.OpenWorkerSession(meta);
|
|
1527
1858
|
workerSessionStream = stream;
|
|
1528
|
-
const
|
|
1859
|
+
const hf = v2Session.getHelloFields();
|
|
1860
|
+
stream.write({
|
|
1529
1861
|
hello: {
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1862
|
+
identity: {
|
|
1863
|
+
service_name: hf.identity.serviceName,
|
|
1864
|
+
instance_id: hf.identity.instanceId,
|
|
1865
|
+
endpoint: serveState.endpoint,
|
|
1866
|
+
transport: serveState.transport
|
|
1867
|
+
},
|
|
1868
|
+
max_inflight: hf.maxInflight,
|
|
1869
|
+
min_inflight: hf.minInflight,
|
|
1870
|
+
resume_token: hf.resumeToken,
|
|
1871
|
+
epoch: String(hf.epoch),
|
|
1872
|
+
last_received_seq: String(hf.lastReceivedSeq),
|
|
1873
|
+
last_sent_seq: String(hf.lastSentSeq),
|
|
1874
|
+
completed_command_ids: hf.completedCommandIds,
|
|
1875
|
+
sdk_version: hf.sdkVersion,
|
|
1876
|
+
capabilities: hf.capabilities,
|
|
1877
|
+
zone: hf.zone,
|
|
1878
|
+
transport_mode: hf.transportMode === "proxy" ? "TRANSPORT_MODE_PROXY" : "TRANSPORT_MODE_DIRECT"
|
|
1533
1879
|
}
|
|
1534
|
-
};
|
|
1535
|
-
stream.write(hello);
|
|
1880
|
+
});
|
|
1536
1881
|
workerSessionPingTimer = setInterval(() => {
|
|
1537
1882
|
if (!workerSessionStream)
|
|
1538
1883
|
return;
|
|
@@ -1540,7 +1885,50 @@ function servicebridge(url, serviceKey, serviceOrOpts = {}, maybeGlobalOpts = {}
|
|
|
1540
1885
|
ping: { timestamp_unix_ms: Date.now() }
|
|
1541
1886
|
});
|
|
1542
1887
|
}, 1e4);
|
|
1888
|
+
workerSessionPositionTimer = setInterval(() => {
|
|
1889
|
+
if (!workerSessionStream || !v2Session)
|
|
1890
|
+
return;
|
|
1891
|
+
workerSessionStream.write({
|
|
1892
|
+
position_update: {
|
|
1893
|
+
last_received_seq: String(v2Session.position.lastReceivedSeq)
|
|
1894
|
+
}
|
|
1895
|
+
});
|
|
1896
|
+
}, 5000);
|
|
1543
1897
|
stream.on("data", (msg) => {
|
|
1898
|
+
if (msg.seq !== undefined && v2Session) {
|
|
1899
|
+
v2Session.position.recordReceived(BigInt(msg.seq));
|
|
1900
|
+
}
|
|
1901
|
+
if (msg.hello_ack && v2Session) {
|
|
1902
|
+
const ack = msg.hello_ack;
|
|
1903
|
+
const helloAck = {
|
|
1904
|
+
sessionId: ack.session_id ?? "",
|
|
1905
|
+
resumeToken: ack.resume_token ?? "",
|
|
1906
|
+
epoch: BigInt(ack.epoch ?? "0"),
|
|
1907
|
+
resumed: ack.resumed ?? false,
|
|
1908
|
+
resumeFromSeq: BigInt(ack.resume_from_seq ?? "0"),
|
|
1909
|
+
replayedCommands: ack.replayed_commands ?? 0,
|
|
1910
|
+
reconciledResults: ack.reconciled_results ?? 0,
|
|
1911
|
+
heartbeatIntervalMs: ack.heartbeat_interval_ms ?? 1e4,
|
|
1912
|
+
heartbeatTimeoutMs: ack.heartbeat_timeout_ms ?? 30000,
|
|
1913
|
+
initialPermits: ack.initial_permits ?? 0,
|
|
1914
|
+
maxPermits: ack.max_permits ?? 0,
|
|
1915
|
+
effectiveTransportMode: ack.effective_transport_mode === "TRANSPORT_MODE_PROXY" ? "proxy" : "direct",
|
|
1916
|
+
resumeFailReason: ack.resume_fail_reason
|
|
1917
|
+
};
|
|
1918
|
+
v2Session.onHelloAck(helloAck);
|
|
1919
|
+
}
|
|
1920
|
+
if (msg.goaway && v2Session) {
|
|
1921
|
+
v2Session.onGoaway(msg.goaway.code ?? "", msg.goaway.reason ?? "");
|
|
1922
|
+
}
|
|
1923
|
+
if (msg.config_push && v2Session) {
|
|
1924
|
+
v2Session.onConfigPush(adaptConfigPush(msg.config_push));
|
|
1925
|
+
}
|
|
1926
|
+
if (msg.permit_grant && v2Session) {
|
|
1927
|
+
v2Session.onPermitGrant(msg.permit_grant.additional_permits ?? 0);
|
|
1928
|
+
}
|
|
1929
|
+
if (msg.flow_update && v2Session) {
|
|
1930
|
+
v2Session.onFlowControlUpdate(msg.flow_update.new_window_size ?? 0, msg.flow_update.reason ?? "");
|
|
1931
|
+
}
|
|
1544
1932
|
if (msg.command) {
|
|
1545
1933
|
processWorkerSessionCommand(msg.command).catch((err) => {
|
|
1546
1934
|
reportSDKError("worker-session-command", err);
|
|
@@ -1554,6 +1942,10 @@ function servicebridge(url, serviceKey, serviceOrOpts = {}, maybeGlobalOpts = {}
|
|
|
1554
1942
|
clearInterval(workerSessionPingTimer);
|
|
1555
1943
|
workerSessionPingTimer = null;
|
|
1556
1944
|
}
|
|
1945
|
+
if (workerSessionPositionTimer) {
|
|
1946
|
+
clearInterval(workerSessionPositionTimer);
|
|
1947
|
+
workerSessionPositionTimer = null;
|
|
1948
|
+
}
|
|
1557
1949
|
workerSessionStream = null;
|
|
1558
1950
|
if (err != null) {
|
|
1559
1951
|
reportSDKError(operation, err);
|
|
@@ -1764,7 +2156,6 @@ function servicebridge(url, serviceKey, serviceOrOpts = {}, maybeGlobalOpts = {}
|
|
|
1764
2156
|
}
|
|
1765
2157
|
server.bindAsync(`${host}:0`, serverCreds, (err, p) => err ? reject(err) : resolve(p));
|
|
1766
2158
|
});
|
|
1767
|
-
workerServer.start();
|
|
1768
2159
|
const endpoint = `${advertiseHost}:${port}`;
|
|
1769
2160
|
const instanceId = opts.instanceId || Array.from(crypto.getRandomValues(new Uint8Array(3))).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
1770
2161
|
serveState = {
|
|
@@ -1781,7 +2172,7 @@ function servicebridge(url, serviceKey, serviceOrOpts = {}, maybeGlobalOpts = {}
|
|
|
1781
2172
|
serverName: tlsOpts?.serverName
|
|
1782
2173
|
};
|
|
1783
2174
|
await openWorkerSession(true);
|
|
1784
|
-
await
|
|
2175
|
+
await reconcileRegistrations("initial");
|
|
1785
2176
|
scheduleNextHeartbeat();
|
|
1786
2177
|
await _controlReady;
|
|
1787
2178
|
} catch (err) {
|
|
@@ -2238,8 +2629,15 @@ function captureConsole(svc) {
|
|
|
2238
2629
|
}
|
|
2239
2630
|
}
|
|
2240
2631
|
export {
|
|
2632
|
+
validateV2Config,
|
|
2241
2633
|
servicebridge,
|
|
2242
2634
|
runWithTraceContext,
|
|
2243
2635
|
getTraceContext,
|
|
2244
|
-
|
|
2636
|
+
V2SessionClient,
|
|
2637
|
+
ServiceBridgeError,
|
|
2638
|
+
PositionTrackerV2,
|
|
2639
|
+
FlowControlStateV2,
|
|
2640
|
+
ConfigPushStateV2,
|
|
2641
|
+
BackoffV2,
|
|
2642
|
+
AdaptiveHeartbeatV2
|
|
2245
2643
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "service-bridge",
|
|
3
|
-
"version": "1.1.1-dev.
|
|
3
|
+
"version": "1.1.1-dev.30",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "ServiceBridge SDK for Node.js — production-ready RPC, durable events, workflows, jobs, and distributed tracing. One Go runtime + PostgreSQL replaces Istio, RabbitMQ, Temporal, and Jaeger.",
|
|
6
6
|
"keywords": [
|