data-primals-engine 1.7.1 → 1.7.3
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 +9 -9
- package/client/package-lock.json +8430 -8121
- package/client/package.json +7 -4
- package/client/src/APIInfo.jsx +1 -1
- package/client/src/App.jsx +2 -1
- package/client/src/App.scss +1635 -1626
- package/client/src/AssistantChat.jsx +2 -3
- package/client/src/CalendarView.jsx +1 -0
- package/client/src/ContentView.jsx +3 -3
- package/client/src/Dashboard.jsx +5 -2
- package/client/src/DashboardChart.jsx +1 -0
- package/client/src/DashboardFlexViewItem.jsx +1 -0
- package/client/src/DashboardHtmlViewItem.jsx +1 -0
- package/client/src/DashboardView.jsx +2 -0
- package/client/src/DataEditor.jsx +0 -1
- package/client/src/DataImporter.jsx +489 -468
- package/client/src/DataLayout.jsx +25 -23
- package/client/src/DataTable.jsx +6 -5
- package/client/src/Dialog.jsx +92 -90
- package/client/src/Dialog.scss +122 -116
- package/client/src/DisplayFlexNodeRenderer.jsx +1 -0
- package/client/src/DocumentationPageLayout.scss +1 -1
- package/client/src/FlexBuilderControls.jsx +1 -0
- package/client/src/FlexBuilderPreview.jsx +1 -1
- package/client/src/FlexNode.jsx +1 -1
- package/client/src/HistoryDialog.jsx +3 -2
- package/client/src/KPIWidget.jsx +1 -1
- package/client/src/KanbanView.jsx +1 -0
- package/client/src/ModelCreator.jsx +4 -0
- package/client/src/ModelList.jsx +1 -0
- package/client/src/PackGallery.jsx +5 -4
- package/client/src/RTETrans.jsx +1 -1
- package/client/src/RelationField.jsx +2 -0
- package/client/src/RelationSelectorWidget.jsx +2 -0
- package/client/src/RelationValue.jsx +1 -0
- package/client/src/RestoreDialog.jsx +1 -0
- package/client/src/ViewSwitcher.jsx +1 -1
- package/client/src/WorkflowEditor.jsx +3 -0
- package/client/src/contexts/CommandContext.jsx +2 -1
- package/client/src/contexts/ModelContext.jsx +3 -3
- package/client/src/hooks/data.js +1 -0
- package/client/src/hooks/useValidation.js +1 -0
- package/client/src/translations.js +24 -24
- package/client/vite.config.js +31 -30
- package/doc/AI-assistance.md +87 -63
- package/doc/Concepts.md +122 -0
- package/doc/Custom-Endpoints.md +31 -0
- package/doc/Event-system.md +13 -14
- package/doc/Home.md +33 -0
- package/doc/Modules.md +83 -0
- package/doc/Packs-gallery.md +8 -23
- package/doc/Workflows.md +32 -0
- package/doc/automation-workflows.md +141 -102
- package/doc/dashboards-kpis-charts.md +47 -49
- package/doc/data-management.md +126 -120
- package/doc/data-models.md +68 -75
- package/doc/roles-permissions.md +144 -43
- package/doc/sharding-replication.md +158 -0
- package/doc/users.md +54 -30
- package/package.json +27 -17
- package/server.js +37 -37
- package/src/ai.jobs.js +135 -0
- package/src/constants.js +560 -545
- package/src/data.js +521 -518
- package/src/email.js +157 -154
- package/src/engine.js +167 -49
- package/src/gameObject.js +6 -0
- package/src/i18n.js +0 -1
- package/src/modules/assistant/assistant.js +782 -763
- package/src/modules/assistant/constants.js +23 -16
- package/src/modules/assistant/providers.js +77 -37
- package/src/modules/auth-google/index.js +53 -50
- package/src/modules/bucket.js +346 -335
- package/src/modules/data/data.backup.js +400 -376
- package/src/modules/data/data.cluster.js +559 -0
- package/src/modules/data/data.core.js +11 -8
- package/src/modules/data/data.js +311 -311
- package/src/modules/data/data.operations.js +3666 -3555
- package/src/modules/data/data.relations.js +1 -0
- package/src/modules/data/data.replication.js +125 -0
- package/src/modules/data/data.routes.js +2206 -1879
- package/src/modules/data/data.scheduling.js +2 -1
- package/src/modules/file.js +248 -247
- package/src/modules/mongodb.js +76 -73
- package/src/modules/user.js +18 -2
- package/src/modules/worker-script-runner.js +97 -0
- package/src/modules/workflow.js +177 -52
- package/src/packs.js +5701 -5701
- package/src/providers.js +298 -297
- package/src/sso.js +91 -25
- package/test/assistant.test.js +207 -206
- package/test/cluster.test.js +221 -0
- package/test/core.test.js +0 -2
- package/test/data.integration.test.js +1425 -1416
- package/test/import_export.integration.test.js +210 -210
- package/test/replication.test.js +163 -0
- package/test/workflow.actions.integration.test.js +487 -475
- package/test/workflow.integration.test.js +332 -329
- package/doc/core-concepts.md +0 -33
|
@@ -0,0 +1,559 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { statfs } from 'node:fs/promises';
|
|
3
|
+
import { Config } from '../../config.js';
|
|
4
|
+
import { Event } from '../../events.js';
|
|
5
|
+
import {DataHandler, Logger} from '../../gameObject.js';
|
|
6
|
+
import { onInit as replicationInit, queueReplication, stopReplicationQueue } from './data.replication.js';// Importer le module dans lui-même pour accéder aux exports (et donc aux mocks dans les tests)
|
|
7
|
+
import { getCollection } from '../mongodb.js';
|
|
8
|
+
|
|
9
|
+
// Importer le module dans lui-même pour accéder aux exports (et donc aux mocks dans les tests)
|
|
10
|
+
import * as self from './data.cluster.js';
|
|
11
|
+
|
|
12
|
+
let logger;
|
|
13
|
+
|
|
14
|
+
let engine; // L'instance du moteur sera injectée via onInit
|
|
15
|
+
let dataHandler; // Pour l'accès à MongoDB
|
|
16
|
+
let clusterLeaseCollection; // Collection MongoDB native pour les baux
|
|
17
|
+
|
|
18
|
+
const LEASE_DURATION_MS = 10000; // Durée du bail en ms (10s)
|
|
19
|
+
|
|
20
|
+
let gossipInterval;
|
|
21
|
+
|
|
22
|
+
// La source de vérité sur l'état du cluster, gérée par le gossip.
|
|
23
|
+
// La clé est l'ID du noeud (ex: 'node-1'), la valeur contient les métadonnées.
|
|
24
|
+
const memberList = new Map();
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Structure d'un membre:
|
|
28
|
+
* { id: 'node-1', url: 'http://node-1:3000', sharding: true, replica: true, status: 'UP' | 'SUSPECT' | 'FULL', version: 1, disk: { free: 123, total: 456 } }
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Diffuse un événement d'invalidation de cache à tous les nœuds du cluster.
|
|
33
|
+
* @param {string} cacheType - Le type de cache à invalider (ex: 'model').
|
|
34
|
+
* @param {string} key - La clé spécifique à invalider dans le cache.
|
|
35
|
+
*/
|
|
36
|
+
async function broadcastCacheInvalidation(cacheType, key) {
|
|
37
|
+
if (!engine || engine.peers.length <= 1) {
|
|
38
|
+
return; // Pas de cluster, rien à faire.
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
logger.info(`[Cluster] Broadcasting direct cache invalidation for '${cacheType}' with key '${key}' to peers.`);
|
|
42
|
+
|
|
43
|
+
const activePeers = getMemberList().filter(p => p.id !== engine.selfId && p.status === 'UP');
|
|
44
|
+
|
|
45
|
+
// On envoie une requête à chaque autre nœud du cluster.
|
|
46
|
+
const broadcastPromises = activePeers.map(peer => {
|
|
47
|
+
const peerUrl = peer.public_domain;
|
|
48
|
+
const targetUrl = `${peerUrl}/api/internal/cache-invalidate`;
|
|
49
|
+
// On suppose que les nœuds partagent un token/secret pour s'authentifier.
|
|
50
|
+
// Le token de l'utilisateur actuel est une bonne option s'il est admin ou a des droits étendus.
|
|
51
|
+
const authToken = (process.env.INTERNAL_CLUSTER_TOKEN) ? `Bearer ${process.env.INTERNAL_CLUSTER_TOKEN}` : null;
|
|
52
|
+
if (!authToken) {
|
|
53
|
+
logger.warn(`[Cluster] INTERNAL_CLUSTER_TOKEN is not set. Cannot broadcast cache invalidation securely.`);
|
|
54
|
+
return Promise.resolve(); // Ne rien faire si pas de token
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return fetch(targetUrl, {
|
|
58
|
+
method: 'POST',
|
|
59
|
+
headers: {
|
|
60
|
+
'Content-Type': 'application/json',
|
|
61
|
+
'Authorization': authToken
|
|
62
|
+
},
|
|
63
|
+
body: JSON.stringify({ cacheType, key })
|
|
64
|
+
}).catch(error => {
|
|
65
|
+
logger.error(`[Cluster] Failed to send cache invalidation to peer ${peerUrl}: ${error.message}`);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
await Promise.allSettled(broadcastPromises);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Ajoute une opération à la file d'attente de réplication.
|
|
74
|
+
* @param {string} operation - 'insert', 'update', 'delete'.
|
|
75
|
+
* @param {string} modelName - Le nom du modèle.
|
|
76
|
+
* @param {object} user - L'objet utilisateur.
|
|
77
|
+
* @param {object} payload - Les données de l'opération.
|
|
78
|
+
*/
|
|
79
|
+
function replicateOperation(operation, modelName, user, payload) {
|
|
80
|
+
queueReplication(operation, modelName, user, payload);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Détermine la liste ordonnée des nœuds responsables pour un utilisateur.
|
|
85
|
+
* Le premier est le maître, les suivants sont les répliques.
|
|
86
|
+
* @param {string} username - Le nom de l'utilisateur.
|
|
87
|
+
* @returns {string[]} Une liste d'URLs de nœuds.
|
|
88
|
+
*/
|
|
89
|
+
function getResponsibleNodesForUser(username, vnodesPerNode = 100) {
|
|
90
|
+
const REPLICATION_FACTOR = Config.Get('replicationFactor', 2);
|
|
91
|
+
const allNodes = getMemberList().filter(m => m.status === 'UP');
|
|
92
|
+
|
|
93
|
+
if (!engine || allNodes.length <= 1) {
|
|
94
|
+
return allNodes.length > 0 ? [allNodes[0]] : [];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// 1. Filtrer les nœuds éligibles pour le sharding
|
|
98
|
+
const shardingNodes = allNodes.filter(p => p.sharding === true);
|
|
99
|
+
if (shardingNodes.length === 0) {
|
|
100
|
+
logger.warn('[Cluster] No nodes available for sharding (sharding:true). Using all nodes as fallback.');
|
|
101
|
+
shardingNodes.push(...allNodes);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// 2. Amélioration avec des "nœuds virtuels" pour une meilleure distribution.
|
|
105
|
+
// Au lieu de `hash % N`, on divise l'espace de hachage en `N * vnodesPerNode` partitions.
|
|
106
|
+
// Cela lisse la distribution et réduit les "hot spots".
|
|
107
|
+
const totalVNodes = shardingNodes.length * vnodesPerNode;
|
|
108
|
+
const userHash = createHash('sha256').update(username).digest();
|
|
109
|
+
// On prend les 4 premiers octets du hash pour avoir un grand nombre entier.
|
|
110
|
+
const userHashInt = userHash.readUInt32BE(0);
|
|
111
|
+
|
|
112
|
+
const partitionIndex = userHashInt % totalVNodes;
|
|
113
|
+
const masterNodeIndex = Math.floor(partitionIndex / vnodesPerNode);
|
|
114
|
+
const masterNode = shardingNodes[masterNodeIndex];
|
|
115
|
+
|
|
116
|
+
const responsibleNodes = [masterNode];
|
|
117
|
+
|
|
118
|
+
// 3. Sélectionner les nœuds de réplication
|
|
119
|
+
// On filtre les noeuds éligibles et on les trie par espace disque libre (du plus grand au plus petit).
|
|
120
|
+
// Cela garantit que nous choisissons toujours les nœuds les plus sains en premier.
|
|
121
|
+
const replicaPool = allNodes.filter(p =>
|
|
122
|
+
p.replica === true &&
|
|
123
|
+
p.id !== masterNode.id &&
|
|
124
|
+
p.status !== 'DOWN' && p.status !== 'SUSPECT' && // On exclut les nœuds en panne
|
|
125
|
+
p.disk && p.disk.free > 0 // On s'assure d'avoir les infos disque et qu'il reste de la place
|
|
126
|
+
).sort((a, b) => b.disk.free - a.disk.free); // Tri descendant par espace libre
|
|
127
|
+
|
|
128
|
+
const numReplicasToFind = Math.min(REPLICATION_FACTOR - 1, replicaPool.length);
|
|
129
|
+
|
|
130
|
+
if (numReplicasToFind > 0) {
|
|
131
|
+
// Au lieu d'une sélection par hash (qui peut assigner un user à un nœud presque plein),
|
|
132
|
+
// on prend simplement les N premiers nœuds de la liste triée.
|
|
133
|
+
// Ce sont les nœuds avec le plus d'espace libre.
|
|
134
|
+
// Cette approche est "goulue" (greedy) mais très efficace pour l'équilibrage.
|
|
135
|
+
const bestReplicas = replicaPool.slice(0, numReplicasToFind);
|
|
136
|
+
responsibleNodes.push(...bestReplicas);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// La condition de log est mise à jour pour refléter la nouvelle logique.
|
|
140
|
+
if (responsibleNodes.length < REPLICATION_FACTOR) {
|
|
141
|
+
// Pas assez de répliques saines disponibles. C'est un problème.
|
|
142
|
+
logger.warn(`[Cluster] Could not find enough healthy replicas for user '${username}'. Wanted ${REPLICATION_FACTOR}, found ${responsibleNodes.length}. Some data may be under-replicated.`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return responsibleNodes;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function getReplicaNodesForUser(username) {
|
|
149
|
+
return getResponsibleNodesForUser(username).slice(1);
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Détermine l'URL du nœud "maître" pour un utilisateur donné.
|
|
153
|
+
* @param {string} username - Le nom de l'utilisateur.
|
|
154
|
+
* @returns {string} L'URL du nœud maître.
|
|
155
|
+
*/
|
|
156
|
+
export function getMasterNodeForUser(username) {
|
|
157
|
+
const nodes = getResponsibleNodesForUser(username);
|
|
158
|
+
// --- FIX: Handle empty node list ---
|
|
159
|
+
// If no responsible nodes are found (e.g., empty peer list in a single-node setup),
|
|
160
|
+
// gracefully fall back to the current node itself.
|
|
161
|
+
if (!nodes || nodes.length === 0) {
|
|
162
|
+
return memberList.get(engine.selfId);
|
|
163
|
+
}
|
|
164
|
+
if (nodes && nodes.length > 0) {
|
|
165
|
+
return nodes[0];
|
|
166
|
+
}
|
|
167
|
+
// Fallback to self if no nodes are found (e.g., single node cluster)
|
|
168
|
+
return memberList.get(engine.selfId);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Vérifie si le nœud actuel est le maître pour cet utilisateur.
|
|
173
|
+
* @param {string} username - Le nom de l'utilisateur.
|
|
174
|
+
* @returns {boolean}
|
|
175
|
+
* @returns {Promise<boolean>}
|
|
176
|
+
*/
|
|
177
|
+
async function isSelfMasterForUser(username) {
|
|
178
|
+
// 1. Détermination du maître potentiel par hash (logique inchangée)
|
|
179
|
+
const potentialMaster = self.getMasterNodeForUser(username);
|
|
180
|
+
if (potentialMaster?.id !== engine.selfId) {
|
|
181
|
+
// Si le hash ne nous désigne pas, nous ne sommes pas le maître.
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// 2. Nous sommes le candidat. Tentons d'acquérir/renouveler le bail.
|
|
186
|
+
// C'est l'étape de "fencing" qui prévient le split-brain.
|
|
187
|
+
const resourceId = `mastership-${username}`;
|
|
188
|
+
const hasLease = await acquireOrRenewLease(resourceId, engine.selfId);
|
|
189
|
+
|
|
190
|
+
if (!hasLease) {
|
|
191
|
+
logger.warn(`[Cluster] Failed to acquire/renew mastership lease for user '${username}'. Another node may be master despite hash assignment. Stepping down.`);
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// 3. Nous sommes le candidat désigné ET nous détenons le bail. Nous sommes le maître.
|
|
196
|
+
return true;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Tente d'acquérir ou de renouveler un bail pour une ressource donnée.
|
|
201
|
+
* Cette opération est atomique.
|
|
202
|
+
* @param {string} resourceId - L'identifiant unique de la ressource à verrouiller.
|
|
203
|
+
* @param {string} nodeId - L'ID du nœud qui tente d'acquérir le bail.
|
|
204
|
+
* @returns {Promise<boolean>} - `true` si le bail est acquis/renouvelé avec succès, `false` sinon.
|
|
205
|
+
*/
|
|
206
|
+
async function acquireOrRenewLease(resourceId, nodeId) {
|
|
207
|
+
if (!clusterLeaseCollection) {
|
|
208
|
+
logger.warn('[Lease] Lease model not initialized, cannot acquire lease. Assuming not master.');
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const now = new Date();
|
|
213
|
+
const newExpiresAt = new Date(now.getTime() + LEASE_DURATION_MS);
|
|
214
|
+
|
|
215
|
+
try {
|
|
216
|
+
// Utilisation du driver natif MongoDB.
|
|
217
|
+
// L'opération est atomique. On met à jour le document si :
|
|
218
|
+
// 1. On est déjà le propriétaire (renouvellement).
|
|
219
|
+
// 2. Le bail a expiré (acquisition).
|
|
220
|
+
const result = await clusterLeaseCollection.findOneAndUpdate(
|
|
221
|
+
{ resourceId, $or: [{ ownerId: nodeId }, { expiresAt: { $lt: now } }] },
|
|
222
|
+
{ $set: { ownerId: nodeId, expiresAt: newExpiresAt } },
|
|
223
|
+
{ upsert: true, returnDocument: 'after' }
|
|
224
|
+
);
|
|
225
|
+
// Le résultat contient le document mis à jour. On vérifie qu'on en est bien le propriétaire.
|
|
226
|
+
return result && result.ownerId === nodeId;
|
|
227
|
+
} catch (error) {
|
|
228
|
+
logger.error(`[Lease] Error during lease acquisition for '${resourceId}': ${error.message}`);
|
|
229
|
+
return false;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Relaye une requête (lecture ou écriture) vers le nœud maître approprié de manière performante (streaming).
|
|
234
|
+
* @param {object} req - L'objet requête Express.
|
|
235
|
+
* @param {object} res - L'objet réponse Express.
|
|
236
|
+
* @param {string} username - Le nom de l'utilisateur concerné.
|
|
237
|
+
* @returns {Promise<boolean>} - Retourne `true` si la requête a été relayée, `false` sinon.
|
|
238
|
+
*/
|
|
239
|
+
async function proxyRequest(req, res, username) {
|
|
240
|
+
// La vérification de maîtrise est maintenant asynchrone et basée sur le bail.
|
|
241
|
+
const isMaster = await isSelfMasterForUser(username);
|
|
242
|
+
|
|
243
|
+
if (isMaster) {
|
|
244
|
+
// Ce nœud est le maître, on ne relaie pas.
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Pour les écritures (POST, PUT, PATCH, DELETE), on cible toujours le maître.
|
|
249
|
+
if (req.method !== 'GET') {
|
|
250
|
+
const masterNode = getMasterNodeForUser(username); // Le maître désigné par le hash
|
|
251
|
+
// SÉCURITÉ : Vérifier que le nœud maître a bien été trouvé avant de l'utiliser.
|
|
252
|
+
if (!masterNode) {
|
|
253
|
+
logger.error(`[Cluster] Cannot proxy WRITE for user ${username}: No master node found.`);
|
|
254
|
+
res.status(503).json({ success: false, error: "Service Unavailable: No master node available to handle the request." });
|
|
255
|
+
return true; // La requête est gérée (avec une erreur), on arrête le traitement.
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// --- PRÉVENTION DE BOUCLE INFINIE ---
|
|
259
|
+
// Si le maître désigné est ce nœud, mais que isSelfMasterForUser a dit non (ex: perte de bail),
|
|
260
|
+
// on ne doit pas se relayer la requête à nous-mêmes. On la traite localement comme un échec.
|
|
261
|
+
if (masterNode.id === engine.selfId) {
|
|
262
|
+
logger.warn(`[Cluster] Mastership conflict for user ${username}. Hash designates self, but lease is not held. Aborting proxy loop.`);
|
|
263
|
+
return false; // On ne relaie pas, la requête sera traitée localement (et échouera probablement, ce qui est correct).
|
|
264
|
+
}
|
|
265
|
+
logger.info(`[Cluster] Proxying WRITE ${req.method} for user ${username} to master node ${masterNode.public_domain}`);
|
|
266
|
+
await attemptProxy(req, res, masterNode);
|
|
267
|
+
return true; // La requête a été traitée (relayée ou a échoué).
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Pour les lectures (GET), on tente le maître, puis les répliques en cas d'échec.
|
|
271
|
+
const responsibleNodes = getResponsibleNodesForUser(username);
|
|
272
|
+
logger.info(`[Cluster] Proxying READ for user ${username}. Node preference: ${responsibleNodes.map(n => n.id).join(' -> ')}`);
|
|
273
|
+
for (const node of responsibleNodes) {
|
|
274
|
+
try {
|
|
275
|
+
if (node.id === engine.selfId) {
|
|
276
|
+
logger.info(`[Cluster] Failover to self. Handling request locally.`);
|
|
277
|
+
return false;
|
|
278
|
+
}
|
|
279
|
+
await attemptProxy(req, res, node);
|
|
280
|
+
return true; // Succès, on arrête la boucle de failover.
|
|
281
|
+
} catch (error) {
|
|
282
|
+
logger.warn(`[Cluster] Proxy attempt to ${node.public_domain} failed: ${error.message}. Trying next node...`);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Si tous les nœuds ont échoué
|
|
287
|
+
logger.error(`[Cluster] All responsible nodes for user ${username} are down. Cannot serve request.`);
|
|
288
|
+
res.status(503).json({ success: false, error: `Service Unavailable: All responsible nodes for user ${username} are currently down.` });
|
|
289
|
+
return true; // La requête a été traitée (a échoué).
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async function attemptProxy(req, res, node) {
|
|
293
|
+
const targetUrl = new URL(req.originalUrl, `https://${node.public_domain}`);
|
|
294
|
+
// On copie les en-têtes, mais on supprime ceux qui sont gérés par fetch ou qui sont spécifiques à la connexion.
|
|
295
|
+
const headers = { ...req.headers };
|
|
296
|
+
delete headers['host']; // Doit être défini par fetch en fonction de targetUrl
|
|
297
|
+
delete headers['content-length']; // Sera recalculé par fetch en fonction du nouveau body
|
|
298
|
+
delete headers['transfer-encoding']; // Peut interférer avec le nouveau body
|
|
299
|
+
|
|
300
|
+
headers['X-Federation-Proxy'] = 'true';
|
|
301
|
+
if (!headers['content-type']) {
|
|
302
|
+
headers['content-type'] = 'application/json';
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Utilisation d'un AbortController pour gérer les timeouts de connexion
|
|
306
|
+
const controller = new AbortController();
|
|
307
|
+
const timeoutId = setTimeout(() => controller.abort(), 5000); // Timeout de 5 secondes
|
|
308
|
+
|
|
309
|
+
try {
|
|
310
|
+
const proxyResponse = await fetch(targetUrl.toString(), {
|
|
311
|
+
method: req.method,
|
|
312
|
+
headers: headers,
|
|
313
|
+
body: (req.method !== 'GET' && req.method !== 'HEAD') ? JSON.stringify(req.fields) : undefined,
|
|
314
|
+
"Accept": "application/json", // Indiquer explicitement que nous attendons du JSON
|
|
315
|
+
signal: controller.signal
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
if (!proxyResponse.ok) {
|
|
319
|
+
throw new Error(`Node responded with status ${proxyResponse.status}`);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
res.status(proxyResponse.status);
|
|
323
|
+
proxyResponse.headers.forEach((value, name) => {
|
|
324
|
+
// On évite de propager des en-têtes liés à la connexion interne du cluster.
|
|
325
|
+
if (name.toLowerCase() !== 'transfer-encoding' && name.toLowerCase() !== 'connection') {
|
|
326
|
+
res.setHeader(name, value);
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
const { Readable } = await import('node:stream');
|
|
330
|
+
Readable.fromWeb(proxyResponse.body).pipe(res);
|
|
331
|
+
} catch (error) {
|
|
332
|
+
// --- AMÉLIORATION DE LA GESTION D'ERREUR ---
|
|
333
|
+
// On enrichit le log pour comprendre pourquoi le fetch a échoué.
|
|
334
|
+
// Un "fetch failed" est souvent une erreur réseau, DNS, ou SSL.
|
|
335
|
+
logger.error(`[Proxy] Fetch to ${targetUrl.toString()} failed. Error: ${error.message}`);
|
|
336
|
+
|
|
337
|
+
// L'objet `error` contient souvent une propriété `cause` avec plus de détails techniques.
|
|
338
|
+
if (error.cause) {
|
|
339
|
+
logger.error(`[Proxy] Underlying cause: ${error.cause.code} - ${error.cause.message}`);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// On relance l'erreur pour que la logique de failover dans proxyRequest puisse fonctionner.
|
|
343
|
+
// On peut même encapsuler l'erreur originale pour conserver le contexte.
|
|
344
|
+
const proxyError = new Error(`Proxy attempt to ${node.public_domain} failed: ${error.message}`);
|
|
345
|
+
proxyError.cause = error; // Conserve l'erreur originale
|
|
346
|
+
throw proxyError;
|
|
347
|
+
} finally {
|
|
348
|
+
clearTimeout(timeoutId);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Vérifie si une requête a déjà été relayée pour éviter les boucles.
|
|
354
|
+
* @param {object} req - L'objet requête Express.
|
|
355
|
+
* @returns {boolean}
|
|
356
|
+
*/
|
|
357
|
+
function isProxiedRequest(req) {
|
|
358
|
+
return req.headers['x-federation-proxy'] === 'true';
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
// --- GOSSIP PROTOCOL LOGIC ---
|
|
363
|
+
|
|
364
|
+
function logMemberList() {
|
|
365
|
+
const simplifiedList = getMemberList().map(m => `${m.id}(${m.status}, v${m.version})`);
|
|
366
|
+
logger.debug(`[Gossip] Member list: [${simplifiedList.join(', ')}]`);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function mergeLists(remoteList) {
|
|
370
|
+
let updated = false;
|
|
371
|
+
for (const remoteMember of remoteList) {
|
|
372
|
+
const localMember = memberList.get(remoteMember.id);
|
|
373
|
+
|
|
374
|
+
if (!localMember) {
|
|
375
|
+
memberList.set(remoteMember.id, remoteMember);
|
|
376
|
+
logger.info(`[Gossip] Discovered new member: ${remoteMember.id} at ${remoteMember.url}`);
|
|
377
|
+
updated = true;
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (remoteMember.id !== engine.selfId && remoteMember.version > (localMember.version || 0)) {
|
|
382
|
+
logger.debug(`[Gossip] Updating member ${remoteMember.id} from v${localMember.version} to v${remoteMember.version}`);
|
|
383
|
+
memberList.set(remoteMember.id, remoteMember);
|
|
384
|
+
updated = true;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
if (updated) {
|
|
388
|
+
logMemberList();
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function updateMemberStatus(memberId, newStatus) {
|
|
393
|
+
const member = memberList.get(memberId);
|
|
394
|
+
if (!member) return;
|
|
395
|
+
|
|
396
|
+
const statusChanged = member.status !== newStatus;
|
|
397
|
+
|
|
398
|
+
// Si le statut change, ou si on reconfirme qu'un noeud est SUSPECT, on met à jour.
|
|
399
|
+
if (statusChanged || newStatus === 'SUSPECT') {
|
|
400
|
+
if (statusChanged) {
|
|
401
|
+
logger.info(`[Gossip] Updating status for ${memberId} from ${member.status} to ${newStatus}`);
|
|
402
|
+
member.status = newStatus;
|
|
403
|
+
member.version++; // On propage le changement uniquement si le statut change.
|
|
404
|
+
}
|
|
405
|
+
member.lastUpdate = Date.now();
|
|
406
|
+
logMemberList();
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Si un noeud redevient 'UP', on réinitialise son état disque en attendant la prochaine mise à jour de sa part.
|
|
410
|
+
if (newStatus === 'UP' && member.status !== 'UP') {
|
|
411
|
+
member.disk = null;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Vérifie périodiquement les nœuds suspects pour les réactiver ou les marquer comme DOWN.
|
|
417
|
+
*/
|
|
418
|
+
function checkSuspectNodes() {
|
|
419
|
+
const now = Date.now();
|
|
420
|
+
const SUSPECT_TIMEOUT = Config.Get('gossipSuspectTimeout', 10000); // 10 secondes
|
|
421
|
+
|
|
422
|
+
const suspectNodes = getMemberList().filter(m => m.status === 'SUSPECT');
|
|
423
|
+
|
|
424
|
+
for (const member of suspectNodes) {
|
|
425
|
+
// Si un nœud est suspect depuis trop longtemps, on le considère comme DOWN.
|
|
426
|
+
// La logique de suppression effective des nœuds DOWN peut être ajoutée ici si nécessaire.
|
|
427
|
+
if (now - (member.lastUpdate || 0) > SUSPECT_TIMEOUT) {
|
|
428
|
+
if (member.status !== 'DOWN') {
|
|
429
|
+
logger.warn(`[Gossip] Member ${member.id} is now considered DOWN (was SUSPECT for too long).`);
|
|
430
|
+
updateMemberStatus(member.id, 'DOWN');
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Met à jour les informations de disque pour le noeud local.
|
|
438
|
+
* Cette fonction sera appelée périodiquement.
|
|
439
|
+
*/
|
|
440
|
+
async function updateSelfDiskUsage() {
|
|
441
|
+
const self = memberList.get(engine.selfId);
|
|
442
|
+
if (!self) return;
|
|
443
|
+
|
|
444
|
+
try {
|
|
445
|
+
// Utilisation de la fonction native de Node.js pour obtenir l'espace disque
|
|
446
|
+
const stats = await statfs(process.cwd()); // Vérifie le disque de l'application
|
|
447
|
+
const free = stats.bavail * stats.bsize; // Espace libre disponible pour l'utilisateur
|
|
448
|
+
const total = stats.blocks * stats.bsize; // Espace total
|
|
449
|
+
self.disk = { free, total };
|
|
450
|
+
|
|
451
|
+
// Si l'espace libre est < 10%, on se marque comme 'FULL' pour ne plus accepter de répliques.
|
|
452
|
+
const usagePercentage = 1 - (free / total);
|
|
453
|
+
updateMemberStatus(self.id, usagePercentage > 0.9 ? 'FULL' : 'UP');
|
|
454
|
+
} catch (err) {
|
|
455
|
+
logger.error(`[Gossip] Could not read disk usage: ${err.message}`);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
async function executeGossip() {
|
|
459
|
+
const peersToGossip = getMemberList().filter(m => m.id !== engine.selfId && m.status === 'UP');
|
|
460
|
+
|
|
461
|
+
// On exécute la vérification des nœuds suspects à chaque cycle, même si aucun pair n'est UP.
|
|
462
|
+
checkSuspectNodes();
|
|
463
|
+
if (peersToGossip.length === 0) {
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const targetPeer = peersToGossip[Math.floor(Math.random() * peersToGossip.length)];
|
|
468
|
+
|
|
469
|
+
try {
|
|
470
|
+
// Avant d'envoyer notre liste, on met à jour nos propres infos
|
|
471
|
+
await updateSelfDiskUsage();
|
|
472
|
+
|
|
473
|
+
const payload = getMemberList();
|
|
474
|
+
const response = await engine.sendToPeer(targetPeer.id, '/api/internal/gossip', payload);
|
|
475
|
+
|
|
476
|
+
if (response.ok) {
|
|
477
|
+
const remoteList = await response.json();
|
|
478
|
+
mergeLists(remoteList);
|
|
479
|
+
// Si la communication a réussi, on s'assure que le pair est bien 'UP'
|
|
480
|
+
// Utile si le pair était 'SUSPECT' et est revenu en ligne.
|
|
481
|
+
if (memberList.get(targetPeer.id)?.status !== 'UP') {
|
|
482
|
+
updateMemberStatus(targetPeer.id, 'UP');
|
|
483
|
+
}
|
|
484
|
+
} else {
|
|
485
|
+
logger.warn(`[Gossip] Failed to gossip with ${targetPeer.id}. Status: ${response.status}`);
|
|
486
|
+
updateMemberStatus(targetPeer.id, 'SUSPECT');
|
|
487
|
+
}
|
|
488
|
+
} catch (error) {
|
|
489
|
+
logger.error(`[Gossip] Error while gossiping with ${targetPeer.id}: ${error.message}`);
|
|
490
|
+
updateMemberStatus(targetPeer.id, 'SUSPECT');
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function getMemberList() {
|
|
495
|
+
return Array.from(memberList.values()).sort((a, b) => a.id.localeCompare(b.id));
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function stopClusterServices() {
|
|
499
|
+
clearInterval(gossipInterval);
|
|
500
|
+
stopReplicationQueue();
|
|
501
|
+
logger.info('[Cluster] Gossip and replication services stopped.');
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
async function initializeCluster(defaultEngine) {
|
|
505
|
+
engine = defaultEngine;
|
|
506
|
+
logger = engine.getComponent(Logger) || new Logger('DataCluster');
|
|
507
|
+
dataHandler = engine.getComponent(DataHandler); // On récupère le gestionnaire de données
|
|
508
|
+
engine.selfId = engine.peers.find(p => p.public_domain === engine.selfUrl)?.id || 'unknown-node';
|
|
509
|
+
|
|
510
|
+
// 1. Initialiser la liste des membres à partir de la configuration statique
|
|
511
|
+
memberList.clear();
|
|
512
|
+
for (const peer of engine.peers) {
|
|
513
|
+
memberList.set(peer.id, {...peer, status: 'UP', version: 1, lastUpdate: Date.now(), disk: null});
|
|
514
|
+
}
|
|
515
|
+
logger.info(`[Cluster] Initialized. Self: ${engine.selfId} @ ${engine.selfUrl}.`);
|
|
516
|
+
logMemberList();
|
|
517
|
+
|
|
518
|
+
// Initialiser le système de baux si un DataHandler est présent
|
|
519
|
+
// La logique est déplacée pour utiliser le driver natif via le module mongodb.
|
|
520
|
+
// Cela ne dépend plus du composant DataHandler qui était problématique.
|
|
521
|
+
try {
|
|
522
|
+
clusterLeaseCollection = getCollection('cluster_leases');
|
|
523
|
+
// Création d'un index unique sur resourceId pour garantir l'intégrité.
|
|
524
|
+
await clusterLeaseCollection.createIndex({resourceId: 1}, {unique: true});
|
|
525
|
+
// Création d'un index TTL qui supprime automatiquement les documents dont le bail a expiré.
|
|
526
|
+
// C'est une bonne pratique pour le nettoyage automatique.
|
|
527
|
+
await clusterLeaseCollection.createIndex({expiresAt: 1}, {expireAfterSeconds: 0});
|
|
528
|
+
logger.info('[Cluster] Lease manager initialized with native MongoDB driver.');
|
|
529
|
+
} catch (error) {
|
|
530
|
+
logger.error('[Cluster] Failed to initialize lease manager with MongoDB. Lease-based master verification is disabled. Cluster may be vulnerable to split-brain.', error);
|
|
531
|
+
clusterLeaseCollection = null; // S'assurer que le système de bail est désactivé en cas d'erreur.
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// 2. Enregistrer l'endpoint pour recevoir les "gossips"
|
|
535
|
+
engine.post('/api/internal/gossip', (req, res) => {
|
|
536
|
+
const remoteList = req.fields;
|
|
537
|
+
if (Array.isArray(remoteList)) {
|
|
538
|
+
mergeLists(remoteList);
|
|
539
|
+
}
|
|
540
|
+
// En réponse, on renvoie notre propre liste mise à jour
|
|
541
|
+
res.json(getMemberList());
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
// 3. Démarrer la boucle de gossip
|
|
545
|
+
const interval = Config.Get('gossipInterval', 2000);
|
|
546
|
+
if (gossipInterval) clearInterval(gossipInterval); // Clear previous interval if re-initializing
|
|
547
|
+
gossipInterval = setInterval(executeGossip, interval);
|
|
548
|
+
|
|
549
|
+
// Démarrer le processeur de la file d'attente de réplication
|
|
550
|
+
replicationInit(engine, {getReplicaNodesForUser: self.getReplicaNodesForUser});
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
export { broadcastCacheInvalidation, replicateOperation, getResponsibleNodesForUser, getReplicaNodesForUser, isSelfMasterForUser, proxyRequest, isProxiedRequest, checkSuspectNodes, getMemberList, stopClusterServices, initializeCluster };
|
|
555
|
+
|
|
556
|
+
export function onInit(engine) {
|
|
557
|
+
// On attend que le serveur soit démarré pour avoir engine.selfUrl et engine.peers
|
|
558
|
+
Event.Listen("OnServerStart", () => initializeCluster(engine), "event", "system");
|
|
559
|
+
}
|
|
@@ -1,12 +1,9 @@
|
|
|
1
1
|
import NodeCache from "node-cache";
|
|
2
|
-
import path from "node:path";
|
|
3
2
|
import {Worker} from 'worker_threads';
|
|
4
|
-
import {fileURLToPath} from "node:url";
|
|
3
|
+
import { fileURLToPath, URL } from "node:url";
|
|
4
|
+
import path from "node:path";
|
|
5
5
|
export const modelsCache = new NodeCache( { stdTTL: 100, checkperiod: 120 } );
|
|
6
6
|
|
|
7
|
-
const __filename = fileURLToPath(import.meta.url);
|
|
8
|
-
const __dirname = path.dirname(__filename);
|
|
9
|
-
|
|
10
7
|
/**
|
|
11
8
|
* Helper to escape characters for regex.
|
|
12
9
|
* @param {string} str The string to escape.
|
|
@@ -56,8 +53,12 @@ export let importJobs = {};
|
|
|
56
53
|
*/
|
|
57
54
|
export function runImportExportWorker(action, payload) {
|
|
58
55
|
return new Promise((resolve, reject) => {
|
|
59
|
-
|
|
60
|
-
|
|
56
|
+
// Construct a full URL to the worker script. This is the most robust way
|
|
57
|
+
// to ensure the worker is found, then convert it to a file path.
|
|
58
|
+
//const workerPath = path.resolve('src/workers/import-export-worker.js');
|
|
59
|
+
const workerPath = import.meta.dirname;
|
|
60
|
+
|
|
61
|
+
const worker = new Worker(path.resolve(workerPath,"../../workers/import-export-worker.js"));
|
|
61
62
|
|
|
62
63
|
worker.postMessage({ action, payload });
|
|
63
64
|
|
|
@@ -91,7 +92,9 @@ export function runImportExportWorker(action, payload) {
|
|
|
91
92
|
*/
|
|
92
93
|
export function runCryptoWorkerTask(action, payload) {
|
|
93
94
|
return new Promise((resolve, reject) => {
|
|
94
|
-
|
|
95
|
+
// Construct a full URL to the worker script, then convert it to a file path
|
|
96
|
+
// for maximum reliability with the Worker constructor.
|
|
97
|
+
const workerPath = path.resolve('src/workers/crypto-worker.js');
|
|
95
98
|
const worker = new Worker(workerPath);
|
|
96
99
|
|
|
97
100
|
worker.postMessage({ action, payload });
|