@oondemand/oon-core-back 0.7.16 → 0.7.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -0
- package/bin/cli.js +8 -1
- package/bin/delivery.js +1 -0
- package/package.json +1 -1
- package/src/api/access.routes.js +6 -0
- package/src/api/activation.routes.js +2 -19
- package/src/api/activationRateLimit.js +84 -0
- package/src/boot/database.js +45 -12
- package/src/boot/gracefulShutdown.js +64 -0
- package/src/boot/startServer.js +26 -3
- package/src/capabilities/transactionalEmail/models.js +3 -1
- package/src/capabilities/transactionalEmail/worker.js +9 -2
- package/src/core/database/policy.js +17 -1
- package/src/core/middlewares/auth.js +17 -0
- package/src/core/runtime/tenantDataPurge.js +166 -0
- package/src/core/storage/files.js +131 -0
- package/src/core/usage/reporter.js +193 -0
- package/src/core/utils/instanceActivation.js +20 -10
- package/src/delivery/buildImage.js +3 -0
- package/src/delivery/launchRuntime.js +28 -10
- package/src/delivery/runtime.js +133 -6
- package/src/delivery/supervisor.js +15 -4
- package/src/index.js +4 -0
- package/src/manifest/appManifest.js +4 -1
- package/src/manifest/processJobs.js +252 -0
- package/src/manifest/processRuntime.js +52 -4
- package/src/manifest/usageMetrics.js +39 -0
package/src/delivery/runtime.js
CHANGED
|
@@ -38,7 +38,40 @@ function readSecret(namespace, name, key) {
|
|
|
38
38
|
} catch { return null; }
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
// Piso da cota do namespace (valores anteriores, fixos para todo App).
|
|
42
|
+
const QUOTA_FLOOR = Object.freeze({ requestsCpuMilli: 2000, requestsMemoryMi: 2048, limitsCpuMilli: 4000, limitsMemoryMi: 4096, pods: 20 });
|
|
43
|
+
|
|
44
|
+
function cpuMilli(value) {
|
|
45
|
+
const text = String(value);
|
|
46
|
+
return text.endsWith("m") ? Number(text.slice(0, -1)) : Number(text) * 1000;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function memoryMi(value) {
|
|
50
|
+
const text = String(value);
|
|
51
|
+
if (text.endsWith("Gi")) return Number(text.slice(0, -2)) * 1024;
|
|
52
|
+
if (text.endsWith("Mi")) return Number(text.slice(0, -2));
|
|
53
|
+
throw new Error(`Unidade de memória não suportada: ${text}`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Cota do namespace calculada pelas réplicas e pelo perfil de recursos, com
|
|
58
|
+
* espaço para o pod extra do rolling update (maxSurge 1). Nunca fica abaixo
|
|
59
|
+
* do piso anterior, então Apps de 1 réplica mantêm a cota de sempre.
|
|
60
|
+
*/
|
|
61
|
+
function namespaceQuota({ replicas = 1, profile = "small" } = {}) {
|
|
62
|
+
const resources = RESOURCE_PROFILES[profile] || RESOURCE_PROFILES.small;
|
|
63
|
+
const pods = replicas + 1;
|
|
64
|
+
return {
|
|
65
|
+
requestsCpu: `${Math.max(QUOTA_FLOOR.requestsCpuMilli, pods * cpuMilli(resources.cpuRequest))}m`,
|
|
66
|
+
requestsMemory: `${Math.max(QUOTA_FLOOR.requestsMemoryMi, pods * memoryMi(resources.memoryRequest))}Mi`,
|
|
67
|
+
limitsCpu: `${Math.max(QUOTA_FLOOR.limitsCpuMilli, pods * cpuMilli(resources.cpuLimit))}m`,
|
|
68
|
+
limitsMemory: `${Math.max(QUOTA_FLOOR.limitsMemoryMi, pods * memoryMi(resources.memoryLimit))}Mi`,
|
|
69
|
+
pods: Math.max(QUOTA_FLOOR.pods, pods),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
41
73
|
function baselineManifest({ context }) {
|
|
74
|
+
const quota = namespaceQuota({ replicas: replicaCount(context), profile: context.resourceProfile });
|
|
42
75
|
return `apiVersion: v1
|
|
43
76
|
kind: Namespace
|
|
44
77
|
metadata:
|
|
@@ -66,7 +99,7 @@ apiVersion: v1
|
|
|
66
99
|
kind: ResourceQuota
|
|
67
100
|
metadata: { name: oon-runtime-quota, namespace: ${context.namespace} }
|
|
68
101
|
spec:
|
|
69
|
-
hard: { requests.cpu: "
|
|
102
|
+
hard: { requests.cpu: "${quota.requestsCpu}", requests.memory: ${quota.requestsMemory}, limits.cpu: "${quota.limitsCpu}", limits.memory: ${quota.limitsMemory}, pods: "${quota.pods}", services: "10" }
|
|
70
103
|
---
|
|
71
104
|
apiVersion: networking.k8s.io/v1
|
|
72
105
|
kind: NetworkPolicy
|
|
@@ -83,8 +116,74 @@ spec:
|
|
|
83
116
|
`;
|
|
84
117
|
}
|
|
85
118
|
|
|
119
|
+
// Cada slot contratado é uma réplica. O teto acompanha a cota de pods do
|
|
120
|
+
// namespace (baselineManifest), com folga para o pod extra do rolling update.
|
|
121
|
+
const MAX_REPLICAS = 10;
|
|
122
|
+
// preStop + OON_SHUTDOWN_GRACE_MS do supervisor (25 s) cabem neste prazo.
|
|
123
|
+
const PRE_STOP_SECONDS = 5;
|
|
124
|
+
const TERMINATION_GRACE_SECONDS = 40;
|
|
125
|
+
|
|
126
|
+
function replicaCount(context = {}) {
|
|
127
|
+
const value = context.replicas ?? 1;
|
|
128
|
+
if (!Number.isSafeInteger(value) || value < 1 || value > MAX_REPLICAS) {
|
|
129
|
+
throw new Error(`Número de réplicas inválido no contexto de delivery: ${JSON.stringify(value)} (esperado 1 a ${MAX_REPLICAS}).`);
|
|
130
|
+
}
|
|
131
|
+
return value;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// F4.2: modo de performance do ambiente (pool MongoDB 4, 8 ou 16).
|
|
135
|
+
const PERFORMANCE_MODES = ["min", "normal", "ultra"];
|
|
136
|
+
function performanceMode(context = {}) {
|
|
137
|
+
const value = context.performanceMode ?? "min";
|
|
138
|
+
if (!PERFORMANCE_MODES.includes(value)) {
|
|
139
|
+
throw new Error(`Modo de performance inválido no contexto de delivery: ${JSON.stringify(value)} (esperado ${PERFORMANCE_MODES.join(", ")}).`);
|
|
140
|
+
}
|
|
141
|
+
return value;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// F4.6: domínios personalizados do App (raiz e www), já com DNS aprovado na
|
|
145
|
+
// Central. Ficam num Ingress próprio, com certificado HTTP-01 do cert-manager
|
|
146
|
+
// (renovação automática), para que um domínio com problema não afete o host
|
|
147
|
+
// da plataforma.
|
|
148
|
+
const MAX_CUSTOM_DOMAINS = 10;
|
|
149
|
+
const HOSTNAME = /^(?=.{4,253}$)(?!-)[a-z0-9-]{1,63}(?<!-)(\.(?!-)[a-z0-9-]{1,63}(?<!-))+$/;
|
|
150
|
+
function customDomains(context = {}) {
|
|
151
|
+
const value = context.customDomains ?? [];
|
|
152
|
+
if (!Array.isArray(value) || value.length > MAX_CUSTOM_DOMAINS
|
|
153
|
+
|| value.some((host) => typeof host !== "string" || !HOSTNAME.test(host) || host === context.hostname)
|
|
154
|
+
|| new Set(value).size !== value.length) {
|
|
155
|
+
throw new Error(`Domínios personalizados inválidos no contexto de delivery: ${JSON.stringify(value)}.`);
|
|
156
|
+
}
|
|
157
|
+
return value;
|
|
158
|
+
}
|
|
159
|
+
function customDomainIngress({ context, infra }) {
|
|
160
|
+
const hosts = customDomains(context);
|
|
161
|
+
if (!hosts.length) return "";
|
|
162
|
+
return `---
|
|
163
|
+
apiVersion: networking.k8s.io/v1
|
|
164
|
+
kind: Ingress
|
|
165
|
+
metadata:
|
|
166
|
+
name: ${context.appCode}-custom
|
|
167
|
+
namespace: ${context.namespace}
|
|
168
|
+
annotations: { cert-manager.io/cluster-issuer: ${infra.CUSTOM_DOMAIN_CLUSTER_ISSUER || "letsencrypt-production-http01"} }
|
|
169
|
+
spec:
|
|
170
|
+
ingressClassName: ${infra.INGRESS_CLASS}
|
|
171
|
+
tls: [{ hosts: [${hosts.join(", ")}], secretName: ${context.appCode}-custom-tls }]
|
|
172
|
+
rules:
|
|
173
|
+
${hosts.map((host) => ` - host: ${host}
|
|
174
|
+
http:
|
|
175
|
+
paths:
|
|
176
|
+
- path: /
|
|
177
|
+
pathType: Prefix
|
|
178
|
+
backend:
|
|
179
|
+
service: { name: ${context.appCode}, port: { name: http } }`).join("\n")}
|
|
180
|
+
`;
|
|
181
|
+
}
|
|
182
|
+
|
|
86
183
|
function runtimeManifest({ context, infra, imageRef, profile = "small" }) {
|
|
87
184
|
const resources = RESOURCE_PROFILES[profile] || RESOURCE_PROFILES.small;
|
|
185
|
+
const replicas = replicaCount(context);
|
|
186
|
+
const mode = performanceMode(context);
|
|
88
187
|
const releaseLabel = kubernetesLabelValue(context.releaseId);
|
|
89
188
|
const releaseAnnotation = JSON.stringify(String(context.releaseId));
|
|
90
189
|
return `apiVersion: apps/v1
|
|
@@ -93,7 +192,10 @@ metadata:
|
|
|
93
192
|
name: ${context.appCode}
|
|
94
193
|
namespace: ${context.namespace}
|
|
95
194
|
spec:
|
|
96
|
-
replicas:
|
|
195
|
+
replicas: ${replicas}
|
|
196
|
+
strategy:
|
|
197
|
+
type: RollingUpdate
|
|
198
|
+
rollingUpdate: { maxUnavailable: 0, maxSurge: 1 }
|
|
97
199
|
selector:
|
|
98
200
|
matchLabels: { app.kubernetes.io/name: ${context.appCode} }
|
|
99
201
|
template:
|
|
@@ -106,6 +208,13 @@ spec:
|
|
|
106
208
|
oon.dev/release-id: ${releaseAnnotation}
|
|
107
209
|
spec:
|
|
108
210
|
serviceAccountName: oon-runtime
|
|
211
|
+
terminationGracePeriodSeconds: ${TERMINATION_GRACE_SECONDS}
|
|
212
|
+
topologySpreadConstraints:
|
|
213
|
+
- maxSkew: 1
|
|
214
|
+
topologyKey: kubernetes.io/hostname
|
|
215
|
+
whenUnsatisfiable: ScheduleAnyway
|
|
216
|
+
labelSelector:
|
|
217
|
+
matchLabels: { app.kubernetes.io/name: ${context.appCode} }
|
|
109
218
|
imagePullSecrets: [{ name: ${infra.REGISTRY_PULL_SECRET_NAME || "ghcr-oon"} }]
|
|
110
219
|
containers:
|
|
111
220
|
- name: central
|
|
@@ -113,8 +222,10 @@ spec:
|
|
|
113
222
|
imagePullPolicy: IfNotPresent
|
|
114
223
|
ports: [{ name: http, containerPort: 8080 }]
|
|
115
224
|
envFrom: [{ secretRef: { name: oon-runtime } }]
|
|
116
|
-
|
|
117
|
-
- name:
|
|
225
|
+
env:
|
|
226
|
+
- name: OON_DB_PERFORMANCE_MODE
|
|
227
|
+
value: "${mode}"
|
|
228
|
+
${context.functionalCapabilities?.["core.secrets"]?.enabled ? ` - name: OON_SECRETS_KEY
|
|
118
229
|
valueFrom:
|
|
119
230
|
secretKeyRef: { name: oon-core-secrets, key: OON_SECRETS_KEY, optional: true }
|
|
120
231
|
` : ""}
|
|
@@ -127,6 +238,20 @@ ${context.functionalCapabilities?.["core.secrets"]?.enabled ? ` env:
|
|
|
127
238
|
readinessProbe:
|
|
128
239
|
httpGet: { path: /api/health/ready, port: http }
|
|
129
240
|
initialDelaySeconds: 10
|
|
241
|
+
lifecycle:
|
|
242
|
+
# Dá tempo para o ingress tirar o pod dos endpoints antes do SIGTERM.
|
|
243
|
+
preStop:
|
|
244
|
+
exec: { command: ["sleep", "${PRE_STOP_SECONDS}"] }
|
|
245
|
+
---
|
|
246
|
+
apiVersion: policy/v1
|
|
247
|
+
kind: PodDisruptionBudget
|
|
248
|
+
metadata: { name: ${context.appCode}, namespace: ${context.namespace} }
|
|
249
|
+
spec:
|
|
250
|
+
# Drains e a autoescala de nós tiram no máximo uma réplica por vez; com uma
|
|
251
|
+
# réplica só, o PDB não bloqueia a manutenção do nó.
|
|
252
|
+
maxUnavailable: 1
|
|
253
|
+
selector:
|
|
254
|
+
matchLabels: { app.kubernetes.io/name: ${context.appCode} }
|
|
130
255
|
---
|
|
131
256
|
apiVersion: v1
|
|
132
257
|
kind: Service
|
|
@@ -152,7 +277,7 @@ spec:
|
|
|
152
277
|
pathType: Prefix
|
|
153
278
|
backend:
|
|
154
279
|
service: { name: ${context.appCode}, port: { name: http } }
|
|
155
|
-
`;
|
|
280
|
+
${customDomainIngress({ context, infra })}`;
|
|
156
281
|
}
|
|
157
282
|
|
|
158
283
|
function copyPullSecret(namespace, infra) {
|
|
@@ -264,6 +389,8 @@ function deployRuntime({ context, infra, imageDigest, resourceProfile = "small"
|
|
|
264
389
|
copyPullSecret(context.namespace, infra);
|
|
265
390
|
const imageRef = `${context.imageRepository}@${imageDigest}`;
|
|
266
391
|
applyYaml(runtimeManifest({ context, infra, imageRef, profile: resourceProfile }));
|
|
392
|
+
// F4.6: sem domínios ativos, o Ingress dos domínios personalizados sai.
|
|
393
|
+
if (!customDomains(context).length) kubectl(["-n", context.namespace, "delete", "ingress", `${context.appCode}-custom`, "--ignore-not-found"]);
|
|
267
394
|
kubectl(["-n", context.namespace, "rollout", "status", `deployment/${context.appCode}`, "--timeout=10m"]);
|
|
268
395
|
return { infraVersion: infra.INFRA_SCHEMA_VERSION, imageRef };
|
|
269
396
|
}
|
|
@@ -306,4 +433,4 @@ async function activateMember(context) {
|
|
|
306
433
|
return data;
|
|
307
434
|
}
|
|
308
435
|
|
|
309
|
-
module.exports = { supportsSecretsV1: true, RESOURCE_PROFILES, randomSecret, dnsLabel, kubernetesLabelValue, provisionMongo, validateMongoUri, baselineManifest, runtimeManifest, provisionRuntime, deployRuntime, waitHttp, activateMember };
|
|
436
|
+
module.exports = { supportsSecretsV1: true, RESOURCE_PROFILES, MAX_REPLICAS, replicaCount, PERFORMANCE_MODES, performanceMode, MAX_CUSTOM_DOMAINS, customDomains, namespaceQuota, randomSecret, dnsLabel, kubernetesLabelValue, provisionMongo, validateMongoUri, baselineManifest, runtimeManifest, provisionRuntime, deployRuntime, waitHttp, activateMember };
|
|
@@ -2,9 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
// PID 1 owns both process groups. An unexpected exit (including status 0)
|
|
4
4
|
// terminates the container; a live proxy cannot hide a dead backend.
|
|
5
|
+
// On SIGTERM each child gets its own stop signal (nginx: SIGQUIT = graceful)
|
|
6
|
+
// and `graceMs` to drain in-flight requests before SIGKILL. The Deployment's
|
|
7
|
+
// terminationGracePeriodSeconds must exceed preStop + graceMs.
|
|
5
8
|
const { spawn } = require("node:child_process");
|
|
6
9
|
|
|
7
|
-
|
|
10
|
+
const DEFAULT_GRACE_MS = 25000;
|
|
11
|
+
|
|
12
|
+
function graceFromEnv(env = process.env) {
|
|
13
|
+
const value = Number(env.OON_SHUTDOWN_GRACE_MS);
|
|
14
|
+
return Number.isSafeInteger(value) && value > 0 ? value : DEFAULT_GRACE_MS;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function supervise({ backend, proxy, graceMs = graceFromEnv() } = {}) {
|
|
8
18
|
const children = [];
|
|
9
19
|
let stopping = false;
|
|
10
20
|
let exitCode = 1;
|
|
@@ -25,7 +35,7 @@ function supervise({ backend, proxy, graceMs = 5000 } = {}) {
|
|
|
25
35
|
if (stopping) return;
|
|
26
36
|
stopping = true;
|
|
27
37
|
exitCode = code;
|
|
28
|
-
for (const child of children) signalGroup(child,
|
|
38
|
+
for (const child of children) signalGroup(child, child.stopSignal);
|
|
29
39
|
timer = setTimeout(() => {
|
|
30
40
|
for (const child of children) signalGroup(child, "SIGKILL");
|
|
31
41
|
}, graceMs);
|
|
@@ -36,6 +46,7 @@ function supervise({ backend, proxy, graceMs = 5000 } = {}) {
|
|
|
36
46
|
cwd: spec.cwd, env: { ...process.env, PORT: "4000" },
|
|
37
47
|
stdio: "inherit", detached: true,
|
|
38
48
|
});
|
|
49
|
+
child.stopSignal = spec.stopSignal || "SIGTERM";
|
|
39
50
|
children.push(child);
|
|
40
51
|
child.once("error", () => { child.failed = true; stop(1); finish(); });
|
|
41
52
|
child.once("exit", () => { stop(1); finish(); });
|
|
@@ -46,7 +57,7 @@ function supervise({ backend, proxy, graceMs = 5000 } = {}) {
|
|
|
46
57
|
|
|
47
58
|
if (require.main === module) supervise({
|
|
48
59
|
backend: { command: process.execPath, args: ["/oon-delivery/launchRuntime.js"], cwd: "/app/backend" },
|
|
49
|
-
proxy: { command: "nginx", args: ["-c", "/etc/nginx/oon.conf", "-g", "daemon off;"] },
|
|
60
|
+
proxy: { command: "nginx", args: ["-c", "/etc/nginx/oon.conf", "-g", "daemon off;"], stopSignal: "SIGQUIT" },
|
|
50
61
|
});
|
|
51
62
|
|
|
52
|
-
module.exports = { supervise };
|
|
63
|
+
module.exports = { supervise, graceFromEnv, DEFAULT_GRACE_MS };
|
package/src/index.js
CHANGED
|
@@ -83,5 +83,9 @@ module.exports = {
|
|
|
83
83
|
PdfRenderingError: capabilities.PdfRenderingError,
|
|
84
84
|
TransactionalEmailError: capabilities.TransactionalEmailError,
|
|
85
85
|
GenericError, registry,
|
|
86
|
+
// F4.8: arquivos compartilhados entre réplicas (GridFS no MongoDB do App).
|
|
87
|
+
files: require("./core/storage/files"),
|
|
88
|
+
// F5.3/F5.5: consumo para os limites de uso do plano do App.
|
|
89
|
+
usage: require("./core/usage/reporter"),
|
|
86
90
|
};
|
|
87
91
|
|
|
@@ -5,6 +5,7 @@ const path = require("node:path");
|
|
|
5
5
|
const ctx = require("../core/context");
|
|
6
6
|
const corePackage = require("../../package.json");
|
|
7
7
|
const { validateRbac } = require("./rbacManifest");
|
|
8
|
+
const { validateUsageMetrics } = require("./usageMetrics");
|
|
8
9
|
const { deriveDeploymentMode, resolveAppArchitecture } = require("./appArchitecture");
|
|
9
10
|
|
|
10
11
|
const APP_MANIFEST_FILENAME = "central.app.json";
|
|
@@ -214,7 +215,7 @@ function validateAppManifest(input, { coreVersion = corePackage.version } = {})
|
|
|
214
215
|
const allowedTopLevel = new Set([
|
|
215
216
|
"$schema", "schemaVersion", "id", "name", "slug", "appKind",
|
|
216
217
|
"tenancyModel", "availability", "accessMode", "authorizationMode", "deploymentMode",
|
|
217
|
-
"modules", "capabilities", "capabilitySettings", "compatibility", "activation", "auth", "rbac",
|
|
218
|
+
"modules", "capabilities", "capabilitySettings", "compatibility", "activation", "auth", "rbac", "usageMetrics",
|
|
218
219
|
]);
|
|
219
220
|
for (const key of Object.keys(input)) {
|
|
220
221
|
if (!allowedTopLevel.has(key)) addIssue(issues, key, "propriedade não suportada.");
|
|
@@ -315,6 +316,7 @@ function validateAppManifest(input, { coreVersion = corePackage.version } = {})
|
|
|
315
316
|
}
|
|
316
317
|
}
|
|
317
318
|
validateRbac(input.rbac, issues);
|
|
319
|
+
validateUsageMetrics(input.usageMetrics, issues);
|
|
318
320
|
if (issues.length) throw new AppManifestError("central.app.json inválido.", issues);
|
|
319
321
|
return JSON.parse(JSON.stringify(input));
|
|
320
322
|
}
|
|
@@ -350,6 +352,7 @@ function appManifestToConfig(manifest, manifestPath) {
|
|
|
350
352
|
capabilitySettings: JSON.parse(JSON.stringify(manifest.capabilitySettings || {})),
|
|
351
353
|
auth: manifest.auth ? JSON.parse(JSON.stringify(manifest.auth)) : undefined,
|
|
352
354
|
rbac: manifest.rbac ? JSON.parse(JSON.stringify(manifest.rbac)) : undefined,
|
|
355
|
+
usageMetrics: manifest.usageMetrics ? JSON.parse(JSON.stringify(manifest.usageMetrics)) : [],
|
|
353
356
|
appManifest: manifest,
|
|
354
357
|
appManifestPath: manifestPath,
|
|
355
358
|
};
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Fila persistida dos recálculos assíncronos (`recalculate: "async"`).
|
|
5
|
+
*
|
|
6
|
+
* Antes a fila ficava só na memória do processo: tarefas pendentes se perdiam
|
|
7
|
+
* quando a réplica reiniciava, e duas réplicas podiam recalcular o mesmo
|
|
8
|
+
* registro ao mesmo tempo. Agora:
|
|
9
|
+
* - cada tarefa é um documento em `oon_process_jobs`; dentro de uma transação
|
|
10
|
+
* de domínio ela é gravada na mesma sessão, então só existe se a mudança que
|
|
11
|
+
* a originou for confirmada;
|
|
12
|
+
* - uma réplica reserva a tarefa com lease (como o worker de e-mail) e, se o
|
|
13
|
+
* processo morrer, outra réplica a retoma quando o lease expira;
|
|
14
|
+
* - uma trava por alvo (`oon_process_job_locks`) impede duas réplicas de
|
|
15
|
+
* recalcular o mesmo conjunto de registros ao mesmo tempo;
|
|
16
|
+
* - tarefas pendentes iguais, já visíveis quando o recálculo começa, são
|
|
17
|
+
* cobertas por ele e removidas juntas (deduplicação).
|
|
18
|
+
*/
|
|
19
|
+
const crypto = require("node:crypto");
|
|
20
|
+
const mongoose = require("../core/database/mongoose");
|
|
21
|
+
|
|
22
|
+
const JOBS = "oon_process_jobs";
|
|
23
|
+
const LOCKS = "oon_process_job_locks";
|
|
24
|
+
const LEASE_MS = 60000;
|
|
25
|
+
const MAX_ATTEMPTS = 5;
|
|
26
|
+
const TICK_MS = 1000;
|
|
27
|
+
|
|
28
|
+
const state = {
|
|
29
|
+
owner: `${process.pid}:${crypto.randomUUID()}`,
|
|
30
|
+
running: false,
|
|
31
|
+
timer: null,
|
|
32
|
+
inFlight: null,
|
|
33
|
+
ready: null,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
function stableValue(value) {
|
|
37
|
+
if (value === null || value === undefined) return value ?? null;
|
|
38
|
+
if (Array.isArray(value)) return value.map(stableValue);
|
|
39
|
+
if (value instanceof Date) return value.toISOString();
|
|
40
|
+
if (typeof value === "object") {
|
|
41
|
+
if (typeof value.toHexString === "function") return value.toHexString();
|
|
42
|
+
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, stableValue(value[key])]));
|
|
43
|
+
}
|
|
44
|
+
return value;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function dedupeKey(modelName, selectors, accessContext) {
|
|
48
|
+
const material = JSON.stringify(stableValue({ modelName, selectors, accessContext: accessContext || null }));
|
|
49
|
+
return crypto.createHash("sha256").update(material).digest("hex");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function db(connection = mongoose.connection) {
|
|
53
|
+
return connection?.readyState === 1 ? connection : null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function ensureReady(connection = db()) {
|
|
57
|
+
if (!connection) return false;
|
|
58
|
+
if (!state.ready) {
|
|
59
|
+
state.ready = Promise.all([
|
|
60
|
+
connection.collection(JOBS).createIndex({ status: 1, nextRunAt: 1 }),
|
|
61
|
+
connection.collection(JOBS).createIndex({ dedupeKey: 1, status: 1 }),
|
|
62
|
+
connection.collection(LOCKS).createIndex({ expiresAt: 1 }),
|
|
63
|
+
]).catch((error) => {
|
|
64
|
+
state.ready = null;
|
|
65
|
+
throw error;
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
await state.ready;
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function available() {
|
|
73
|
+
return Boolean(db());
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function enqueue({ modelName, selectors, accessContext, session } = {}) {
|
|
77
|
+
const connection = db();
|
|
78
|
+
if (!connection) throw new Error("Fila persistida indisponível sem conexão com o banco.");
|
|
79
|
+
// Índices e coleção são criados fora da transação; dentro dela só o insert.
|
|
80
|
+
await ensureReady(connection);
|
|
81
|
+
const now = new Date();
|
|
82
|
+
await connection.collection(JOBS).insertOne({
|
|
83
|
+
model: modelName,
|
|
84
|
+
selectors,
|
|
85
|
+
accessContext: accessContext || null,
|
|
86
|
+
dedupeKey: dedupeKey(modelName, selectors, accessContext),
|
|
87
|
+
status: "pending",
|
|
88
|
+
attempts: 0,
|
|
89
|
+
nextRunAt: now,
|
|
90
|
+
createdAt: now,
|
|
91
|
+
}, session ? { session } : {});
|
|
92
|
+
if (!session) kick();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function lease(connection) {
|
|
96
|
+
const now = new Date();
|
|
97
|
+
const result = await connection.collection(JOBS).findOneAndUpdate(
|
|
98
|
+
{
|
|
99
|
+
$or: [
|
|
100
|
+
{ status: "pending", nextRunAt: { $lte: now } },
|
|
101
|
+
{ status: "processing", leaseExpiresAt: { $lte: now } },
|
|
102
|
+
],
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
$set: { status: "processing", leaseOwner: state.owner, leaseExpiresAt: new Date(now.getTime() + LEASE_MS) },
|
|
106
|
+
$inc: { attempts: 1 },
|
|
107
|
+
},
|
|
108
|
+
{ sort: { nextRunAt: 1, createdAt: 1 }, returnDocument: "after" },
|
|
109
|
+
);
|
|
110
|
+
return result && Object.hasOwn(result, "value") ? result.value : result;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function acquireLock(connection, key) {
|
|
114
|
+
const locks = connection.collection(LOCKS);
|
|
115
|
+
const expiresAt = new Date(Date.now() + LEASE_MS);
|
|
116
|
+
try {
|
|
117
|
+
await locks.insertOne({ _id: key, owner: state.owner, expiresAt });
|
|
118
|
+
return true;
|
|
119
|
+
} catch (error) {
|
|
120
|
+
if (error?.code !== 11000) throw error;
|
|
121
|
+
}
|
|
122
|
+
const takeover = await locks.updateOne(
|
|
123
|
+
{ _id: key, expiresAt: { $lte: new Date() } },
|
|
124
|
+
{ $set: { owner: state.owner, expiresAt } },
|
|
125
|
+
);
|
|
126
|
+
return Number(takeover?.modifiedCount || 0) > 0;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function releaseLock(connection, key) {
|
|
130
|
+
await connection.collection(LOCKS).deleteOne({ _id: key, owner: state.owner });
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function backoff(attempts) {
|
|
134
|
+
return Math.min(300000, 1000 * (2 ** Math.max(0, attempts - 1)));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Processa uma tarefa. Retorna `false` quando não há tarefa disponível agora.
|
|
139
|
+
* `run(modelName, selectors, accessContext)` executa o recálculo.
|
|
140
|
+
*/
|
|
141
|
+
async function processOne(run, connection = db()) {
|
|
142
|
+
if (!connection) return false;
|
|
143
|
+
await ensureReady(connection);
|
|
144
|
+
const jobs = connection.collection(JOBS);
|
|
145
|
+
const job = await lease(connection);
|
|
146
|
+
if (!job) return false;
|
|
147
|
+
|
|
148
|
+
if (!await acquireLock(connection, job.dedupeKey)) {
|
|
149
|
+
// Outra réplica está recalculando o mesmo alvo: tenta de novo em seguida.
|
|
150
|
+
await jobs.updateOne(
|
|
151
|
+
{ _id: job._id, leaseOwner: state.owner },
|
|
152
|
+
{ $set: { status: "pending", nextRunAt: new Date(Date.now() + 500) }, $unset: { leaseOwner: "", leaseExpiresAt: "" }, $inc: { attempts: -1 } },
|
|
153
|
+
);
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
// Pendentes iguais já visíveis agora são cobertas por este recálculo, que lê o estado atual.
|
|
158
|
+
const siblings = await jobs.find(
|
|
159
|
+
{ dedupeKey: job.dedupeKey, status: "pending", _id: { $ne: job._id } },
|
|
160
|
+
{ projection: { _id: 1 } },
|
|
161
|
+
).toArray();
|
|
162
|
+
try {
|
|
163
|
+
await run(job.model, job.selectors, job.accessContext || undefined);
|
|
164
|
+
} catch (error) {
|
|
165
|
+
const failed = job.attempts >= MAX_ATTEMPTS;
|
|
166
|
+
await jobs.updateOne(
|
|
167
|
+
{ _id: job._id, leaseOwner: state.owner },
|
|
168
|
+
{
|
|
169
|
+
$set: failed
|
|
170
|
+
? { status: "failed", failedAt: new Date(), lastError: String(error?.message || error) }
|
|
171
|
+
: { status: "pending", nextRunAt: new Date(Date.now() + backoff(job.attempts)), lastError: String(error?.message || error) },
|
|
172
|
+
$unset: { leaseOwner: "", leaseExpiresAt: "" },
|
|
173
|
+
},
|
|
174
|
+
);
|
|
175
|
+
console.error("OON_PROCESS_ASYNC_RECALCULATION_FAILED", { model: job.model, attempts: job.attempts, final: failed, message: error?.message });
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
await jobs.deleteOne({ _id: job._id, leaseOwner: state.owner });
|
|
179
|
+
if (siblings.length) await jobs.deleteMany({ _id: { $in: siblings.map((doc) => doc._id) }, status: "pending" });
|
|
180
|
+
return true;
|
|
181
|
+
} finally {
|
|
182
|
+
await releaseLock(connection, job.dedupeKey);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
let runner = null;
|
|
187
|
+
|
|
188
|
+
function setRunner(fn) {
|
|
189
|
+
runner = fn;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function drainAvailable() {
|
|
193
|
+
if (!runner) return;
|
|
194
|
+
// eslint-disable-next-line no-await-in-loop
|
|
195
|
+
while (await processOne(runner)) { /* continua até não haver tarefa disponível */ }
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function runExclusive() {
|
|
199
|
+
if (state.inFlight) return state.inFlight;
|
|
200
|
+
state.inFlight = drainAvailable()
|
|
201
|
+
.catch((error) => console.error("OON_PROCESS_JOBS_WORKER_FAILED", { message: error?.message }))
|
|
202
|
+
.finally(() => { state.inFlight = null; });
|
|
203
|
+
return state.inFlight;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function schedule(delay = TICK_MS) {
|
|
207
|
+
if (!state.running) return;
|
|
208
|
+
clearTimeout(state.timer);
|
|
209
|
+
state.timer = setTimeout(() => { void runExclusive().finally(() => schedule()); }, delay);
|
|
210
|
+
state.timer.unref?.();
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function kick() {
|
|
214
|
+
if (state.running) schedule(0);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function start() {
|
|
218
|
+
if (state.running || !available()) return false;
|
|
219
|
+
await ensureReady();
|
|
220
|
+
state.running = true;
|
|
221
|
+
schedule(10);
|
|
222
|
+
return true;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function stop() {
|
|
226
|
+
state.running = false;
|
|
227
|
+
clearTimeout(state.timer);
|
|
228
|
+
state.timer = null;
|
|
229
|
+
if (state.inFlight) await state.inFlight;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Processa todas as tarefas disponíveis agora e espera a que estiver em andamento. */
|
|
233
|
+
async function drain() {
|
|
234
|
+
if (state.inFlight) await state.inFlight;
|
|
235
|
+
await runExclusive();
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
module.exports = {
|
|
239
|
+
JOBS,
|
|
240
|
+
LOCKS,
|
|
241
|
+
MAX_ATTEMPTS,
|
|
242
|
+
available,
|
|
243
|
+
dedupeKey,
|
|
244
|
+
drain,
|
|
245
|
+
enqueue,
|
|
246
|
+
ensureReady,
|
|
247
|
+
processOne,
|
|
248
|
+
setRunner,
|
|
249
|
+
start,
|
|
250
|
+
state,
|
|
251
|
+
stop,
|
|
252
|
+
};
|
|
@@ -10,6 +10,7 @@ const {
|
|
|
10
10
|
roundValue,
|
|
11
11
|
valuesEquivalent,
|
|
12
12
|
} = require("./domainRules");
|
|
13
|
+
const processJobs = require("./processJobs");
|
|
13
14
|
|
|
14
15
|
const MAX_AUTOMATIC_TRANSITIONS = 8;
|
|
15
16
|
const MAX_TRANSACTION_ATTEMPTS = 3;
|
|
@@ -535,11 +536,29 @@ async function recalculateTarget(entry, selectors, options = {}) {
|
|
|
535
536
|
return { matched: documents.length, modified: result.modifiedCount || 0 };
|
|
536
537
|
}
|
|
537
538
|
|
|
538
|
-
function
|
|
539
|
+
function frozenAccessContext(accessContext) {
|
|
539
540
|
// Capture a value, never a mutable request/session or the next user's context.
|
|
540
|
-
|
|
541
|
-
permissions: Object.freeze([...(
|
|
541
|
+
return accessContext ? Object.freeze({ ...accessContext,
|
|
542
|
+
permissions: Object.freeze([...(accessContext.permissions || [])]) }) : undefined;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function plainAccessContext(accessContext) {
|
|
546
|
+
return accessContext ? { ...accessContext, permissions: [...(accessContext.permissions || [])] } : undefined;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// Com banco conectado, a fila é persistida (processJobs): sobrevive a reinícios
|
|
550
|
+
// e é compartilhada entre réplicas. Sem banco, mantém a fila em memória.
|
|
551
|
+
function enqueueAsynchronousRecalculation(target, options) {
|
|
552
|
+
const accessContext = frozenAccessContext(options.accessContext);
|
|
542
553
|
scopeFilter(target.entry, accessContext);
|
|
554
|
+
if (processJobs.available()) {
|
|
555
|
+
return processJobs.enqueue({
|
|
556
|
+
modelName: target.entry.name,
|
|
557
|
+
selectors: target.selectors,
|
|
558
|
+
accessContext: plainAccessContext(accessContext),
|
|
559
|
+
session: options.session?.inTransaction?.() ? options.session : undefined,
|
|
560
|
+
});
|
|
561
|
+
}
|
|
543
562
|
asynchronousQueue = asynchronousQueue
|
|
544
563
|
.then(() => new Promise((resolve) => setImmediate(resolve)))
|
|
545
564
|
.then(() => recalculateTarget(target.entry, target.selectors, { session: null, accessContext }))
|
|
@@ -565,11 +584,16 @@ async function recalculateDependents(entry, before, after, options = {}) {
|
|
|
565
584
|
const results = [];
|
|
566
585
|
for (const target of targets) {
|
|
567
586
|
if (target.mode === "async") {
|
|
568
|
-
if (options.session?.inTransaction()) {
|
|
587
|
+
if (options.session?.inTransaction() && processJobs.available()) {
|
|
588
|
+
// Gravada na mesma transação: a tarefa só existe se a mudança for confirmada.
|
|
589
|
+
await enqueueAsynchronousRecalculation(target, options);
|
|
590
|
+
} else if (options.session?.inTransaction()) {
|
|
569
591
|
const jobs = transactionJobs.get(options.session);
|
|
570
592
|
if (!jobs) throw new DomainRuleError("Fila assíncrona exige transação gerenciada.", { code: "OON_PROCESS_TRANSACTION_REQUIRED", statusCode: 409 });
|
|
571
593
|
scopeFilter(target.entry, options.accessContext);
|
|
572
594
|
jobs.push({ target, options: { accessContext: options.accessContext ? { ...options.accessContext } : undefined } });
|
|
595
|
+
} else if (processJobs.available()) {
|
|
596
|
+
await enqueueAsynchronousRecalculation(target, options);
|
|
573
597
|
} else enqueueAsynchronousRecalculation(target, options);
|
|
574
598
|
results.push({ model: target.entry.name, mode: "async" });
|
|
575
599
|
} else {
|
|
@@ -583,8 +607,30 @@ async function recalculateDependents(entry, before, after, options = {}) {
|
|
|
583
607
|
return results;
|
|
584
608
|
}
|
|
585
609
|
|
|
610
|
+
processJobs.setRunner((modelName, selectors, accessContext) => {
|
|
611
|
+
const entry = registry.getModel(modelName);
|
|
612
|
+
if (!entry) throw new Error(`Model ${modelName} não registrada para o recálculo assíncrono.`);
|
|
613
|
+
return recalculateTarget(entry, selectors, { session: null, accessContext });
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
/** Processa as tarefas assíncronas disponíveis agora (fila persistida e em memória). */
|
|
586
617
|
async function drainProcessJobs() {
|
|
587
618
|
await asynchronousQueue;
|
|
619
|
+
if (processJobs.available()) await processJobs.drain();
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/** Inicia o worker da fila persistida; chamado no boot, depois de conectar ao banco. */
|
|
623
|
+
function startProcessJobs() {
|
|
624
|
+
return processJobs.start();
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* Encerramento: para o worker persistido (as pendentes ficam para outra
|
|
629
|
+
* réplica) e espera a fila em memória e a tarefa em andamento.
|
|
630
|
+
*/
|
|
631
|
+
async function stopProcessJobs() {
|
|
632
|
+
await processJobs.stop();
|
|
633
|
+
await asynchronousQueue;
|
|
588
634
|
}
|
|
589
635
|
|
|
590
636
|
module.exports = {
|
|
@@ -601,5 +647,7 @@ module.exports = {
|
|
|
601
647
|
recalculateDependents,
|
|
602
648
|
recalculateTarget,
|
|
603
649
|
drainProcessJobs,
|
|
650
|
+
startProcessJobs,
|
|
651
|
+
stopProcessJobs,
|
|
604
652
|
toPlainRecord,
|
|
605
653
|
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// F5.2: métricas de uso que o App declara no central.app.json (`usageMetrics`).
|
|
4
|
+
// A Central lê em GET /api/core/usage-metrics para pré-preencher os limites
|
|
5
|
+
// do plano do App (Revenda > Planos), como faz com o catálogo de perfis.
|
|
6
|
+
const METRIC = /^[a-z][a-z0-9_.-]{1,62}$/;
|
|
7
|
+
const MAX_METRICS = 10;
|
|
8
|
+
|
|
9
|
+
function validateUsageMetrics(input, issues) {
|
|
10
|
+
if (input === undefined) return;
|
|
11
|
+
const add = (path, message) => issues.push({ path, message });
|
|
12
|
+
if (!Array.isArray(input) || input.length > MAX_METRICS) return add("usageMetrics", `deve ser uma lista com até ${MAX_METRICS} métricas.`);
|
|
13
|
+
const seen = new Set();
|
|
14
|
+
input.forEach((metric, index) => {
|
|
15
|
+
const path = `usageMetrics[${index}]`;
|
|
16
|
+
if (!metric || typeof metric !== "object" || Array.isArray(metric)) return add(path, "deve ser um objeto.");
|
|
17
|
+
for (const key of Object.keys(metric)) if (!["code", "name", "unit", "description", "defaults"].includes(key)) add(`${path}.${key}`, "propriedade não suportada.");
|
|
18
|
+
if (typeof metric.code !== "string" || !METRIC.test(metric.code)) add(`${path}.code`, "use letras minúsculas, números, ponto, hífen ou sublinhado.");
|
|
19
|
+
else if (seen.has(metric.code)) add(`${path}.code`, "código repetido."); else seen.add(metric.code);
|
|
20
|
+
if (typeof metric.name !== "string" || !metric.name.trim() || metric.name.length > 80) add(`${path}.name`, "obrigatório, até 80 caracteres.");
|
|
21
|
+
const d = metric.defaults;
|
|
22
|
+
if (d === undefined) return;
|
|
23
|
+
if (!d || typeof d !== "object") return add(`${path}.defaults`, "deve ser um objeto.");
|
|
24
|
+
if (d.included !== undefined && (!Number.isSafeInteger(d.included) || d.included < 0)) add(`${path}.defaults.included`, "inteiro maior ou igual a zero.");
|
|
25
|
+
if (d.onLimit !== undefined && !["block", "auto_addon"].includes(d.onLimit)) add(`${path}.defaults.onLimit`, "block ou auto_addon.");
|
|
26
|
+
if (d.overage !== undefined) {
|
|
27
|
+
const o = d.overage || {};
|
|
28
|
+
if (!["unit", "block"].includes(o.mode)) add(`${path}.defaults.overage.mode`, "unit ou block.");
|
|
29
|
+
if (!Number.isSafeInteger(o.priceMinor) || o.priceMinor < 1) add(`${path}.defaults.overage.priceMinor`, "valor em centavos, maior que zero.");
|
|
30
|
+
if (o.mode === "block" && (!Number.isSafeInteger(o.blockSize) || o.blockSize < 1)) add(`${path}.defaults.overage.blockSize`, "tamanho do bloco, maior que zero.");
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function publicUsageMetrics(config) {
|
|
36
|
+
return { schemaVersion: 1, appCode: String(config.appCode || config.slug || "central"), metrics: JSON.parse(JSON.stringify(config.usageMetrics || [])) };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
module.exports = { validateUsageMetrics, publicUsageMetrics, MAX_METRICS };
|