fix: 修复QQBot插件Worker并发恢复
This commit is contained in:
parent
2c6e845995
commit
5498e2e52f
@ -55,6 +55,14 @@ QQBOT_SEND_GLOBAL_INTERVAL_MS=2500
|
|||||||
QQBOT_SEND_TARGET_INTERVAL_MS=8000
|
QQBOT_SEND_TARGET_INTERVAL_MS=8000
|
||||||
QQBOT_SEND_JITTER_MS=800
|
QQBOT_SEND_JITTER_MS=800
|
||||||
QQBOT_SEND_MAX_QUEUE_WAIT_MS=30000
|
QQBOT_SEND_MAX_QUEUE_WAIT_MS=30000
|
||||||
|
# K8s production can use QQBOT_PLUGIN_QUEUE_REDIS_HOST=kt-qqbot-plugin-redis.
|
||||||
|
QQBOT_PLUGIN_QUEUE_REDIS_HOST=127.0.0.1
|
||||||
|
QQBOT_PLUGIN_QUEUE_REDIS_PORT=6379
|
||||||
|
QQBOT_PLUGIN_QUEUE_REDIS_PASSWORD=
|
||||||
|
QQBOT_PLUGIN_QUEUE_REDIS_DB=0
|
||||||
|
QQBOT_PLUGIN_QUEUE_REDIS_PREFIX=kt:qqbot:plugin-worker
|
||||||
|
QQBOT_PLUGIN_QUEUE_REMOVE_ON_FAIL=100
|
||||||
|
QQBOT_PLUGIN_QUEUE_WAIT_BUFFER_MS=5000
|
||||||
QQBOT_COMMAND_MIN_COOLDOWN_MS=5000
|
QQBOT_COMMAND_MIN_COOLDOWN_MS=5000
|
||||||
QQBOT_RULE_MIN_COOLDOWN_MS=30000
|
QQBOT_RULE_MIN_COOLDOWN_MS=30000
|
||||||
QQBOT_REPEATER_THRESHOLD=4
|
QQBOT_REPEATER_THRESHOLD=4
|
||||||
|
|||||||
4
API.md
4
API.md
@ -85,7 +85,7 @@ Admin、Component、Dict、MinIO、Blog 管理、WordPress 管理和 QQBot 管
|
|||||||
| Admin | `ADMIN_TOKEN_SECRET`、`ADMIN_COOKIE_SECURE`、`SNOWFLAKE_WORKER_ID`、`SNOWFLAKE_DATACENTER_ID` |
|
| Admin | `ADMIN_TOKEN_SECRET`、`ADMIN_COOKIE_SECURE`、`SNOWFLAKE_WORKER_ID`、`SNOWFLAKE_DATACENTER_ID` |
|
||||||
| WordPress | `WORDPRESS_BASE_URL`、`WORDPRESS_HOST_HEADER`、`WORDPRESS_ADMIN_USERNAME`、`WORDPRESS_ADMIN_PASSWORD` |
|
| WordPress | `WORDPRESS_BASE_URL`、`WORDPRESS_HOST_HEADER`、`WORDPRESS_ADMIN_USERNAME`、`WORDPRESS_ADMIN_PASSWORD` |
|
||||||
| Loki | `LOG_LEVEL`、`LOG_APP_NAME`、`LOKI_URL`、`LOKI_QUERY_HOST`、`LOKI_QUERY_SELECTOR` |
|
| Loki | `LOG_LEVEL`、`LOG_APP_NAME`、`LOKI_URL`、`LOKI_QUERY_HOST`、`LOKI_QUERY_SELECTOR` |
|
||||||
| QQBot | `QQBOT_ENABLED`、`QQBOT_ACCOUNT_SECRET_KEY`、`QQBOT_REVERSE_WS_PATH`、`QQBOT_REVERSE_WS_TOKEN`、`QQBOT_EVENT_BUS`、`QQBOT_SEND_*`、`QQBOT_COMMAND_MIN_COOLDOWN_MS`、`QQBOT_RULE_MIN_COOLDOWN_MS`、`QQBOT_REPEATER_*` |
|
| QQBot | `QQBOT_ENABLED`、`QQBOT_ACCOUNT_SECRET_KEY`、`QQBOT_REVERSE_WS_PATH`、`QQBOT_REVERSE_WS_TOKEN`、`QQBOT_EVENT_BUS`、`QQBOT_SEND_*`、`QQBOT_PLUGIN_QUEUE_REDIS_*`、`QQBOT_COMMAND_MIN_COOLDOWN_MS`、`QQBOT_RULE_MIN_COOLDOWN_MS`、`QQBOT_REPEATER_*` |
|
||||||
| NapCat | `NAPCAT_WEBUI_BASE_URL`、`NAPCAT_WEBUI_TOKEN`、`QQBOT_NAPCAT_*` |
|
| NapCat | `NAPCAT_WEBUI_BASE_URL`、`NAPCAT_WEBUI_TOKEN`、`QQBOT_NAPCAT_*` |
|
||||||
| MQTT | `MQTT_URL`、`MQTT_USERNAME`、`MQTT_PASSWORD`、`MQTT_CLIENT_ID` |
|
| MQTT | `MQTT_URL`、`MQTT_USERNAME`、`MQTT_PASSWORD`、`MQTT_CLIENT_ID` |
|
||||||
| BangDream | `BANGDREAM_TSUGU_MAIN_SERVER`、`BANGDREAM_TSUGU_DISPLAYED_SERVERS`、`BANGDREAM_TSUGU_CACHE_ROOT` |
|
| BangDream | `BANGDREAM_TSUGU_MAIN_SERVER`、`BANGDREAM_TSUGU_DISPLAYED_SERVERS`、`BANGDREAM_TSUGU_CACHE_ROOT` |
|
||||||
@ -94,6 +94,8 @@ Admin、Component、Dict、MinIO、Blog 管理、WordPress 管理和 QQBot 管
|
|||||||
|
|
||||||
真实密码、Token、OAuth secret 和生产 env 不提交到 Git。
|
真实密码、Token、OAuth secret 和生产 env 不提交到 Git。
|
||||||
|
|
||||||
|
QQBot 插件 worker 队列依赖 Redis。K8s 生产清单提供内部 Redis Service `kt-qqbot-plugin-redis:6379`,用于 `QQBOT_PLUGIN_QUEUE_REDIS_HOST` / `QQBOT_PLUGIN_QUEUE_REDIS_PORT`。
|
||||||
|
|
||||||
## Admin 与基础后台
|
## Admin 与基础后台
|
||||||
|
|
||||||
### Auth / User
|
### Auth / User
|
||||||
|
|||||||
2
Jenkinsfile
vendored
2
Jenkinsfile
vendored
@ -36,6 +36,8 @@ def requiredRuntimeEnvKeys() {
|
|||||||
'WORDPRESS_BASE_URL',
|
'WORDPRESS_BASE_URL',
|
||||||
'FFLOGS_CLIENT_ID',
|
'FFLOGS_CLIENT_ID',
|
||||||
'FFLOGS_CLIENT_SECRET',
|
'FFLOGS_CLIENT_SECRET',
|
||||||
|
'QQBOT_PLUGIN_QUEUE_REDIS_HOST',
|
||||||
|
'QQBOT_PLUGIN_QUEUE_REDIS_PORT',
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -62,13 +62,15 @@ ci/ Jenkins Agent/Docker 辅助文件
|
|||||||
| Admin | `ADMIN_TOKEN_SECRET`、`ADMIN_COOKIE_SECURE`、`SNOWFLAKE_WORKER_ID`、`SNOWFLAKE_DATACENTER_ID` |
|
| Admin | `ADMIN_TOKEN_SECRET`、`ADMIN_COOKIE_SECURE`、`SNOWFLAKE_WORKER_ID`、`SNOWFLAKE_DATACENTER_ID` |
|
||||||
| WordPress | `WORDPRESS_BASE_URL`、`WORDPRESS_HOST_HEADER`、`WORDPRESS_ADMIN_USERNAME`、`WORDPRESS_ADMIN_PASSWORD`、`WORDPRESS_*_TIMEOUT_MS` |
|
| WordPress | `WORDPRESS_BASE_URL`、`WORDPRESS_HOST_HEADER`、`WORDPRESS_ADMIN_USERNAME`、`WORDPRESS_ADMIN_PASSWORD`、`WORDPRESS_*_TIMEOUT_MS` |
|
||||||
| Logging/Loki | `LOG_LEVEL`、`LOG_APP_NAME`、`LOKI_URL`、`LOKI_QUERY_HOST`、`LOKI_*` |
|
| Logging/Loki | `LOG_LEVEL`、`LOG_APP_NAME`、`LOKI_URL`、`LOKI_QUERY_HOST`、`LOKI_*` |
|
||||||
| QQBot/NapCat | `QQBOT_ENABLED`、`QQBOT_ACCOUNT_SECRET_KEY`、`QQBOT_REVERSE_WS_*`、`QQBOT_SEND_*`、`QQBOT_COMMAND_MIN_COOLDOWN_MS`、`QQBOT_RULE_MIN_COOLDOWN_MS`、`QQBOT_REPEATER_*`、`NAPCAT_*`、`QQBOT_NAPCAT_*`、`MQTT_*` |
|
| QQBot/NapCat | `QQBOT_ENABLED`、`QQBOT_ACCOUNT_SECRET_KEY`、`QQBOT_REVERSE_WS_*`、`QQBOT_SEND_*`、`QQBOT_PLUGIN_QUEUE_REDIS_*`、`QQBOT_COMMAND_MIN_COOLDOWN_MS`、`QQBOT_RULE_MIN_COOLDOWN_MS`、`QQBOT_REPEATER_*`、`NAPCAT_*`、`QQBOT_NAPCAT_*`、`MQTT_*` |
|
||||||
| BangDream | `BANGDREAM_TSUGU_MAIN_SERVER`、`BANGDREAM_TSUGU_DISPLAYED_SERVERS`、`BANGDREAM_TSUGU_CACHE_ROOT` |
|
| BangDream | `BANGDREAM_TSUGU_MAIN_SERVER`、`BANGDREAM_TSUGU_DISPLAYED_SERVERS`、`BANGDREAM_TSUGU_CACHE_ROOT` |
|
||||||
| FF14 Market | `FF14_XIVAPI_BASE_URL`、`FF14_UNIVERSALIS_BASE_URL`、`FF14_MARKET_CACHE_TTL_MS` |
|
| FF14 Market | `FF14_XIVAPI_BASE_URL`、`FF14_UNIVERSALIS_BASE_URL`、`FF14_MARKET_CACHE_TTL_MS` |
|
||||||
| FFLogs | `FFLOGS_BASE_URL`、`FFLOGS_GRAPHQL_URL`、`FFLOGS_TOKEN_URL`、`FFLOGS_CLIENT_ID`、`FFLOGS_CLIENT_SECRET` |
|
| FFLogs | `FFLOGS_BASE_URL`、`FFLOGS_GRAPHQL_URL`、`FFLOGS_TOKEN_URL`、`FFLOGS_CLIENT_ID`、`FFLOGS_CLIENT_SECRET` |
|
||||||
|
|
||||||
`DB_SYNC=true` 只适合本地开发或明确允许自动同步表结构的环境;生产应关闭并使用 SQL/迁移脚本。
|
`DB_SYNC=true` 只适合本地开发或明确允许自动同步表结构的环境;生产应关闭并使用 SQL/迁移脚本。
|
||||||
|
|
||||||
|
QQBot 插件 worker 使用 BullMQ 队列串行执行同一插件安装实例的请求。K8s 生产清单包含内部服务 `kt-qqbot-plugin-redis`,生产 env 可将 `QQBOT_PLUGIN_QUEUE_REDIS_HOST` 配为该服务名。
|
||||||
|
|
||||||
## 启动
|
## 启动
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@ -9,10 +9,7 @@ spec:
|
|||||||
replicas: 1
|
replicas: 1
|
||||||
revisionHistoryLimit: 3
|
revisionHistoryLimit: 3
|
||||||
strategy:
|
strategy:
|
||||||
type: RollingUpdate
|
type: Recreate
|
||||||
rollingUpdate:
|
|
||||||
maxSurge: 1
|
|
||||||
maxUnavailable: 0
|
|
||||||
selector:
|
selector:
|
||||||
matchLabels:
|
matchLabels:
|
||||||
app: kt-template-online-api
|
app: kt-template-online-api
|
||||||
@ -87,3 +84,78 @@ spec:
|
|||||||
port: 48085
|
port: 48085
|
||||||
targetPort: 48085
|
targetPort: 48085
|
||||||
nodePort: 30085
|
nodePort: 30085
|
||||||
|
---
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: kt-qqbot-plugin-redis
|
||||||
|
namespace: kt-prod
|
||||||
|
labels:
|
||||||
|
app: kt-qqbot-plugin-redis
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
revisionHistoryLimit: 2
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: kt-qqbot-plugin-redis
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: kt-qqbot-plugin-redis
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: redis
|
||||||
|
image: redis:7.4-alpine
|
||||||
|
imagePullPolicy: IfNotPresent
|
||||||
|
args:
|
||||||
|
- redis-server
|
||||||
|
- --appendonly
|
||||||
|
- "no"
|
||||||
|
- --save
|
||||||
|
- ""
|
||||||
|
ports:
|
||||||
|
- name: redis
|
||||||
|
containerPort: 6379
|
||||||
|
readinessProbe:
|
||||||
|
tcpSocket:
|
||||||
|
port: 6379
|
||||||
|
initialDelaySeconds: 3
|
||||||
|
periodSeconds: 5
|
||||||
|
timeoutSeconds: 2
|
||||||
|
failureThreshold: 6
|
||||||
|
livenessProbe:
|
||||||
|
tcpSocket:
|
||||||
|
port: 6379
|
||||||
|
initialDelaySeconds: 15
|
||||||
|
periodSeconds: 10
|
||||||
|
timeoutSeconds: 2
|
||||||
|
failureThreshold: 3
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 25m
|
||||||
|
memory: 64Mi
|
||||||
|
limits:
|
||||||
|
cpu: 250m
|
||||||
|
memory: 256Mi
|
||||||
|
volumeMounts:
|
||||||
|
- name: redis-data
|
||||||
|
mountPath: /data
|
||||||
|
volumes:
|
||||||
|
- name: redis-data
|
||||||
|
emptyDir: {}
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: kt-qqbot-plugin-redis
|
||||||
|
namespace: kt-prod
|
||||||
|
labels:
|
||||||
|
app: kt-qqbot-plugin-redis
|
||||||
|
spec:
|
||||||
|
type: ClusterIP
|
||||||
|
selector:
|
||||||
|
app: kt-qqbot-plugin-redis
|
||||||
|
ports:
|
||||||
|
- name: redis
|
||||||
|
port: 6379
|
||||||
|
targetPort: 6379
|
||||||
|
|||||||
@ -34,6 +34,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kwitsukasa/knife4j-swagger-vue3": "0.1.2",
|
"@kwitsukasa/knife4j-swagger-vue3": "0.1.2",
|
||||||
|
"@nestjs/bullmq": "11.0.4",
|
||||||
"@nestjs/common": "^11.1.24",
|
"@nestjs/common": "^11.1.24",
|
||||||
"@nestjs/config": "^4.0.4",
|
"@nestjs/config": "^4.0.4",
|
||||||
"@nestjs/core": "^11.1.24",
|
"@nestjs/core": "^11.1.24",
|
||||||
@ -41,6 +42,7 @@
|
|||||||
"@nestjs/swagger": "^11.4.4",
|
"@nestjs/swagger": "^11.4.4",
|
||||||
"@nestjs/typeorm": "^11.0.1",
|
"@nestjs/typeorm": "^11.0.1",
|
||||||
"axios": "^1.17.0",
|
"axios": "^1.17.0",
|
||||||
|
"bullmq": "5.78.1",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
"chartjs-adapter-moment": "^1.0.1",
|
"chartjs-adapter-moment": "^1.0.1",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
|
|||||||
223
pnpm-lock.yaml
223
pnpm-lock.yaml
@ -11,6 +11,9 @@ importers:
|
|||||||
'@kwitsukasa/knife4j-swagger-vue3':
|
'@kwitsukasa/knife4j-swagger-vue3':
|
||||||
specifier: 0.1.2
|
specifier: 0.1.2
|
||||||
version: 0.1.2(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))
|
version: 0.1.2(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))
|
||||||
|
'@nestjs/bullmq':
|
||||||
|
specifier: 11.0.4
|
||||||
|
version: 11.0.4(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(bullmq@5.78.1)
|
||||||
'@nestjs/common':
|
'@nestjs/common':
|
||||||
specifier: ^11.1.24
|
specifier: ^11.1.24
|
||||||
version: 11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
version: 11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
@ -28,10 +31,13 @@ importers:
|
|||||||
version: 11.4.4(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)
|
version: 11.4.4(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)
|
||||||
'@nestjs/typeorm':
|
'@nestjs/typeorm':
|
||||||
specifier: ^11.0.1
|
specifier: ^11.0.1
|
||||||
version: 11.0.1(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(mysql2@3.22.4(@types/node@22.19.19))(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.3)))
|
version: 11.0.1(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(ioredis@5.10.1)(mysql2@3.22.4(@types/node@22.19.19))(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.3)))
|
||||||
axios:
|
axios:
|
||||||
specifier: ^1.17.0
|
specifier: ^1.17.0
|
||||||
version: 1.17.0
|
version: 1.17.0
|
||||||
|
bullmq:
|
||||||
|
specifier: 5.78.1
|
||||||
|
version: 5.78.1
|
||||||
chart.js:
|
chart.js:
|
||||||
specifier: ^4.5.1
|
specifier: ^4.5.1
|
||||||
version: 4.5.1
|
version: 4.5.1
|
||||||
@ -118,7 +124,7 @@ importers:
|
|||||||
version: 1.0.0-beta.2
|
version: 1.0.0-beta.2
|
||||||
typeorm:
|
typeorm:
|
||||||
specifier: ^0.3.28
|
specifier: ^0.3.28
|
||||||
version: 0.3.30(mysql2@3.22.4(@types/node@22.19.19))(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.3))
|
version: 0.3.30(ioredis@5.10.1)(mysql2@3.22.4(@types/node@22.19.19))(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.3))
|
||||||
unified:
|
unified:
|
||||||
specifier: ^11.0.5
|
specifier: ^11.0.5
|
||||||
version: 11.0.5
|
version: 11.0.5
|
||||||
@ -585,6 +591,9 @@ packages:
|
|||||||
'@types/node':
|
'@types/node':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@ioredis/commands@1.5.1':
|
||||||
|
resolution: {integrity: sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==}
|
||||||
|
|
||||||
'@isaacs/cliui@8.0.2':
|
'@isaacs/cliui@8.0.2':
|
||||||
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
|
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@ -869,6 +878,49 @@ packages:
|
|||||||
'@microsoft/tsdoc@0.16.0':
|
'@microsoft/tsdoc@0.16.0':
|
||||||
resolution: {integrity: sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==}
|
resolution: {integrity: sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==}
|
||||||
|
|
||||||
|
'@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4':
|
||||||
|
resolution: {integrity: sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4':
|
||||||
|
resolution: {integrity: sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4':
|
||||||
|
resolution: {integrity: sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4':
|
||||||
|
resolution: {integrity: sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4':
|
||||||
|
resolution: {integrity: sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4':
|
||||||
|
resolution: {integrity: sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@nestjs/bull-shared@11.0.4':
|
||||||
|
resolution: {integrity: sha512-VBJcDHSAzxQnpcDfA0kt9MTGUD1XZzfByV70su0W0eDCQ9aqIEBlzWRW21tv9FG9dIut22ysgDidshdjlnczLw==}
|
||||||
|
peerDependencies:
|
||||||
|
'@nestjs/common': ^10.0.0 || ^11.0.0
|
||||||
|
'@nestjs/core': ^10.0.0 || ^11.0.0
|
||||||
|
|
||||||
|
'@nestjs/bullmq@11.0.4':
|
||||||
|
resolution: {integrity: sha512-wBzK9raAVG0/6NTMdvLGM4/FQ1lsB35/pYS8L6a0SDgkTiLpd7mAjQ8R692oMx5s7IjvgntaZOuTUrKYLNfIkA==}
|
||||||
|
peerDependencies:
|
||||||
|
'@nestjs/common': ^10.0.0 || ^11.0.0
|
||||||
|
'@nestjs/core': ^10.0.0 || ^11.0.0
|
||||||
|
bullmq: ^3.0.0 || ^4.0.0 || ^5.0.0
|
||||||
|
|
||||||
'@nestjs/cli@11.0.21':
|
'@nestjs/cli@11.0.21':
|
||||||
resolution: {integrity: sha512-F8mV0Sj/zVEouzR3NxBuJy08YHTUOmC5Xdcx3qIIaJWzrm8Vw86CHkhkaPBJ5ewRMHPDCShPmhsfwhpCcjts3A==}
|
resolution: {integrity: sha512-F8mV0Sj/zVEouzR3NxBuJy08YHTUOmC5Xdcx3qIIaJWzrm8Vw86CHkhkaPBJ5ewRMHPDCShPmhsfwhpCcjts3A==}
|
||||||
engines: {node: '>= 20.11'}
|
engines: {node: '>= 20.11'}
|
||||||
@ -1652,6 +1704,15 @@ packages:
|
|||||||
buffer@6.0.3:
|
buffer@6.0.3:
|
||||||
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
|
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
|
||||||
|
|
||||||
|
bullmq@5.78.1:
|
||||||
|
resolution: {integrity: sha512-zD5IT+qMqbMgPFPdL9FwnZka1bz6nckM+5lXj4N0vsXqdzoVO6wizmXpwsg/4GnHmXJsL7XOKeWA64tYUdPrOA==}
|
||||||
|
engines: {node: '>=12.22.0'}
|
||||||
|
peerDependencies:
|
||||||
|
redis: '>=5.0.0'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
redis:
|
||||||
|
optional: true
|
||||||
|
|
||||||
busboy@1.6.0:
|
busboy@1.6.0:
|
||||||
resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==}
|
resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==}
|
||||||
engines: {node: '>=10.16.0'}
|
engines: {node: '>=10.16.0'}
|
||||||
@ -1777,6 +1838,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==}
|
resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==}
|
||||||
engines: {node: '>=0.8'}
|
engines: {node: '>=0.8'}
|
||||||
|
|
||||||
|
cluster-key-slot@1.1.2:
|
||||||
|
resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
co@4.6.0:
|
co@4.6.0:
|
||||||
resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==}
|
resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==}
|
||||||
engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'}
|
engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'}
|
||||||
@ -1892,6 +1957,10 @@ packages:
|
|||||||
create-require@1.1.1:
|
create-require@1.1.1:
|
||||||
resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==}
|
resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==}
|
||||||
|
|
||||||
|
cron-parser@4.9.0:
|
||||||
|
resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==}
|
||||||
|
engines: {node: '>=12.0.0'}
|
||||||
|
|
||||||
cross-env@7.0.3:
|
cross-env@7.0.3:
|
||||||
resolution: {integrity: sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==}
|
resolution: {integrity: sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==}
|
||||||
engines: {node: '>=10.14', npm: '>=6', yarn: '>=1'}
|
engines: {node: '>=10.14', npm: '>=6', yarn: '>=1'}
|
||||||
@ -2575,6 +2644,10 @@ packages:
|
|||||||
inherits@2.0.4:
|
inherits@2.0.4:
|
||||||
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
|
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
|
||||||
|
|
||||||
|
ioredis@5.10.1:
|
||||||
|
resolution: {integrity: sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==}
|
||||||
|
engines: {node: '>=12.22.0'}
|
||||||
|
|
||||||
ip-address@10.2.0:
|
ip-address@10.2.0:
|
||||||
resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==}
|
resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==}
|
||||||
engines: {node: '>= 12'}
|
engines: {node: '>= 12'}
|
||||||
@ -2932,6 +3005,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
|
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
lodash.defaults@4.2.0:
|
||||||
|
resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==}
|
||||||
|
|
||||||
|
lodash.isarguments@3.1.0:
|
||||||
|
resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==}
|
||||||
|
|
||||||
lodash.memoize@4.1.2:
|
lodash.memoize@4.1.2:
|
||||||
resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==}
|
resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==}
|
||||||
|
|
||||||
@ -2965,6 +3044,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==}
|
resolution: {integrity: sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==}
|
||||||
engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'}
|
engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'}
|
||||||
|
|
||||||
|
luxon@3.7.2:
|
||||||
|
resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
magic-string@0.30.17:
|
magic-string@0.30.17:
|
||||||
resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==}
|
resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==}
|
||||||
|
|
||||||
@ -3234,6 +3317,13 @@ packages:
|
|||||||
ms@2.1.3:
|
ms@2.1.3:
|
||||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||||
|
|
||||||
|
msgpackr-extract@3.0.4:
|
||||||
|
resolution: {integrity: sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
msgpackr@2.0.2:
|
||||||
|
resolution: {integrity: sha512-c5hYOXFbP79Slh6Dzd2wzk+jnV7mX1UxfMYtilnY1NmalXPqG8DGb5cYCMBrW4AsH3zekBBZd4QrKz9NhtvYLQ==}
|
||||||
|
|
||||||
multer@2.1.1:
|
multer@2.1.1:
|
||||||
resolution: {integrity: sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==}
|
resolution: {integrity: sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==}
|
||||||
engines: {node: '>= 10.16.0'}
|
engines: {node: '>= 10.16.0'}
|
||||||
@ -3292,6 +3382,10 @@ packages:
|
|||||||
encoding:
|
encoding:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
node-gyp-build-optional-packages@5.2.2:
|
||||||
|
resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
node-int64@0.4.0:
|
node-int64@0.4.0:
|
||||||
resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==}
|
resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==}
|
||||||
|
|
||||||
@ -3625,6 +3719,14 @@ packages:
|
|||||||
real-require@1.0.0:
|
real-require@1.0.0:
|
||||||
resolution: {integrity: sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==}
|
resolution: {integrity: sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==}
|
||||||
|
|
||||||
|
redis-errors@1.2.0:
|
||||||
|
resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==}
|
||||||
|
engines: {node: '>=4'}
|
||||||
|
|
||||||
|
redis-parser@3.0.0:
|
||||||
|
resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==}
|
||||||
|
engines: {node: '>=4'}
|
||||||
|
|
||||||
reflect-metadata@0.1.14:
|
reflect-metadata@0.1.14:
|
||||||
resolution: {integrity: sha512-ZhYeb6nRaXCfhnndflDK8qI6ZQ/YcWZCISRAWICW9XYqMUwjZM9Z0DveWX/ABN01oxSHwVxKQmxeYZSsm0jh5A==}
|
resolution: {integrity: sha512-ZhYeb6nRaXCfhnndflDK8qI6ZQ/YcWZCISRAWICW9XYqMUwjZM9Z0DveWX/ABN01oxSHwVxKQmxeYZSsm0jh5A==}
|
||||||
|
|
||||||
@ -3762,6 +3864,11 @@ packages:
|
|||||||
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
|
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
semver@7.8.1:
|
||||||
|
resolution: {integrity: sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==}
|
||||||
|
engines: {node: '>=10'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
semver@7.8.2:
|
semver@7.8.2:
|
||||||
resolution: {integrity: sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ==}
|
resolution: {integrity: sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@ -3896,6 +4003,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==}
|
resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
standard-as-callback@2.1.0:
|
||||||
|
resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==}
|
||||||
|
|
||||||
statuses@2.0.2:
|
statuses@2.0.2:
|
||||||
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
|
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
@ -4976,6 +5086,8 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 22.19.19
|
'@types/node': 22.19.19
|
||||||
|
|
||||||
|
'@ioredis/commands@1.5.1': {}
|
||||||
|
|
||||||
'@isaacs/cliui@8.0.2':
|
'@isaacs/cliui@8.0.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
string-width: 5.1.2
|
string-width: 5.1.2
|
||||||
@ -5460,6 +5572,38 @@ snapshots:
|
|||||||
|
|
||||||
'@microsoft/tsdoc@0.16.0': {}
|
'@microsoft/tsdoc@0.16.0': {}
|
||||||
|
|
||||||
|
'@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@nestjs/bull-shared@11.0.4(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)':
|
||||||
|
dependencies:
|
||||||
|
'@nestjs/common': 11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
|
'@nestjs/core': 11.1.24(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@nestjs/bullmq@11.0.4(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(bullmq@5.78.1)':
|
||||||
|
dependencies:
|
||||||
|
'@nestjs/bull-shared': 11.0.4(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)
|
||||||
|
'@nestjs/common': 11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
|
'@nestjs/core': 11.1.24(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
|
bullmq: 5.78.1
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@nestjs/cli@11.0.21(@types/node@22.19.19)(prettier@3.8.3)':
|
'@nestjs/cli@11.0.21(@types/node@22.19.19)(prettier@3.8.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@angular-devkit/core': 19.2.24(chokidar@4.0.3)
|
'@angular-devkit/core': 19.2.24(chokidar@4.0.3)
|
||||||
@ -5580,13 +5724,13 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@nestjs/platform-express': 11.1.24(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)
|
'@nestjs/platform-express': 11.1.24(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)
|
||||||
|
|
||||||
'@nestjs/typeorm@11.0.1(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(mysql2@3.22.4(@types/node@22.19.19))(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.3)))':
|
'@nestjs/typeorm@11.0.1(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(ioredis@5.10.1)(mysql2@3.22.4(@types/node@22.19.19))(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.3)))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@nestjs/common': 11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
'@nestjs/common': 11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
'@nestjs/core': 11.1.24(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
'@nestjs/core': 11.1.24(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
reflect-metadata: 0.2.2
|
reflect-metadata: 0.2.2
|
||||||
rxjs: 7.8.2
|
rxjs: 7.8.2
|
||||||
typeorm: 0.3.30(mysql2@3.22.4(@types/node@22.19.19))(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.3))
|
typeorm: 0.3.30(ioredis@5.10.1)(mysql2@3.22.4(@types/node@22.19.19))(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.3))
|
||||||
|
|
||||||
'@noble/hashes@1.8.0': {}
|
'@noble/hashes@1.8.0': {}
|
||||||
|
|
||||||
@ -6330,6 +6474,17 @@ snapshots:
|
|||||||
base64-js: 1.5.1
|
base64-js: 1.5.1
|
||||||
ieee754: 1.2.1
|
ieee754: 1.2.1
|
||||||
|
|
||||||
|
bullmq@5.78.1:
|
||||||
|
dependencies:
|
||||||
|
cron-parser: 4.9.0
|
||||||
|
ioredis: 5.10.1
|
||||||
|
msgpackr: 2.0.2
|
||||||
|
node-abort-controller: 3.1.1
|
||||||
|
semver: 7.8.1
|
||||||
|
tslib: 2.8.1
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
busboy@1.6.0:
|
busboy@1.6.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
streamsearch: 1.1.0
|
streamsearch: 1.1.0
|
||||||
@ -6440,6 +6595,8 @@ snapshots:
|
|||||||
|
|
||||||
clone@1.0.4: {}
|
clone@1.0.4: {}
|
||||||
|
|
||||||
|
cluster-key-slot@1.1.2: {}
|
||||||
|
|
||||||
co@4.6.0: {}
|
co@4.6.0: {}
|
||||||
|
|
||||||
codepage@1.15.0: {}
|
codepage@1.15.0: {}
|
||||||
@ -6535,6 +6692,10 @@ snapshots:
|
|||||||
|
|
||||||
create-require@1.1.1: {}
|
create-require@1.1.1: {}
|
||||||
|
|
||||||
|
cron-parser@4.9.0:
|
||||||
|
dependencies:
|
||||||
|
luxon: 3.7.2
|
||||||
|
|
||||||
cross-env@7.0.3:
|
cross-env@7.0.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
cross-spawn: 7.0.6
|
cross-spawn: 7.0.6
|
||||||
@ -7331,6 +7492,20 @@ snapshots:
|
|||||||
|
|
||||||
inherits@2.0.4: {}
|
inherits@2.0.4: {}
|
||||||
|
|
||||||
|
ioredis@5.10.1:
|
||||||
|
dependencies:
|
||||||
|
'@ioredis/commands': 1.5.1
|
||||||
|
cluster-key-slot: 1.1.2
|
||||||
|
debug: 4.4.3
|
||||||
|
denque: 2.1.0
|
||||||
|
lodash.defaults: 4.2.0
|
||||||
|
lodash.isarguments: 3.1.0
|
||||||
|
redis-errors: 1.2.0
|
||||||
|
redis-parser: 3.0.0
|
||||||
|
standard-as-callback: 2.1.0
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
ip-address@10.2.0: {}
|
ip-address@10.2.0: {}
|
||||||
|
|
||||||
ipaddr.js@1.9.1: {}
|
ipaddr.js@1.9.1: {}
|
||||||
@ -7860,6 +8035,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
p-locate: 5.0.0
|
p-locate: 5.0.0
|
||||||
|
|
||||||
|
lodash.defaults@4.2.0: {}
|
||||||
|
|
||||||
|
lodash.isarguments@3.1.0: {}
|
||||||
|
|
||||||
lodash.memoize@4.1.2: {}
|
lodash.memoize@4.1.2: {}
|
||||||
|
|
||||||
lodash.merge@4.6.2: {}
|
lodash.merge@4.6.2: {}
|
||||||
@ -7885,6 +8064,8 @@ snapshots:
|
|||||||
|
|
||||||
lru.min@1.1.4: {}
|
lru.min@1.1.4: {}
|
||||||
|
|
||||||
|
luxon@3.7.2: {}
|
||||||
|
|
||||||
magic-string@0.30.17:
|
magic-string@0.30.17:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
@ -8344,6 +8525,22 @@ snapshots:
|
|||||||
|
|
||||||
ms@2.1.3: {}
|
ms@2.1.3: {}
|
||||||
|
|
||||||
|
msgpackr-extract@3.0.4:
|
||||||
|
dependencies:
|
||||||
|
node-gyp-build-optional-packages: 5.2.2
|
||||||
|
optionalDependencies:
|
||||||
|
'@msgpackr-extract/msgpackr-extract-darwin-arm64': 3.0.4
|
||||||
|
'@msgpackr-extract/msgpackr-extract-darwin-x64': 3.0.4
|
||||||
|
'@msgpackr-extract/msgpackr-extract-linux-arm': 3.0.4
|
||||||
|
'@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.4
|
||||||
|
'@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.4
|
||||||
|
'@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.4
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
msgpackr@2.0.2:
|
||||||
|
optionalDependencies:
|
||||||
|
msgpackr-extract: 3.0.4
|
||||||
|
|
||||||
multer@2.1.1:
|
multer@2.1.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
append-field: 1.0.0
|
append-field: 1.0.0
|
||||||
@ -8400,6 +8597,11 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
whatwg-url: 5.0.0
|
whatwg-url: 5.0.0
|
||||||
|
|
||||||
|
node-gyp-build-optional-packages@5.2.2:
|
||||||
|
dependencies:
|
||||||
|
detect-libc: 2.1.2
|
||||||
|
optional: true
|
||||||
|
|
||||||
node-int64@0.4.0: {}
|
node-int64@0.4.0: {}
|
||||||
|
|
||||||
node-releases@2.0.47: {}
|
node-releases@2.0.47: {}
|
||||||
@ -8730,6 +8932,12 @@ snapshots:
|
|||||||
|
|
||||||
real-require@1.0.0: {}
|
real-require@1.0.0: {}
|
||||||
|
|
||||||
|
redis-errors@1.2.0: {}
|
||||||
|
|
||||||
|
redis-parser@3.0.0:
|
||||||
|
dependencies:
|
||||||
|
redis-errors: 1.2.0
|
||||||
|
|
||||||
reflect-metadata@0.1.14: {}
|
reflect-metadata@0.1.14: {}
|
||||||
|
|
||||||
reflect-metadata@0.2.2: {}
|
reflect-metadata@0.2.2: {}
|
||||||
@ -8897,6 +9105,8 @@ snapshots:
|
|||||||
|
|
||||||
semver@6.3.1: {}
|
semver@6.3.1: {}
|
||||||
|
|
||||||
|
semver@7.8.1: {}
|
||||||
|
|
||||||
semver@7.8.2: {}
|
semver@7.8.2: {}
|
||||||
|
|
||||||
send@1.2.1:
|
send@1.2.1:
|
||||||
@ -9052,6 +9262,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
escape-string-regexp: 2.0.0
|
escape-string-regexp: 2.0.0
|
||||||
|
|
||||||
|
standard-as-callback@2.1.0: {}
|
||||||
|
|
||||||
statuses@2.0.2: {}
|
statuses@2.0.2: {}
|
||||||
|
|
||||||
streamsearch@1.1.0: {}
|
streamsearch@1.1.0: {}
|
||||||
@ -9362,7 +9574,7 @@ snapshots:
|
|||||||
|
|
||||||
typedarray@0.0.6: {}
|
typedarray@0.0.6: {}
|
||||||
|
|
||||||
typeorm@0.3.30(mysql2@3.22.4(@types/node@22.19.19))(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.3)):
|
typeorm@0.3.30(ioredis@5.10.1)(mysql2@3.22.4(@types/node@22.19.19))(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.3)):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@sqltools/formatter': 1.2.5
|
'@sqltools/formatter': 1.2.5
|
||||||
ansis: 4.3.1
|
ansis: 4.3.1
|
||||||
@ -9380,6 +9592,7 @@ snapshots:
|
|||||||
uuid: 11.1.1
|
uuid: 11.1.1
|
||||||
yargs: 17.7.2
|
yargs: 17.7.2
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
|
ioredis: 5.10.1
|
||||||
mysql2: 3.22.4(@types/node@22.19.19)
|
mysql2: 3.22.4(@types/node@22.19.19)
|
||||||
ts-node: 10.9.2(@types/node@22.19.19)(typescript@5.9.3)
|
ts-node: 10.9.2(@types/node@22.19.19)(typescript@5.9.3)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
|
|||||||
@ -16,7 +16,10 @@ import {
|
|||||||
parseQqbotPluginManifest,
|
parseQqbotPluginManifest,
|
||||||
type QqbotPluginManifest,
|
type QqbotPluginManifest,
|
||||||
} from '../domain/manifest';
|
} from '../domain/manifest';
|
||||||
import type { QqbotPluginWorkerRuntime } from '../infrastructure/integration/runtime';
|
import type {
|
||||||
|
QqbotPluginRuntimeEvent as QqbotPluginWorkerRuntimeEvent,
|
||||||
|
QqbotPluginWorkerRuntime,
|
||||||
|
} from '../infrastructure/integration/runtime';
|
||||||
import { QqbotPluginArgumentParserService } from './argument/qqbot-plugin-argument-parser.service';
|
import { QqbotPluginArgumentParserService } from './argument/qqbot-plugin-argument-parser.service';
|
||||||
import { QqbotBuiltinPluginPackageLoaderService } from '../infrastructure/integration/package/builtin-plugin-package-loader.service';
|
import { QqbotBuiltinPluginPackageLoaderService } from '../infrastructure/integration/package/builtin-plugin-package-loader.service';
|
||||||
import { QqbotPluginPackageReaderService } from '../infrastructure/integration/package/plugin-package-reader.service';
|
import { QqbotPluginPackageReaderService } from '../infrastructure/integration/package/plugin-package-reader.service';
|
||||||
@ -51,6 +54,7 @@ export type QqbotPluginRuntimeFactory = {
|
|||||||
| 'activate'
|
| 'activate'
|
||||||
| 'deactivate'
|
| 'deactivate'
|
||||||
| 'dispose'
|
| 'dispose'
|
||||||
|
| 'drainRuntimeEvents'
|
||||||
| 'executeOperation'
|
| 'executeOperation'
|
||||||
| 'handleEvent'
|
| 'handleEvent'
|
||||||
| 'health'
|
| 'health'
|
||||||
@ -351,30 +355,34 @@ export class QqbotPluginPlatformService
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const output = await workerContext.worker.executeOperation({
|
try {
|
||||||
input: normalizedInput,
|
const output = await workerContext.worker.executeOperation({
|
||||||
operationId: operation.key,
|
input: normalizedInput,
|
||||||
operationKey: operation.key,
|
operationId: operation.key,
|
||||||
timeoutMs: operation.timeoutMs,
|
operationKey: operation.key,
|
||||||
});
|
timeoutMs: operation.timeoutMs,
|
||||||
const pluginId = this.getPluginIdFromContext(input.context);
|
|
||||||
if (pluginId) {
|
|
||||||
await this.runtimeEventRepository.save({
|
|
||||||
eventType: 'command log mapped',
|
|
||||||
installationId: null,
|
|
||||||
level: 'info',
|
|
||||||
pluginId,
|
|
||||||
safeSummary: {
|
|
||||||
operationKey: input.operationKey,
|
|
||||||
outputKeys:
|
|
||||||
output && typeof output === 'object'
|
|
||||||
? Object.keys(output as Record<string, unknown>).sort()
|
|
||||||
: [],
|
|
||||||
pluginKey: input.pluginKey,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
const pluginId = this.getPluginIdFromContext(input.context);
|
||||||
|
if (pluginId) {
|
||||||
|
await this.runtimeEventRepository.save({
|
||||||
|
eventType: 'command log mapped',
|
||||||
|
installationId: null,
|
||||||
|
level: 'info',
|
||||||
|
pluginId,
|
||||||
|
safeSummary: {
|
||||||
|
operationKey: input.operationKey,
|
||||||
|
outputKeys:
|
||||||
|
output && typeof output === 'object'
|
||||||
|
? Object.keys(output as Record<string, unknown>).sort()
|
||||||
|
: [],
|
||||||
|
pluginKey: input.pluginKey,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
} finally {
|
||||||
|
await this.flushWorkerRuntimeEvents(workerContext);
|
||||||
}
|
}
|
||||||
return output;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async dispatchEvent(input: QqbotPluginEventDispatchInput) {
|
async dispatchEvent(input: QqbotPluginEventDispatchInput) {
|
||||||
@ -387,12 +395,16 @@ export class QqbotPluginPlatformService
|
|||||||
) {
|
) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const result = await workerContext.worker.handleEvent({
|
try {
|
||||||
event: input.message,
|
const result = await workerContext.worker.handleEvent({
|
||||||
eventKey: event.eventName || input.eventKey,
|
event: input.message,
|
||||||
timeoutMs: workerContext.manifest.runtime.timeoutMs,
|
eventKey: event.eventName || input.eventKey,
|
||||||
});
|
timeoutMs: workerContext.manifest.runtime.timeoutMs,
|
||||||
handled = Boolean(result) || handled;
|
});
|
||||||
|
handled = Boolean(result) || handled;
|
||||||
|
} finally {
|
||||||
|
await this.flushWorkerRuntimeEvents(workerContext);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return handled;
|
return handled;
|
||||||
@ -745,11 +757,20 @@ export class QqbotPluginPlatformService
|
|||||||
private async stopWorker(installationId: string) {
|
private async stopWorker(installationId: string) {
|
||||||
const worker = this.activeWorkers.get(installationId);
|
const worker = this.activeWorkers.get(installationId);
|
||||||
if (!worker) return;
|
if (!worker) return;
|
||||||
|
const workerContext = this.activeWorkerContexts.get(installationId);
|
||||||
try {
|
try {
|
||||||
await worker.deactivate();
|
try {
|
||||||
|
await worker.deactivate();
|
||||||
|
} finally {
|
||||||
|
await this.flushWorkerRuntimeEventsBestEffort(workerContext);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
await worker.dispose();
|
try {
|
||||||
this.unregisterActiveWorker(installationId);
|
await worker.dispose();
|
||||||
|
} finally {
|
||||||
|
await this.flushWorkerRuntimeEventsBestEffort(workerContext);
|
||||||
|
this.unregisterActiveWorker(installationId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -788,17 +809,79 @@ export class QqbotPluginPlatformService
|
|||||||
await worker.load(version.manifestJson);
|
await worker.load(version.manifestJson);
|
||||||
await worker.activate();
|
await worker.activate();
|
||||||
await worker.health();
|
await worker.health();
|
||||||
|
await this.flushRuntimeEvents(
|
||||||
|
installation.id,
|
||||||
|
installation.pluginId,
|
||||||
|
worker,
|
||||||
|
);
|
||||||
await this.registerActiveWorker(
|
await this.registerActiveWorker(
|
||||||
installation,
|
installation,
|
||||||
version,
|
version,
|
||||||
worker as QqbotPluginWorkerRuntime,
|
worker as QqbotPluginWorkerRuntime,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await worker.dispose();
|
await this.flushRuntimeEvents(
|
||||||
|
installation.id,
|
||||||
|
installation.pluginId,
|
||||||
|
worker,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await worker.dispose();
|
||||||
|
} finally {
|
||||||
|
await this.flushRuntimeEvents(
|
||||||
|
installation.id,
|
||||||
|
installation.pluginId,
|
||||||
|
worker,
|
||||||
|
);
|
||||||
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async flushWorkerRuntimeEvents(workerContext: ActiveWorkerContext) {
|
||||||
|
await this.flushRuntimeEvents(
|
||||||
|
workerContext.installationId,
|
||||||
|
workerContext.pluginId,
|
||||||
|
workerContext.worker,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async flushWorkerRuntimeEventsBestEffort(
|
||||||
|
workerContext?: ActiveWorkerContext,
|
||||||
|
) {
|
||||||
|
if (!workerContext) return;
|
||||||
|
try {
|
||||||
|
await this.flushWorkerRuntimeEvents(workerContext);
|
||||||
|
} catch {
|
||||||
|
// Runtime event persistence must not block worker cleanup.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async flushRuntimeEvents(
|
||||||
|
installationId: string,
|
||||||
|
pluginId: string,
|
||||||
|
worker: Pick<QqbotPluginWorkerRuntime, 'drainRuntimeEvents'>,
|
||||||
|
) {
|
||||||
|
const events = worker.drainRuntimeEvents?.() || [];
|
||||||
|
if (!events.length || !this.isPersistablePluginId(pluginId)) return;
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
events.map((event: QqbotPluginWorkerRuntimeEvent) =>
|
||||||
|
this.runtimeEventRepository.save({
|
||||||
|
eventType: event.eventType,
|
||||||
|
installationId,
|
||||||
|
level: event.level,
|
||||||
|
pluginId,
|
||||||
|
safeSummary: event.safeSummary,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private isPersistablePluginId(pluginId: string) {
|
||||||
|
return /^\d+$/.test(pluginId);
|
||||||
|
}
|
||||||
|
|
||||||
private async recordRuntimeEvent(
|
private async recordRuntimeEvent(
|
||||||
installation: QqbotPluginInstallation,
|
installation: QqbotPluginInstallation,
|
||||||
eventType: string,
|
eventType: string,
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { Worker } from 'node:worker_threads';
|
import { Worker } from 'node:worker_threads';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { throwVbenError } from '@/common';
|
import { throwVbenError } from '@/common';
|
||||||
@ -13,8 +14,14 @@ import type {
|
|||||||
QqbotPluginVersion,
|
QqbotPluginVersion,
|
||||||
} from '@/modules/qqbot/plugin-platform/infrastructure/persistence';
|
} from '@/modules/qqbot/plugin-platform/infrastructure/persistence';
|
||||||
import {
|
import {
|
||||||
|
QqbotPluginWorkerResponseError,
|
||||||
QqbotPluginWorkerRuntime,
|
QqbotPluginWorkerRuntime,
|
||||||
|
serializePluginWorkerResponseError,
|
||||||
} from './worker-runtime';
|
} from './worker-runtime';
|
||||||
|
import {
|
||||||
|
createQqbotBullmqWorkerQueueOptions,
|
||||||
|
QqbotBullmqPluginWorkerRequestQueue,
|
||||||
|
} from './bullmq-plugin-worker-request.queue';
|
||||||
import type {
|
import type {
|
||||||
QqbotPluginWorkerDriver,
|
QqbotPluginWorkerDriver,
|
||||||
QqbotPluginWorkerRequest,
|
QqbotPluginWorkerRequest,
|
||||||
@ -26,6 +33,7 @@ export class QqbotBuiltinPluginWorkerRuntimeFactoryService
|
|||||||
{
|
{
|
||||||
constructor(
|
constructor(
|
||||||
private readonly pluginLoader: QqbotBuiltinPluginPackageLoaderService,
|
private readonly pluginLoader: QqbotBuiltinPluginPackageLoaderService,
|
||||||
|
private readonly configService: ConfigService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
create(
|
create(
|
||||||
@ -33,8 +41,19 @@ export class QqbotBuiltinPluginWorkerRuntimeFactoryService
|
|||||||
version: QqbotPluginVersion,
|
version: QqbotPluginVersion,
|
||||||
) {
|
) {
|
||||||
const pluginKey = getManifestPluginKey(version.manifestJson);
|
const pluginKey = getManifestPluginKey(version.manifestJson);
|
||||||
|
const driver = new QqbotBuiltinPluginWorkerThreadDriver(
|
||||||
|
this.pluginLoader,
|
||||||
|
pluginKey,
|
||||||
|
);
|
||||||
return new QqbotPluginWorkerRuntime(
|
return new QqbotPluginWorkerRuntime(
|
||||||
new QqbotBuiltinPluginWorkerThreadDriver(this.pluginLoader, pluginKey),
|
new QqbotBullmqPluginWorkerRequestQueue(
|
||||||
|
driver,
|
||||||
|
createQqbotBullmqWorkerQueueOptions(
|
||||||
|
this.configService,
|
||||||
|
pluginKey,
|
||||||
|
installation.id,
|
||||||
|
),
|
||||||
|
),
|
||||||
{
|
{
|
||||||
defaultTimeoutMs: getDefaultRuntimeTimeout(version.manifestJson),
|
defaultTimeoutMs: getDefaultRuntimeTimeout(version.manifestJson),
|
||||||
installationId: installation.id,
|
installationId: installation.id,
|
||||||
@ -133,7 +152,7 @@ export class QqbotBuiltinPluginWorkerThreadDriver
|
|||||||
pending.resolve(message.result);
|
pending.resolve(message.result);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
pending.reject(deserializeWorkerError(message.error));
|
pending.reject(new QqbotPluginWorkerResponseError(message.error || {}));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -177,20 +196,7 @@ function resolveWorkerExecArgv() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function serializeWorkerError(error: unknown) {
|
function serializeWorkerError(error: unknown) {
|
||||||
return {
|
return serializePluginWorkerResponseError(error);
|
||||||
message: error instanceof Error ? error.message : `${error}`,
|
|
||||||
name: error instanceof Error ? error.name : 'Error',
|
|
||||||
stack: error instanceof Error ? error.stack : undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function deserializeWorkerError(
|
|
||||||
error?: { message?: string; name?: string; stack?: string },
|
|
||||||
) {
|
|
||||||
const output = new Error(error?.message || 'QQBot 插件 worker 请求失败');
|
|
||||||
if (error?.name) output.name = error.name;
|
|
||||||
if (error?.stack) output.stack = error.stack;
|
|
||||||
return output;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getManifestPluginKey(manifest: unknown) {
|
function getManifestPluginKey(manifest: unknown) {
|
||||||
|
|||||||
@ -0,0 +1,318 @@
|
|||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
import type { ConfigService } from '@nestjs/config';
|
||||||
|
import {
|
||||||
|
Job,
|
||||||
|
Queue,
|
||||||
|
QueueEvents,
|
||||||
|
Worker,
|
||||||
|
type ConnectionOptions,
|
||||||
|
type JobsOptions,
|
||||||
|
} from 'bullmq';
|
||||||
|
import {
|
||||||
|
QqbotPluginWorkerExpiredRequestError,
|
||||||
|
QqbotPluginWorkerResponseError,
|
||||||
|
QqbotPluginWorkerStaleRequestError,
|
||||||
|
type QqbotPluginWorkerResponseErrorInput,
|
||||||
|
} from './worker-runtime';
|
||||||
|
import type {
|
||||||
|
QqbotPluginWorkerDriver,
|
||||||
|
QqbotPluginWorkerRequest,
|
||||||
|
QqbotPluginWorkerRequestQueue,
|
||||||
|
} from './worker-runtime.types';
|
||||||
|
|
||||||
|
export type QqbotBullmqWorkerRequestQueueOptions = {
|
||||||
|
connection: ConnectionOptions;
|
||||||
|
installationId: string;
|
||||||
|
pluginKey: string;
|
||||||
|
prefix: string;
|
||||||
|
removeOnFailCount: number;
|
||||||
|
waitUntilFinishedBufferMs: number;
|
||||||
|
workerInstanceId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type QqbotWorkerQueueJobData = {
|
||||||
|
expiresAt: number;
|
||||||
|
generation: number;
|
||||||
|
message: QqbotPluginWorkerRequest;
|
||||||
|
workerInstanceId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type QqbotWorkerQueueResult =
|
||||||
|
| {
|
||||||
|
ok: true;
|
||||||
|
result: unknown;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
error: QqbotPluginWorkerResponseErrorInput;
|
||||||
|
ok: false;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class QqbotBullmqPluginWorkerRequestQueue
|
||||||
|
implements QqbotPluginWorkerRequestQueue
|
||||||
|
{
|
||||||
|
private readonly queue: Queue<
|
||||||
|
QqbotWorkerQueueJobData,
|
||||||
|
QqbotWorkerQueueResult,
|
||||||
|
string
|
||||||
|
>;
|
||||||
|
private readonly queueEvents: QueueEvents;
|
||||||
|
private readonly ready: Promise<unknown[]>;
|
||||||
|
private readonly worker: Worker<
|
||||||
|
QqbotWorkerQueueJobData,
|
||||||
|
QqbotWorkerQueueResult,
|
||||||
|
string
|
||||||
|
>;
|
||||||
|
private readonly logger = new Logger(
|
||||||
|
QqbotBullmqPluginWorkerRequestQueue.name,
|
||||||
|
);
|
||||||
|
private closed = false;
|
||||||
|
private generation = 0;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly driver: QqbotPluginWorkerDriver,
|
||||||
|
private readonly options: QqbotBullmqWorkerRequestQueueOptions,
|
||||||
|
) {
|
||||||
|
const queueName = buildWorkerQueueName(
|
||||||
|
options.pluginKey,
|
||||||
|
options.installationId,
|
||||||
|
);
|
||||||
|
const bullmqOptions = {
|
||||||
|
connection: options.connection,
|
||||||
|
prefix: options.prefix,
|
||||||
|
};
|
||||||
|
this.queue = new Queue(queueName, bullmqOptions);
|
||||||
|
this.queueEvents = new QueueEvents(queueName, bullmqOptions);
|
||||||
|
this.worker = new Worker(
|
||||||
|
queueName,
|
||||||
|
async (job: Job<QqbotWorkerQueueJobData>) => {
|
||||||
|
if (job.data.expiresAt <= Date.now()) {
|
||||||
|
return {
|
||||||
|
error: {
|
||||||
|
message: 'worker-request-expired',
|
||||||
|
name: 'QqbotPluginWorkerExpiredRequestError',
|
||||||
|
},
|
||||||
|
ok: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (job.data.workerInstanceId !== this.options.workerInstanceId) {
|
||||||
|
return {
|
||||||
|
error: {
|
||||||
|
message: 'worker-request-foreign-instance',
|
||||||
|
name: 'QqbotPluginWorkerStaleRequestError',
|
||||||
|
},
|
||||||
|
ok: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (job.data.generation !== this.generation) {
|
||||||
|
return {
|
||||||
|
error: {
|
||||||
|
message: 'worker-request-stale',
|
||||||
|
name: 'QqbotPluginWorkerStaleRequestError',
|
||||||
|
},
|
||||||
|
ok: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
result: await this.driver.request(job.data.message),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof QqbotPluginWorkerResponseError) {
|
||||||
|
return {
|
||||||
|
error: error.serializedError,
|
||||||
|
ok: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
await this.reset();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...bullmqOptions,
|
||||||
|
concurrency: 1,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
this.queue.on('error', (error) => {
|
||||||
|
this.logBullmqError('queue', error);
|
||||||
|
});
|
||||||
|
this.queueEvents.on('error', (error) => {
|
||||||
|
this.logBullmqError('queueEvents', error);
|
||||||
|
});
|
||||||
|
this.worker.on('error', (error) => {
|
||||||
|
this.logBullmqError('worker', error);
|
||||||
|
});
|
||||||
|
this.ready = Promise.all([
|
||||||
|
this.queue.waitUntilReady(),
|
||||||
|
this.queueEvents.waitUntilReady(),
|
||||||
|
this.worker.waitUntilReady(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async request(message: QqbotPluginWorkerRequest): Promise<unknown> {
|
||||||
|
if (this.closed) {
|
||||||
|
throw new Error('QQBot 插件 worker 队列已关闭');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.ready;
|
||||||
|
const job = await this.queue.add(
|
||||||
|
message.type,
|
||||||
|
{
|
||||||
|
expiresAt:
|
||||||
|
Date.now() + message.timeoutMs + this.options.waitUntilFinishedBufferMs,
|
||||||
|
generation: this.generation,
|
||||||
|
message,
|
||||||
|
workerInstanceId: this.options.workerInstanceId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
attempts: 1,
|
||||||
|
jobId: message.correlationId,
|
||||||
|
removeOnComplete: true,
|
||||||
|
removeOnFail: this.options.removeOnFailCount,
|
||||||
|
} satisfies JobsOptions,
|
||||||
|
);
|
||||||
|
const result = await job.waitUntilFinished(
|
||||||
|
this.queueEvents,
|
||||||
|
message.timeoutMs + this.options.waitUntilFinishedBufferMs,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result?.ok === false) {
|
||||||
|
if (result.error.name === 'QqbotPluginWorkerStaleRequestError') {
|
||||||
|
throw new QqbotPluginWorkerStaleRequestError(result.error.message);
|
||||||
|
}
|
||||||
|
if (result.error.name === 'QqbotPluginWorkerExpiredRequestError') {
|
||||||
|
throw new QqbotPluginWorkerExpiredRequestError(result.error.message);
|
||||||
|
}
|
||||||
|
throw new QqbotPluginWorkerResponseError(result.error);
|
||||||
|
}
|
||||||
|
return result?.result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async reset(): Promise<void> {
|
||||||
|
this.generation += 1;
|
||||||
|
await this.driver.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
async close(): Promise<void> {
|
||||||
|
this.closed = true;
|
||||||
|
await Promise.allSettled([
|
||||||
|
this.worker.close(),
|
||||||
|
this.queueEvents.close(),
|
||||||
|
this.queue.close(),
|
||||||
|
this.driver.dispose(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private logBullmqError(
|
||||||
|
source: 'queue' | 'queueEvents' | 'worker',
|
||||||
|
error: Error,
|
||||||
|
) {
|
||||||
|
this.logger.error(
|
||||||
|
`QQBot 插件 worker BullMQ ${source} 异常:${error.message}`,
|
||||||
|
error.stack,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createQqbotBullmqWorkerQueueOptions(
|
||||||
|
configService: ConfigService,
|
||||||
|
pluginKey: string,
|
||||||
|
installationId: string,
|
||||||
|
): QqbotBullmqWorkerRequestQueueOptions {
|
||||||
|
return {
|
||||||
|
connection: resolveQqbotPluginQueueConnection(configService),
|
||||||
|
installationId,
|
||||||
|
pluginKey,
|
||||||
|
prefix: resolveQqbotPluginQueuePrefix(configService),
|
||||||
|
removeOnFailCount: readNumberConfig(
|
||||||
|
configService,
|
||||||
|
['QQBOT_PLUGIN_QUEUE_REMOVE_ON_FAIL'],
|
||||||
|
100,
|
||||||
|
),
|
||||||
|
waitUntilFinishedBufferMs: readNumberConfig(
|
||||||
|
configService,
|
||||||
|
['QQBOT_PLUGIN_QUEUE_WAIT_BUFFER_MS'],
|
||||||
|
5_000,
|
||||||
|
),
|
||||||
|
workerInstanceId: createWorkerInstanceId(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveQqbotPluginQueuePrefix(configService: ConfigService) {
|
||||||
|
return readStringConfig(
|
||||||
|
configService,
|
||||||
|
['QQBOT_PLUGIN_QUEUE_REDIS_PREFIX'],
|
||||||
|
'kt:qqbot:plugin-worker',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveQqbotPluginQueueConnection(
|
||||||
|
configService: ConfigService,
|
||||||
|
): ConnectionOptions {
|
||||||
|
const host = readStringConfig(configService, [
|
||||||
|
'QQBOT_PLUGIN_QUEUE_REDIS_HOST',
|
||||||
|
'REDIS_HOST',
|
||||||
|
]);
|
||||||
|
if (!host) {
|
||||||
|
throw new Error('QQBot 插件队列缺少 Redis 主机配置');
|
||||||
|
}
|
||||||
|
|
||||||
|
const password = readStringConfig(configService, [
|
||||||
|
'QQBOT_PLUGIN_QUEUE_REDIS_PASSWORD',
|
||||||
|
'REDIS_PASSWORD',
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
db: readNumberConfig(
|
||||||
|
configService,
|
||||||
|
['QQBOT_PLUGIN_QUEUE_REDIS_DB', 'REDIS_DB'],
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
host,
|
||||||
|
password: password || undefined,
|
||||||
|
port: readNumberConfig(
|
||||||
|
configService,
|
||||||
|
['QQBOT_PLUGIN_QUEUE_REDIS_PORT', 'REDIS_PORT'],
|
||||||
|
6379,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWorkerQueueName(pluginKey: string, installationId: string) {
|
||||||
|
const safePluginKey = pluginKey.replace(/[^a-zA-Z0-9_-]/g, '-');
|
||||||
|
const safeInstallationId = installationId.replace(/[^a-zA-Z0-9_-]/g, '-');
|
||||||
|
return `qqbot-plugin-worker:${safePluginKey}:${safeInstallationId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWorkerInstanceId() {
|
||||||
|
return [
|
||||||
|
process.env.HOSTNAME || 'local',
|
||||||
|
process.pid,
|
||||||
|
Date.now(),
|
||||||
|
Math.random().toString(16).slice(2),
|
||||||
|
].join(':');
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStringConfig(
|
||||||
|
configService: ConfigService,
|
||||||
|
keys: string[],
|
||||||
|
fallback = '',
|
||||||
|
) {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = configService.get<string | number | undefined>(key);
|
||||||
|
if (value !== undefined && value !== null && `${value}`.trim()) {
|
||||||
|
return `${value}`.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readNumberConfig(
|
||||||
|
configService: ConfigService,
|
||||||
|
keys: string[],
|
||||||
|
fallback: number,
|
||||||
|
) {
|
||||||
|
const value = readStringConfig(configService, keys);
|
||||||
|
if (!value) return fallback;
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : fallback;
|
||||||
|
}
|
||||||
@ -1,3 +1,4 @@
|
|||||||
export * from './worker-runtime';
|
export * from './worker-runtime';
|
||||||
export * from './worker-runtime.types';
|
export * from './worker-runtime.types';
|
||||||
export * from './builtin-plugin-worker-runtime.factory';
|
export * from './builtin-plugin-worker-runtime.factory';
|
||||||
|
export * from './bullmq-plugin-worker-request.queue';
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import {
|
|||||||
type QqbotPluginRuntimeEvent,
|
type QqbotPluginRuntimeEvent,
|
||||||
type QqbotPluginRuntimeStatus,
|
type QqbotPluginRuntimeStatus,
|
||||||
type QqbotPluginSafeInputSummary,
|
type QqbotPluginSafeInputSummary,
|
||||||
type QqbotPluginWorkerDriver,
|
type QqbotPluginWorkerRequestQueue,
|
||||||
type QqbotPluginWorkerRequest,
|
type QqbotPluginWorkerRequest,
|
||||||
type QqbotPluginWorkerRequestType,
|
type QqbotPluginWorkerRequestType,
|
||||||
type QqbotPluginWorkerRuntimeOptions,
|
type QqbotPluginWorkerRuntimeOptions,
|
||||||
@ -23,6 +23,46 @@ export class QqbotPluginRuntimeError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type QqbotPluginWorkerResponseErrorInput = {
|
||||||
|
message?: string;
|
||||||
|
name?: string;
|
||||||
|
stack?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class QqbotPluginWorkerResponseError extends Error {
|
||||||
|
constructor(readonly serializedError: QqbotPluginWorkerResponseErrorInput) {
|
||||||
|
super(serializedError.message || 'QQBot 插件 worker 请求失败');
|
||||||
|
this.name = serializedError.name || 'QqbotPluginWorkerResponseError';
|
||||||
|
if (serializedError.stack) this.stack = serializedError.stack;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const serializePluginWorkerResponseError = (
|
||||||
|
error: unknown,
|
||||||
|
): QqbotPluginWorkerResponseErrorInput => ({
|
||||||
|
message: error instanceof Error ? error.message : `${error}`,
|
||||||
|
name: error instanceof Error ? error.name : 'Error',
|
||||||
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
export class QqbotPluginWorkerStaleRequestError extends Error {
|
||||||
|
constructor(message = 'QQBot 插件 worker 队列请求已过期,需要恢复后重试') {
|
||||||
|
super(message);
|
||||||
|
this.name = 'QqbotPluginWorkerStaleRequestError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class QqbotPluginWorkerExpiredRequestError extends Error {
|
||||||
|
constructor(message = 'QQBot 插件 worker 队列请求已超时') {
|
||||||
|
super(message);
|
||||||
|
this.name = 'QqbotPluginWorkerExpiredRequestError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isNamedError = (error: unknown, name: string) => {
|
||||||
|
return error instanceof Error && error.name === name;
|
||||||
|
};
|
||||||
|
|
||||||
const createCorrelationId = () => {
|
const createCorrelationId = () => {
|
||||||
return `qqbot-plugin-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
return `qqbot-plugin-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||||
};
|
};
|
||||||
@ -38,18 +78,27 @@ const summarizeInput = (
|
|||||||
|
|
||||||
export class QqbotPluginWorkerRuntime {
|
export class QqbotPluginWorkerRuntime {
|
||||||
private readonly runtimeEvents: QqbotPluginRuntimeEvent[] = [];
|
private readonly runtimeEvents: QqbotPluginRuntimeEvent[] = [];
|
||||||
|
private manifestForRecovery?: unknown;
|
||||||
|
private recoveryPromise?: Promise<void>;
|
||||||
|
private shouldRecoverActive = false;
|
||||||
|
|
||||||
status: QqbotPluginRuntimeStatus = 'stopped';
|
status: QqbotPluginRuntimeStatus = 'stopped';
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly driver: QqbotPluginWorkerDriver,
|
private readonly requestQueue: QqbotPluginWorkerRequestQueue,
|
||||||
private readonly options: QqbotPluginWorkerRuntimeOptions,
|
private readonly options: QqbotPluginWorkerRuntimeOptions,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async load(manifest: unknown) {
|
async load(manifest: unknown) {
|
||||||
const result = await this.request('load', {
|
this.manifestForRecovery = manifest;
|
||||||
manifest,
|
const result = await this.request(
|
||||||
});
|
'load',
|
||||||
|
{
|
||||||
|
manifest,
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
{ skipRecovery: true },
|
||||||
|
);
|
||||||
this.status = 'loaded';
|
this.status = 'loaded';
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@ -57,6 +106,7 @@ export class QqbotPluginWorkerRuntime {
|
|||||||
async activate() {
|
async activate() {
|
||||||
const result = await this.request('activate');
|
const result = await this.request('activate');
|
||||||
this.status = 'active';
|
this.status = 'active';
|
||||||
|
this.shouldRecoverActive = true;
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -92,15 +142,17 @@ export class QqbotPluginWorkerRuntime {
|
|||||||
async deactivate() {
|
async deactivate() {
|
||||||
const result = await this.request('deactivate');
|
const result = await this.request('deactivate');
|
||||||
this.status = 'stopped';
|
this.status = 'stopped';
|
||||||
|
this.shouldRecoverActive = false;
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async dispose() {
|
async dispose() {
|
||||||
try {
|
try {
|
||||||
await this.request('dispose');
|
await this.request('dispose', {}, undefined, { skipRecovery: true });
|
||||||
} finally {
|
} finally {
|
||||||
await this.driver.dispose();
|
await this.requestQueue.close();
|
||||||
this.status = 'stopped';
|
this.status = 'stopped';
|
||||||
|
this.shouldRecoverActive = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -108,11 +160,20 @@ export class QqbotPluginWorkerRuntime {
|
|||||||
return [...this.runtimeEvents];
|
return [...this.runtimeEvents];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
drainRuntimeEvents() {
|
||||||
|
return this.runtimeEvents.splice(0);
|
||||||
|
}
|
||||||
|
|
||||||
private async request(
|
private async request(
|
||||||
type: QqbotPluginWorkerRequestType,
|
type: QqbotPluginWorkerRequestType,
|
||||||
payload: Partial<QqbotPluginWorkerRequest> = {},
|
payload: Partial<QqbotPluginWorkerRequest> = {},
|
||||||
timeoutMs = this.options.defaultTimeoutMs,
|
timeoutMs = this.options.defaultTimeoutMs,
|
||||||
|
control: { retryStale?: boolean; skipRecovery?: boolean } = {},
|
||||||
) {
|
) {
|
||||||
|
if (!control.skipRecovery) {
|
||||||
|
await this.recoverIfNeeded(type);
|
||||||
|
}
|
||||||
|
|
||||||
const message: QqbotPluginWorkerRequest = {
|
const message: QqbotPluginWorkerRequest = {
|
||||||
correlationId: createCorrelationId(),
|
correlationId: createCorrelationId(),
|
||||||
installationId: this.options.installationId,
|
installationId: this.options.installationId,
|
||||||
@ -123,7 +184,7 @@ export class QqbotPluginWorkerRuntime {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const requestPromise = Promise.resolve().then(() =>
|
const requestPromise = Promise.resolve().then(() =>
|
||||||
this.driver.request(message),
|
this.requestQueue.request(message),
|
||||||
);
|
);
|
||||||
requestPromise.catch(() => undefined);
|
requestPromise.catch(() => undefined);
|
||||||
|
|
||||||
@ -135,8 +196,47 @@ export class QqbotPluginWorkerRuntime {
|
|||||||
if (error instanceof QqbotPluginRuntimeError) {
|
if (error instanceof QqbotPluginRuntimeError) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
(error instanceof QqbotPluginWorkerStaleRequestError ||
|
||||||
|
isNamedError(error, 'QqbotPluginWorkerStaleRequestError')) &&
|
||||||
|
control.retryStale !== false
|
||||||
|
) {
|
||||||
|
await this.markWorkerFailed('worker-stale-request', {
|
||||||
|
correlationId: message.correlationId,
|
||||||
|
operationId: message.operationId,
|
||||||
|
type,
|
||||||
|
});
|
||||||
|
await this.recoverIfNeeded(type);
|
||||||
|
return this.request(type, payload, timeoutMs, {
|
||||||
|
retryStale: false,
|
||||||
|
skipRecovery: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
error instanceof QqbotPluginWorkerExpiredRequestError ||
|
||||||
|
isNamedError(error, 'QqbotPluginWorkerExpiredRequestError')
|
||||||
|
) {
|
||||||
|
const runtimeError = new QqbotPluginRuntimeError(
|
||||||
|
'PLUGIN_WORKER_TIMEOUT',
|
||||||
|
this.options.pluginKey,
|
||||||
|
'QQBot plugin worker queue request expired.',
|
||||||
|
{
|
||||||
|
correlationId: message.correlationId,
|
||||||
|
operationId: message.operationId,
|
||||||
|
timeoutMs,
|
||||||
|
type,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
this.recordRuntimeEvent(
|
||||||
|
'worker-request-expired',
|
||||||
|
runtimeError.safeSummary,
|
||||||
|
);
|
||||||
|
throw runtimeError;
|
||||||
|
}
|
||||||
|
if (error instanceof QqbotPluginWorkerResponseError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
this.status = 'failed';
|
|
||||||
const runtimeError = new QqbotPluginRuntimeError(
|
const runtimeError = new QqbotPluginRuntimeError(
|
||||||
'PLUGIN_WORKER_CRASH',
|
'PLUGIN_WORKER_CRASH',
|
||||||
this.options.pluginKey,
|
this.options.pluginKey,
|
||||||
@ -148,7 +248,7 @@ export class QqbotPluginWorkerRuntime {
|
|||||||
type,
|
type,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
this.recordRuntimeEvent('worker-crash', runtimeError.safeSummary);
|
await this.markWorkerFailed('worker-crash', runtimeError.safeSummary);
|
||||||
throw runtimeError;
|
throw runtimeError;
|
||||||
} finally {
|
} finally {
|
||||||
timeout.clear();
|
timeout.clear();
|
||||||
@ -163,7 +263,6 @@ export class QqbotPluginWorkerRuntime {
|
|||||||
let timer: NodeJS.Timeout | undefined;
|
let timer: NodeJS.Timeout | undefined;
|
||||||
const promise = new Promise<never>((_, reject) => {
|
const promise = new Promise<never>((_, reject) => {
|
||||||
timer = setTimeout(async () => {
|
timer = setTimeout(async () => {
|
||||||
this.status = 'failed';
|
|
||||||
const error = new QqbotPluginRuntimeError(
|
const error = new QqbotPluginRuntimeError(
|
||||||
'PLUGIN_WORKER_TIMEOUT',
|
'PLUGIN_WORKER_TIMEOUT',
|
||||||
this.options.pluginKey,
|
this.options.pluginKey,
|
||||||
@ -175,13 +274,7 @@ export class QqbotPluginWorkerRuntime {
|
|||||||
type,
|
type,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
this.recordRuntimeEvent('worker-timeout', error.safeSummary);
|
await this.markWorkerFailed('worker-timeout', error.safeSummary);
|
||||||
|
|
||||||
try {
|
|
||||||
await this.driver.dispose();
|
|
||||||
} catch {
|
|
||||||
// Timeout disposal is best-effort; the timeout error is the primary signal.
|
|
||||||
}
|
|
||||||
reject(error);
|
reject(error);
|
||||||
}, timeoutMs);
|
}, timeoutMs);
|
||||||
timer.unref?.();
|
timer.unref?.();
|
||||||
@ -194,13 +287,87 @@ export class QqbotPluginWorkerRuntime {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private recordRuntimeEvent(
|
private async recoverIfNeeded(triggerType: QqbotPluginWorkerRequestType) {
|
||||||
|
if (this.status !== 'failed' || !this.manifestForRecovery) return;
|
||||||
|
|
||||||
|
if (!this.recoveryPromise) {
|
||||||
|
this.recoveryPromise = this.recoverWorker(triggerType).finally(() => {
|
||||||
|
this.recoveryPromise = undefined;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.recoveryPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async recoverWorker(triggerType: QqbotPluginWorkerRequestType) {
|
||||||
|
this.recordRuntimeEvent(
|
||||||
|
'worker-recover-started',
|
||||||
|
{
|
||||||
|
triggerType,
|
||||||
|
},
|
||||||
|
'warn',
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.request(
|
||||||
|
'load',
|
||||||
|
{
|
||||||
|
manifest: this.manifestForRecovery,
|
||||||
|
},
|
||||||
|
this.options.defaultTimeoutMs,
|
||||||
|
{ skipRecovery: true },
|
||||||
|
);
|
||||||
|
this.status = 'loaded';
|
||||||
|
|
||||||
|
if (this.shouldRecoverActive) {
|
||||||
|
await this.request(
|
||||||
|
'activate',
|
||||||
|
{},
|
||||||
|
this.options.defaultTimeoutMs,
|
||||||
|
{ skipRecovery: true },
|
||||||
|
);
|
||||||
|
this.status = 'active';
|
||||||
|
}
|
||||||
|
|
||||||
|
this.recordRuntimeEvent(
|
||||||
|
'worker-recovered',
|
||||||
|
{
|
||||||
|
status: this.status,
|
||||||
|
triggerType,
|
||||||
|
},
|
||||||
|
'info',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async markWorkerFailed(
|
||||||
eventType: string,
|
eventType: string,
|
||||||
safeSummary: Record<string, unknown>,
|
safeSummary: Record<string, unknown>,
|
||||||
|
) {
|
||||||
|
if (this.status === 'active') {
|
||||||
|
this.shouldRecoverActive = true;
|
||||||
|
}
|
||||||
|
this.status = 'failed';
|
||||||
|
|
||||||
|
let resetError: string | undefined;
|
||||||
|
try {
|
||||||
|
await this.requestQueue.reset();
|
||||||
|
} catch (error) {
|
||||||
|
resetError = error instanceof Error ? error.message : `${error}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.recordRuntimeEvent(eventType, {
|
||||||
|
...safeSummary,
|
||||||
|
...(resetError ? { resetError } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private recordRuntimeEvent(
|
||||||
|
eventType: string,
|
||||||
|
safeSummary: Record<string, unknown>,
|
||||||
|
level: QqbotPluginRuntimeEvent['level'] = 'error',
|
||||||
) {
|
) {
|
||||||
this.runtimeEvents.push({
|
this.runtimeEvents.push({
|
||||||
eventType,
|
eventType,
|
||||||
level: 'error',
|
level,
|
||||||
pluginKey: this.options.pluginKey,
|
pluginKey: this.options.pluginKey,
|
||||||
safeSummary,
|
safeSummary,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -38,6 +38,12 @@ export type QqbotPluginWorkerDriver = {
|
|||||||
request(message: QqbotPluginWorkerRequest): Promise<unknown>;
|
request(message: QqbotPluginWorkerRequest): Promise<unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type QqbotPluginWorkerRequestQueue = {
|
||||||
|
close(): Promise<void>;
|
||||||
|
request(message: QqbotPluginWorkerRequest): Promise<unknown>;
|
||||||
|
reset(): Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
export type QqbotPluginWorkerRuntimeOptions = {
|
export type QqbotPluginWorkerRuntimeOptions = {
|
||||||
defaultTimeoutMs: number;
|
defaultTimeoutMs: number;
|
||||||
installationId: string;
|
installationId: string;
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { forwardRef, Module } from '@nestjs/common';
|
import { forwardRef, Module } from '@nestjs/common';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { BullModule } from '@nestjs/bullmq';
|
||||||
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { AdminAuthGuardModule } from '@/modules/admin/identity/auth/admin-auth-guard.module';
|
import { AdminAuthGuardModule } from '@/modules/admin/identity/auth/admin-auth-guard.module';
|
||||||
import { DictModule } from '@/modules/admin/platform-config/dict/dict.module';
|
import { DictModule } from '@/modules/admin/platform-config/dict/dict.module';
|
||||||
@ -16,7 +17,11 @@ import { QQBOT_PLUGIN_PLATFORM_ENTITIES } from './infrastructure/persistence';
|
|||||||
import { QqbotBuiltinPluginPackageLoaderService } from './infrastructure/integration/package/builtin-plugin-package-loader.service';
|
import { QqbotBuiltinPluginPackageLoaderService } from './infrastructure/integration/package/builtin-plugin-package-loader.service';
|
||||||
import { QqbotPluginPackageReaderService } from './infrastructure/integration/package/plugin-package-reader.service';
|
import { QqbotPluginPackageReaderService } from './infrastructure/integration/package/plugin-package-reader.service';
|
||||||
import { QqbotPluginHttpClientService } from './infrastructure/integration/sdk';
|
import { QqbotPluginHttpClientService } from './infrastructure/integration/sdk';
|
||||||
import { QqbotBuiltinPluginWorkerRuntimeFactoryService } from './infrastructure/integration/runtime';
|
import {
|
||||||
|
QqbotBuiltinPluginWorkerRuntimeFactoryService,
|
||||||
|
resolveQqbotPluginQueueConnection,
|
||||||
|
resolveQqbotPluginQueuePrefix,
|
||||||
|
} from './infrastructure/integration/runtime';
|
||||||
import { QQBOT_PLUGIN_RUNTIME_FACTORY } from './application/plugin-platform.service';
|
import { QQBOT_PLUGIN_RUNTIME_FACTORY } from './application/plugin-platform.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@ -28,6 +33,14 @@ import { QQBOT_PLUGIN_RUNTIME_FACTORY } from './application/plugin-platform.serv
|
|||||||
],
|
],
|
||||||
imports: [
|
imports: [
|
||||||
ConfigModule,
|
ConfigModule,
|
||||||
|
BullModule.forRootAsync({
|
||||||
|
imports: [ConfigModule],
|
||||||
|
inject: [ConfigService],
|
||||||
|
useFactory: (configService: ConfigService) => ({
|
||||||
|
connection: resolveQqbotPluginQueueConnection(configService),
|
||||||
|
prefix: resolveQqbotPluginQueuePrefix(configService),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
AdminAuthGuardModule,
|
AdminAuthGuardModule,
|
||||||
DictModule,
|
DictModule,
|
||||||
forwardRef(() => QqbotCoreModule),
|
forwardRef(() => QqbotCoreModule),
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
|
import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { MODULE_METADATA } from '@nestjs/common/constants';
|
import { MODULE_METADATA } from '@nestjs/common/constants';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { QQBOT_PLUGIN_RUNTIME_FACTORY } from '../../../../src/modules/qqbot/plugin-platform/application/plugin-platform.service';
|
import { QQBOT_PLUGIN_RUNTIME_FACTORY } from '../../../../src/modules/qqbot/plugin-platform/application/plugin-platform.service';
|
||||||
import { QqbotPluginPlatformService } from '../../../../src/modules/qqbot/plugin-platform/application/plugin-platform.service';
|
import { QqbotPluginPlatformService } from '../../../../src/modules/qqbot/plugin-platform/application/plugin-platform.service';
|
||||||
import { QqbotEventPluginRegistryService } from '../../../../src/modules/qqbot/plugin-platform/application/registry/qqbot-event-plugin-registry.service';
|
import { QqbotEventPluginRegistryService } from '../../../../src/modules/qqbot/plugin-platform/application/registry/qqbot-event-plugin-registry.service';
|
||||||
@ -49,6 +50,7 @@ describe('QQBot plugin platform lifecycle runtime contract', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(dependencies?.[0]).toBe(QqbotBuiltinPluginPackageLoaderService);
|
expect(dependencies?.[0]).toBe(QqbotBuiltinPluginPackageLoaderService);
|
||||||
|
expect(dependencies?.[1]).toBe(ConfigService);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses a real worker-thread boundary for built-in plugin runtimes', () => {
|
it('uses a real worker-thread boundary for built-in plugin runtimes', () => {
|
||||||
@ -62,6 +64,45 @@ describe('QQBot plugin platform lifecycle runtime contract', () => {
|
|||||||
expect(source).not.toContain('new QqbotBuiltinPluginWorkerDriver');
|
expect(source).not.toContain('new QqbotBuiltinPluginWorkerDriver');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses BullMQ queues to serialize plugin worker requests instead of ad hoc in-memory chaining', () => {
|
||||||
|
const source = [
|
||||||
|
readSource(
|
||||||
|
'src/modules/qqbot/plugin-platform/plugin-platform.module.ts',
|
||||||
|
),
|
||||||
|
readSource(
|
||||||
|
'src/modules/qqbot/plugin-platform/infrastructure/integration/runtime/bullmq-plugin-worker-request.queue.ts',
|
||||||
|
),
|
||||||
|
readSource(
|
||||||
|
'src/modules/qqbot/plugin-platform/infrastructure/integration/runtime/builtin-plugin-worker-runtime.factory.ts',
|
||||||
|
),
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
expect(source).toContain("@nestjs/bullmq");
|
||||||
|
expect(source).toContain("from 'bullmq'");
|
||||||
|
expect(source).toContain('new Queue(');
|
||||||
|
expect(source).toContain('new Worker(');
|
||||||
|
expect(source).toContain('new QueueEvents(');
|
||||||
|
expect(source).toContain('concurrency: 1');
|
||||||
|
expect(source).toContain('installation.id');
|
||||||
|
expect(source).toContain('options.installationId');
|
||||||
|
expect(source).toContain("this.queue.on('error'");
|
||||||
|
expect(source).toContain("this.queueEvents.on('error'");
|
||||||
|
expect(source).toContain("this.worker.on('error'");
|
||||||
|
expect(source).toContain('expiresAt');
|
||||||
|
expect(source).toContain('workerInstanceId');
|
||||||
|
expect(source).toContain('worker-request-expired');
|
||||||
|
expect(source).not.toContain('previous.catch(() => undefined).then');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps API deployment as a single plugin queue consumer during releases', () => {
|
||||||
|
const source = readSource('k8s/prod/api.yaml');
|
||||||
|
|
||||||
|
expect(source).toContain('replicas: 1');
|
||||||
|
expect(source).toContain('type: Recreate');
|
||||||
|
expect(source).not.toContain('maxSurge: 1');
|
||||||
|
expect(source).not.toContain('maxUnavailable: 0');
|
||||||
|
});
|
||||||
|
|
||||||
it('uses dedicated lifecycle use cases instead of direct status flips', () => {
|
it('uses dedicated lifecycle use cases instead of direct status flips', () => {
|
||||||
const controller = readSource(
|
const controller = readSource(
|
||||||
'src/modules/qqbot/plugin-platform/contract/plugin-platform.controller.ts',
|
'src/modules/qqbot/plugin-platform/contract/plugin-platform.controller.ts',
|
||||||
@ -181,7 +222,7 @@ describe('QQBot plugin platform lifecycle runtime contract', () => {
|
|||||||
const installation = {
|
const installation = {
|
||||||
id: 'installation-1',
|
id: 'installation-1',
|
||||||
installedPath: 'D:/plugins/demo-plugin',
|
installedPath: 'D:/plugins/demo-plugin',
|
||||||
pluginId: 'plugin-1',
|
pluginId: '2060000000000000002',
|
||||||
runtimeStatus: 'stopped',
|
runtimeStatus: 'stopped',
|
||||||
status: 'installed',
|
status: 'installed',
|
||||||
versionId: 'version-1',
|
versionId: 'version-1',
|
||||||
@ -212,10 +253,24 @@ describe('QQBot plugin platform lifecycle runtime contract', () => {
|
|||||||
const configRepository = createRepository();
|
const configRepository = createRepository();
|
||||||
const assetRepository = createRepository();
|
const assetRepository = createRepository();
|
||||||
const runtimeEventRepository = createRepository();
|
const runtimeEventRepository = createRepository();
|
||||||
|
const runtimeEventBatches = [
|
||||||
|
[],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
eventType: 'worker-dispose-finished',
|
||||||
|
level: 'info',
|
||||||
|
pluginKey: 'demo-plugin',
|
||||||
|
safeSummary: {
|
||||||
|
phase: 'dispose',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
];
|
||||||
const worker = {
|
const worker = {
|
||||||
activate: jest.fn(async () => ({ ok: true })),
|
activate: jest.fn(async () => ({ ok: true })),
|
||||||
deactivate: jest.fn(async () => ({ ok: true })),
|
deactivate: jest.fn(async () => ({ ok: true })),
|
||||||
dispose: jest.fn(async () => undefined),
|
dispose: jest.fn(async () => undefined),
|
||||||
|
drainRuntimeEvents: jest.fn(() => runtimeEventBatches.shift() || []),
|
||||||
health: jest.fn(async () => ({ ok: true })),
|
health: jest.fn(async () => ({ ok: true })),
|
||||||
load: jest.fn(async () => ({ ok: true })),
|
load: jest.fn(async () => ({ ok: true })),
|
||||||
};
|
};
|
||||||
@ -290,6 +345,132 @@ describe('QQBot plugin platform lifecycle runtime contract', () => {
|
|||||||
);
|
);
|
||||||
expect(worker.deactivate).toHaveBeenCalled();
|
expect(worker.deactivate).toHaveBeenCalled();
|
||||||
expect(worker.dispose).toHaveBeenCalled();
|
expect(worker.dispose).toHaveBeenCalled();
|
||||||
|
expect(runtimeEventRepository.save).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
eventType: 'worker-dispose-finished',
|
||||||
|
installationId: installation.id,
|
||||||
|
pluginId: installation.pluginId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps worker cleanup best-effort when stop runtime event persistence fails', async () => {
|
||||||
|
const manifest = {
|
||||||
|
assets: [],
|
||||||
|
configSchema: {},
|
||||||
|
entry: 'src/index.ts',
|
||||||
|
events: [],
|
||||||
|
legacyAliases: [],
|
||||||
|
migrations: [],
|
||||||
|
minApiSdkVersion: '1.0.0',
|
||||||
|
name: 'Demo Plugin',
|
||||||
|
operations: [],
|
||||||
|
permissions: [],
|
||||||
|
pluginKey: 'demo-plugin',
|
||||||
|
runtime: {
|
||||||
|
maxConcurrency: 1,
|
||||||
|
memoryMb: 128,
|
||||||
|
timeoutMs: 5000,
|
||||||
|
workerType: 'node-worker',
|
||||||
|
},
|
||||||
|
version: '0.1.0',
|
||||||
|
};
|
||||||
|
const installation = {
|
||||||
|
id: 'installation-cleanup',
|
||||||
|
installedPath: 'D:/plugins/demo-plugin',
|
||||||
|
pluginId: '2060000000000000003',
|
||||||
|
runtimeStatus: 'stopped',
|
||||||
|
status: 'installed',
|
||||||
|
versionId: 'version-cleanup',
|
||||||
|
};
|
||||||
|
const version = {
|
||||||
|
id: 'version-cleanup',
|
||||||
|
manifestJson: manifest,
|
||||||
|
packageHash: 'hash',
|
||||||
|
pluginId: installation.pluginId,
|
||||||
|
version: '0.1.0',
|
||||||
|
};
|
||||||
|
const createRepository = (findOneValue?: unknown) => ({
|
||||||
|
find: jest.fn(async () => []),
|
||||||
|
findAndCount: jest.fn(async () => [[], 0]),
|
||||||
|
findOne: jest.fn(async () => findOneValue),
|
||||||
|
save: jest.fn(async (value) => value),
|
||||||
|
update: jest.fn(async () => ({ affected: 1 })),
|
||||||
|
});
|
||||||
|
const runtimeEventRepository = createRepository();
|
||||||
|
runtimeEventRepository.save = jest.fn(async (value) => {
|
||||||
|
if (
|
||||||
|
value &&
|
||||||
|
typeof value === 'object' &&
|
||||||
|
(value as { eventType?: string }).eventType ===
|
||||||
|
'worker-dispose-finished'
|
||||||
|
) {
|
||||||
|
throw new Error('runtime event db unavailable');
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
});
|
||||||
|
const runtimeEventBatches = [
|
||||||
|
[],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
eventType: 'worker-dispose-finished',
|
||||||
|
level: 'info',
|
||||||
|
pluginKey: 'demo-plugin',
|
||||||
|
safeSummary: {
|
||||||
|
phase: 'dispose',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
];
|
||||||
|
const worker = {
|
||||||
|
activate: jest.fn(async () => ({ ok: true })),
|
||||||
|
deactivate: jest.fn(async () => ({ ok: true })),
|
||||||
|
dispose: jest.fn(async () => undefined),
|
||||||
|
drainRuntimeEvents: jest.fn(() => runtimeEventBatches.shift() || []),
|
||||||
|
health: jest.fn(async () => ({ ok: true })),
|
||||||
|
load: jest.fn(async () => ({ ok: true })),
|
||||||
|
};
|
||||||
|
const service = new (QqbotPluginPlatformService as any)(
|
||||||
|
createRepository({
|
||||||
|
id: installation.pluginId,
|
||||||
|
pluginKey: 'demo-plugin',
|
||||||
|
}),
|
||||||
|
createRepository(version),
|
||||||
|
createRepository(installation),
|
||||||
|
createRepository(),
|
||||||
|
createRepository(),
|
||||||
|
createRepository(),
|
||||||
|
createRepository(),
|
||||||
|
createRepository(),
|
||||||
|
runtimeEventRepository,
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
create: jest.fn(() => worker),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
setPluginActive: jest.fn(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
setPluginActive: jest.fn(),
|
||||||
|
},
|
||||||
|
) as QqbotPluginPlatformService;
|
||||||
|
|
||||||
|
await service.enableInstallation({ id: installation.id });
|
||||||
|
await expect(
|
||||||
|
service.disableInstallation({ id: installation.id }),
|
||||||
|
).resolves.toMatchObject({
|
||||||
|
id: installation.id,
|
||||||
|
runtimeStatus: 'stopped',
|
||||||
|
status: 'disabled',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(worker.dispose).toHaveBeenCalled();
|
||||||
|
expect(
|
||||||
|
(service as any).activeWorkers.has(installation.id),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
(service as any).activeWorkerContexts.has(installation.id),
|
||||||
|
).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('routes enabled command and message executions through active worker runtimes', async () => {
|
it('routes enabled command and message executions through active worker runtimes', async () => {
|
||||||
@ -331,7 +512,7 @@ describe('QQBot plugin platform lifecycle runtime contract', () => {
|
|||||||
const installation = {
|
const installation = {
|
||||||
id: 'installation-execute',
|
id: 'installation-execute',
|
||||||
installedPath: 'D:/plugins/demo-plugin',
|
installedPath: 'D:/plugins/demo-plugin',
|
||||||
pluginId: 'plugin-execute',
|
pluginId: '2060000000000000001',
|
||||||
runtimeStatus: 'stopped',
|
runtimeStatus: 'stopped',
|
||||||
status: 'installed',
|
status: 'installed',
|
||||||
versionId: 'version-execute',
|
versionId: 'version-execute',
|
||||||
@ -350,10 +531,25 @@ describe('QQBot plugin platform lifecycle runtime contract', () => {
|
|||||||
save: jest.fn(async (value) => value),
|
save: jest.fn(async (value) => value),
|
||||||
update: jest.fn(async () => ({ affected: 1 })),
|
update: jest.fn(async () => ({ affected: 1 })),
|
||||||
});
|
});
|
||||||
|
const runtimeEventRepository = createRepository();
|
||||||
|
const runtimeEventBatches = [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
eventType: 'worker-recovered',
|
||||||
|
level: 'info',
|
||||||
|
pluginKey: 'demo-plugin',
|
||||||
|
safeSummary: {
|
||||||
|
status: 'active',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
];
|
||||||
const worker = {
|
const worker = {
|
||||||
activate: jest.fn(async () => ({ ok: true })),
|
activate: jest.fn(async () => ({ ok: true })),
|
||||||
deactivate: jest.fn(async () => ({ ok: true })),
|
deactivate: jest.fn(async () => ({ ok: true })),
|
||||||
dispose: jest.fn(async () => undefined),
|
dispose: jest.fn(async () => undefined),
|
||||||
|
drainRuntimeEvents: jest.fn(() => runtimeEventBatches.shift() || []),
|
||||||
executeOperation: jest.fn(async () => ({ replyText: 'worker-ok' })),
|
executeOperation: jest.fn(async () => ({ replyText: 'worker-ok' })),
|
||||||
handleEvent: jest.fn(async () => true),
|
handleEvent: jest.fn(async () => true),
|
||||||
health: jest.fn(async () => ({ ok: true })),
|
health: jest.fn(async () => ({ ok: true })),
|
||||||
@ -398,7 +594,7 @@ describe('QQBot plugin platform lifecycle runtime contract', () => {
|
|||||||
createRepository(),
|
createRepository(),
|
||||||
createRepository(),
|
createRepository(),
|
||||||
createRepository(),
|
createRepository(),
|
||||||
createRepository(),
|
runtimeEventRepository,
|
||||||
argumentParser,
|
argumentParser,
|
||||||
runtimeFactory,
|
runtimeFactory,
|
||||||
fallbackCommandRegistry,
|
fallbackCommandRegistry,
|
||||||
@ -452,6 +648,17 @@ describe('QQBot plugin platform lifecycle runtime contract', () => {
|
|||||||
);
|
);
|
||||||
expect(fallbackCommandRegistry.execute).not.toHaveBeenCalled();
|
expect(fallbackCommandRegistry.execute).not.toHaveBeenCalled();
|
||||||
expect(fallbackEventRegistry.dispatchMessage).not.toHaveBeenCalled();
|
expect(fallbackEventRegistry.dispatchMessage).not.toHaveBeenCalled();
|
||||||
|
expect(runtimeEventRepository.save).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
eventType: 'worker-recovered',
|
||||||
|
installationId: installation.id,
|
||||||
|
level: 'info',
|
||||||
|
pluginId: installation.pluginId,
|
||||||
|
safeSummary: {
|
||||||
|
status: 'active',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not execute workers for persisted disabled built-in plugins', async () => {
|
it('does not execute workers for persisted disabled built-in plugins', async () => {
|
||||||
|
|||||||
@ -1,8 +1,13 @@
|
|||||||
|
import type { ConfigService } from '@nestjs/config';
|
||||||
import {
|
import {
|
||||||
|
createQqbotBullmqWorkerQueueOptions,
|
||||||
QqbotPluginRuntimeError,
|
QqbotPluginRuntimeError,
|
||||||
|
QqbotPluginWorkerResponseError,
|
||||||
QqbotPluginWorkerRuntime,
|
QqbotPluginWorkerRuntime,
|
||||||
|
resolveQqbotPluginQueueConnection,
|
||||||
type QqbotPluginWorkerDriver,
|
type QqbotPluginWorkerDriver,
|
||||||
type QqbotPluginWorkerRequest,
|
type QqbotPluginWorkerRequest,
|
||||||
|
type QqbotPluginWorkerRequestQueue,
|
||||||
} from '../../../../src/modules/qqbot/plugin-platform/infrastructure/integration/runtime';
|
} from '../../../../src/modules/qqbot/plugin-platform/infrastructure/integration/runtime';
|
||||||
import { createQqbotPluginSdk } from '../../../../src/modules/qqbot/plugin-platform/infrastructure/integration/sdk';
|
import { createQqbotPluginSdk } from '../../../../src/modules/qqbot/plugin-platform/infrastructure/integration/sdk';
|
||||||
|
|
||||||
@ -34,8 +39,75 @@ class RecordingDriver implements QqbotPluginWorkerDriver {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class RecordingRequestQueue implements QqbotPluginWorkerRequestQueue {
|
||||||
|
private previous = Promise.resolve();
|
||||||
|
|
||||||
|
constructor(private readonly driver: QqbotPluginWorkerDriver) {}
|
||||||
|
|
||||||
|
async close() {
|
||||||
|
await this.driver.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
request(message: QqbotPluginWorkerRequest): Promise<unknown> {
|
||||||
|
const run = this.previous
|
||||||
|
.catch(() => undefined)
|
||||||
|
.then(() => this.driver.request(message));
|
||||||
|
this.previous = run.then(
|
||||||
|
() => undefined,
|
||||||
|
() => undefined,
|
||||||
|
);
|
||||||
|
return run;
|
||||||
|
}
|
||||||
|
|
||||||
|
async reset() {
|
||||||
|
await this.driver.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class GenerationAwareRequestQueue implements QqbotPluginWorkerRequestQueue {
|
||||||
|
private generation = 0;
|
||||||
|
private previous = Promise.resolve();
|
||||||
|
|
||||||
|
constructor(private readonly driver: QqbotPluginWorkerDriver) {}
|
||||||
|
|
||||||
|
async close() {
|
||||||
|
await this.driver.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
request(message: QqbotPluginWorkerRequest): Promise<unknown> {
|
||||||
|
const generation = this.generation;
|
||||||
|
const run = this.previous
|
||||||
|
.catch(() => undefined)
|
||||||
|
.then(async () => {
|
||||||
|
if (generation !== this.generation) {
|
||||||
|
const error = new Error('stale worker queue request');
|
||||||
|
error.name = 'QqbotPluginWorkerStaleRequestError';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await this.driver.request(message);
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof QqbotPluginWorkerResponseError)) {
|
||||||
|
this.generation += 1;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.previous = run.then(
|
||||||
|
() => undefined,
|
||||||
|
() => undefined,
|
||||||
|
);
|
||||||
|
return run;
|
||||||
|
}
|
||||||
|
|
||||||
|
async reset() {
|
||||||
|
this.generation += 1;
|
||||||
|
await this.driver.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const createRuntime = (driver = new RecordingDriver()) => {
|
const createRuntime = (driver = new RecordingDriver()) => {
|
||||||
const runtime = new QqbotPluginWorkerRuntime(driver, {
|
const runtime = new QqbotPluginWorkerRuntime(new RecordingRequestQueue(driver), {
|
||||||
defaultTimeoutMs: 50,
|
defaultTimeoutMs: 50,
|
||||||
installationId: 'install-1',
|
installationId: 'install-1',
|
||||||
pluginKey: 'demo-plugin',
|
pluginKey: 'demo-plugin',
|
||||||
@ -45,6 +117,190 @@ const createRuntime = (driver = new RecordingDriver()) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe('QQBot plugin worker runtime', () => {
|
describe('QQBot plugin worker runtime', () => {
|
||||||
|
it('serializes concurrent worker execution requests for one plugin runtime', async () => {
|
||||||
|
const releaseFirst = createDeferred<void>();
|
||||||
|
const releaseSecond = createDeferred<void>();
|
||||||
|
let activeRequests = 0;
|
||||||
|
let maxActiveRequests = 0;
|
||||||
|
const requestOrder: string[] = [];
|
||||||
|
const driver: QqbotPluginWorkerDriver = {
|
||||||
|
dispose: jest.fn(async () => undefined),
|
||||||
|
request: jest.fn(async (message) => {
|
||||||
|
activeRequests += 1;
|
||||||
|
maxActiveRequests = Math.max(maxActiveRequests, activeRequests);
|
||||||
|
requestOrder.push(message.operationId || message.type);
|
||||||
|
|
||||||
|
if (message.operationId === 'op-1') {
|
||||||
|
await releaseFirst.promise;
|
||||||
|
}
|
||||||
|
if (message.operationId === 'op-2') {
|
||||||
|
await releaseSecond.promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
activeRequests -= 1;
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
operationId: message.operationId,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const runtime = new QqbotPluginWorkerRuntime(new RecordingRequestQueue(driver), {
|
||||||
|
defaultTimeoutMs: 100,
|
||||||
|
installationId: 'install-serial',
|
||||||
|
pluginKey: 'demo-plugin',
|
||||||
|
});
|
||||||
|
|
||||||
|
const first = runtime.executeOperation({
|
||||||
|
input: { text: 'first' },
|
||||||
|
operationId: 'op-1',
|
||||||
|
operationKey: 'demo-plugin.echo',
|
||||||
|
});
|
||||||
|
const second = runtime.executeOperation({
|
||||||
|
input: { text: 'second' },
|
||||||
|
operationId: 'op-2',
|
||||||
|
operationKey: 'demo-plugin.echo',
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await waitUntil(() => requestOrder.includes('op-1'));
|
||||||
|
expect(requestOrder).toEqual(['op-1']);
|
||||||
|
|
||||||
|
releaseFirst.resolve();
|
||||||
|
await expect(first).resolves.toMatchObject({ operationId: 'op-1' });
|
||||||
|
|
||||||
|
await waitUntil(() => requestOrder.includes('op-2'));
|
||||||
|
expect(maxActiveRequests).toBe(1);
|
||||||
|
releaseSecond.resolve();
|
||||||
|
await expect(second).resolves.toMatchObject({ operationId: 'op-2' });
|
||||||
|
} finally {
|
||||||
|
releaseFirst.resolve();
|
||||||
|
releaseSecond.resolve();
|
||||||
|
await Promise.allSettled([first, second]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recovers a failed worker by reloading and activating it before the next request', async () => {
|
||||||
|
const requestTypes: string[] = [];
|
||||||
|
const driver: QqbotPluginWorkerDriver = {
|
||||||
|
dispose: jest.fn(async () => undefined),
|
||||||
|
request: jest.fn(async (message) => {
|
||||||
|
requestTypes.push(message.type);
|
||||||
|
if (message.operationId === 'op-crash') {
|
||||||
|
throw new Error('worker crashed');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
operationId: message.operationId,
|
||||||
|
type: message.type,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const runtime = new QqbotPluginWorkerRuntime(new RecordingRequestQueue(driver), {
|
||||||
|
defaultTimeoutMs: 50,
|
||||||
|
installationId: 'install-recover',
|
||||||
|
pluginKey: 'demo-plugin',
|
||||||
|
});
|
||||||
|
const manifest = {
|
||||||
|
entry: 'src/index.ts',
|
||||||
|
pluginKey: 'demo-plugin',
|
||||||
|
version: '0.1.0',
|
||||||
|
};
|
||||||
|
|
||||||
|
await runtime.load(manifest);
|
||||||
|
await runtime.activate();
|
||||||
|
await expect(
|
||||||
|
runtime.executeOperation({
|
||||||
|
input: { text: 'boom' },
|
||||||
|
operationId: 'op-crash',
|
||||||
|
operationKey: 'demo-plugin.echo',
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
code: 'PLUGIN_WORKER_CRASH',
|
||||||
|
});
|
||||||
|
expect(runtime.status).toBe('failed');
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
runtime.executeOperation({
|
||||||
|
input: { text: 'after crash' },
|
||||||
|
operationId: 'op-after-crash',
|
||||||
|
operationKey: 'demo-plugin.echo',
|
||||||
|
}),
|
||||||
|
).resolves.toMatchObject({ operationId: 'op-after-crash' });
|
||||||
|
|
||||||
|
expect(driver.dispose).toHaveBeenCalled();
|
||||||
|
expect(requestTypes).toEqual([
|
||||||
|
'load',
|
||||||
|
'activate',
|
||||||
|
'executeOperation',
|
||||||
|
'load',
|
||||||
|
'activate',
|
||||||
|
'executeOperation',
|
||||||
|
]);
|
||||||
|
expect(runtime.status).toBe('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recovers before retrying requests that were already queued when a worker crashed', async () => {
|
||||||
|
const requestTypes: string[] = [];
|
||||||
|
const driver: QqbotPluginWorkerDriver = {
|
||||||
|
dispose: jest.fn(async () => undefined),
|
||||||
|
request: jest.fn(async (message) => {
|
||||||
|
requestTypes.push(message.operationId || message.type);
|
||||||
|
if (message.operationId === 'op-crash') {
|
||||||
|
throw new Error('worker crashed');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
operationId: message.operationId,
|
||||||
|
type: message.type,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const runtime = new QqbotPluginWorkerRuntime(
|
||||||
|
new GenerationAwareRequestQueue(driver),
|
||||||
|
{
|
||||||
|
defaultTimeoutMs: 50,
|
||||||
|
installationId: 'install-stale-retry',
|
||||||
|
pluginKey: 'demo-plugin',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await runtime.load({
|
||||||
|
entry: 'src/index.ts',
|
||||||
|
pluginKey: 'demo-plugin',
|
||||||
|
version: '0.1.0',
|
||||||
|
});
|
||||||
|
await runtime.activate();
|
||||||
|
|
||||||
|
const first = runtime
|
||||||
|
.executeOperation({
|
||||||
|
input: { text: 'boom' },
|
||||||
|
operationId: 'op-crash',
|
||||||
|
operationKey: 'demo-plugin.echo',
|
||||||
|
})
|
||||||
|
.catch((error) => error);
|
||||||
|
const second = runtime.executeOperation({
|
||||||
|
input: { text: 'after crash' },
|
||||||
|
operationId: 'op-after-crash',
|
||||||
|
operationKey: 'demo-plugin.echo',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(first).resolves.toMatchObject({
|
||||||
|
code: 'PLUGIN_WORKER_CRASH',
|
||||||
|
});
|
||||||
|
await expect(second).resolves.toMatchObject({
|
||||||
|
operationId: 'op-after-crash',
|
||||||
|
});
|
||||||
|
expect(requestTypes).toEqual([
|
||||||
|
'load',
|
||||||
|
'activate',
|
||||||
|
'op-crash',
|
||||||
|
'load',
|
||||||
|
'activate',
|
||||||
|
'op-after-crash',
|
||||||
|
]);
|
||||||
|
expect(runtime.status).toBe('active');
|
||||||
|
});
|
||||||
|
|
||||||
it('sends lifecycle and execution RPC messages with correlation IDs and safe input summaries', async () => {
|
it('sends lifecycle and execution RPC messages with correlation IDs and safe input summaries', async () => {
|
||||||
const { driver, runtime } = createRuntime();
|
const { driver, runtime } = createRuntime();
|
||||||
driver.responses.set('executeOperation', { replyText: 'ok' });
|
driver.responses.set('executeOperation', { replyText: 'ok' });
|
||||||
@ -143,6 +399,42 @@ describe('QQBot plugin worker runtime', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps plugin response errors from poisoning the worker runtime', async () => {
|
||||||
|
const driver = new RecordingDriver(
|
||||||
|
new Map([
|
||||||
|
[
|
||||||
|
'executeOperation',
|
||||||
|
new QqbotPluginWorkerResponseError({
|
||||||
|
message: '业务参数错误',
|
||||||
|
name: 'PluginCommandError',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const { runtime } = createRuntime(driver);
|
||||||
|
|
||||||
|
await runtime.load({
|
||||||
|
entry: 'src/index.ts',
|
||||||
|
pluginKey: 'demo-plugin',
|
||||||
|
version: '0.1.0',
|
||||||
|
});
|
||||||
|
await runtime.activate();
|
||||||
|
await expect(
|
||||||
|
runtime.executeOperation({
|
||||||
|
input: { text: 'bad input' },
|
||||||
|
operationId: 'op-plugin-error',
|
||||||
|
operationKey: 'demo-plugin.echo',
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
message: '业务参数错误',
|
||||||
|
name: 'PluginCommandError',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(runtime.status).toBe('active');
|
||||||
|
expect(driver.disposed).toBe(false);
|
||||||
|
expect(runtime.listRuntimeEvents()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
it('times out slow worker calls and disposes the driver boundary', async () => {
|
it('times out slow worker calls and disposes the driver boundary', async () => {
|
||||||
const driver: QqbotPluginWorkerDriver = {
|
const driver: QqbotPluginWorkerDriver = {
|
||||||
dispose: jest.fn(async () => undefined),
|
dispose: jest.fn(async () => undefined),
|
||||||
@ -150,7 +442,7 @@ describe('QQBot plugin worker runtime', () => {
|
|||||||
() => new Promise((resolve) => setTimeout(resolve, 1000)),
|
() => new Promise((resolve) => setTimeout(resolve, 1000)),
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
const runtime = new QqbotPluginWorkerRuntime(driver, {
|
const runtime = new QqbotPluginWorkerRuntime(new RecordingRequestQueue(driver), {
|
||||||
defaultTimeoutMs: 5,
|
defaultTimeoutMs: 5,
|
||||||
installationId: 'install-timeout',
|
installationId: 'install-timeout',
|
||||||
pluginKey: 'demo-plugin',
|
pluginKey: 'demo-plugin',
|
||||||
@ -181,7 +473,7 @@ describe('QQBot plugin worker runtime', () => {
|
|||||||
dispose: jest.fn(async () => undefined),
|
dispose: jest.fn(async () => undefined),
|
||||||
request: jest.fn(async () => ({ ok: true })),
|
request: jest.fn(async () => ({ ok: true })),
|
||||||
};
|
};
|
||||||
const runtime = new QqbotPluginWorkerRuntime(driver, {
|
const runtime = new QqbotPluginWorkerRuntime(new RecordingRequestQueue(driver), {
|
||||||
defaultTimeoutMs: 5,
|
defaultTimeoutMs: 5,
|
||||||
installationId: 'install-success',
|
installationId: 'install-success',
|
||||||
pluginKey: 'demo-plugin',
|
pluginKey: 'demo-plugin',
|
||||||
@ -201,6 +493,29 @@ describe('QQBot plugin worker runtime', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function createDeferred<T>() {
|
||||||
|
let resolve!: (value: T | PromiseLike<T>) => void;
|
||||||
|
let reject!: (reason?: unknown) => void;
|
||||||
|
const promise = new Promise<T>((innerResolve, innerReject) => {
|
||||||
|
resolve = innerResolve;
|
||||||
|
reject = innerReject;
|
||||||
|
});
|
||||||
|
return { promise, reject, resolve };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitUntil(
|
||||||
|
condition: () => boolean,
|
||||||
|
timeoutMs = 100,
|
||||||
|
): Promise<void> {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
while (!condition()) {
|
||||||
|
if (Date.now() > deadline) {
|
||||||
|
throw new Error('condition was not met before timeout');
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
describe('QQBot plugin SDK contract', () => {
|
describe('QQBot plugin SDK contract', () => {
|
||||||
it('exposes only host-controlled capabilities to plugin code', () => {
|
it('exposes only host-controlled capabilities to plugin code', () => {
|
||||||
const sdk = createQqbotPluginSdk({
|
const sdk = createQqbotPluginSdk({
|
||||||
@ -248,3 +563,49 @@ describe('QQBot plugin SDK contract', () => {
|
|||||||
expect('repository' in sdk).toBe(false);
|
expect('repository' in sdk).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('QQBot plugin worker queue config', () => {
|
||||||
|
it('requires an explicit Redis host for plugin worker queues', () => {
|
||||||
|
const configService = createConfigService({});
|
||||||
|
|
||||||
|
expect(() => resolveQqbotPluginQueueConnection(configService)).toThrow(
|
||||||
|
'QQBot 插件队列缺少 Redis 主机配置',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses safe numeric defaults when optional Redis values are blank', () => {
|
||||||
|
const options = createQqbotBullmqWorkerQueueOptions(
|
||||||
|
createConfigService({
|
||||||
|
QQBOT_PLUGIN_QUEUE_REDIS_DB: '',
|
||||||
|
QQBOT_PLUGIN_QUEUE_REDIS_HOST: 'redis.local',
|
||||||
|
QQBOT_PLUGIN_QUEUE_REDIS_PORT: '',
|
||||||
|
QQBOT_PLUGIN_QUEUE_REDIS_PREFIX: '',
|
||||||
|
QQBOT_PLUGIN_QUEUE_REMOVE_ON_FAIL: '',
|
||||||
|
QQBOT_PLUGIN_QUEUE_WAIT_BUFFER_MS: '',
|
||||||
|
}),
|
||||||
|
'bangdream',
|
||||||
|
'install-1',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(options).toMatchObject({
|
||||||
|
connection: {
|
||||||
|
db: 0,
|
||||||
|
host: 'redis.local',
|
||||||
|
port: 6379,
|
||||||
|
},
|
||||||
|
installationId: 'install-1',
|
||||||
|
pluginKey: 'bangdream',
|
||||||
|
prefix: 'kt:qqbot:plugin-worker',
|
||||||
|
removeOnFailCount: 100,
|
||||||
|
waitUntilFinishedBufferMs: 5_000,
|
||||||
|
workerInstanceId: expect.any(String),
|
||||||
|
});
|
||||||
|
expect(options.workerInstanceId).toContain(':');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function createConfigService(values: Record<string, string>): ConfigService {
|
||||||
|
return {
|
||||||
|
get: <T = string | undefined>(key: string) => values[key] as T,
|
||||||
|
} as ConfigService;
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user