data-primals-engine 1.7.2 → 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.
Files changed (78) hide show
  1. package/README.md +160 -160
  2. package/client/package-lock.json +8430 -8430
  3. package/client/src/APIInfo.jsx +1 -1
  4. package/client/src/App.jsx +2 -1
  5. package/client/src/App.scss +1635 -1626
  6. package/client/src/AssistantChat.jsx +1 -0
  7. package/client/src/CalendarView.jsx +1 -0
  8. package/client/src/ContentView.jsx +3 -3
  9. package/client/src/Dashboard.jsx +5 -2
  10. package/client/src/DashboardChart.jsx +1 -0
  11. package/client/src/DashboardFlexViewItem.jsx +1 -0
  12. package/client/src/DashboardHtmlViewItem.jsx +1 -0
  13. package/client/src/DashboardView.jsx +2 -0
  14. package/client/src/DataEditor.jsx +0 -1
  15. package/client/src/DataImporter.jsx +489 -468
  16. package/client/src/DataLayout.jsx +6 -3
  17. package/client/src/DataTable.jsx +4 -3
  18. package/client/src/Dialog.jsx +92 -90
  19. package/client/src/Dialog.scss +122 -116
  20. package/client/src/DisplayFlexNodeRenderer.jsx +1 -0
  21. package/client/src/FlexBuilderControls.jsx +1 -0
  22. package/client/src/FlexBuilderPreview.jsx +1 -1
  23. package/client/src/FlexNode.jsx +1 -1
  24. package/client/src/HistoryDialog.jsx +3 -2
  25. package/client/src/KPIWidget.jsx +1 -1
  26. package/client/src/KanbanView.jsx +1 -0
  27. package/client/src/ModelCreator.jsx +4 -0
  28. package/client/src/ModelList.jsx +1 -0
  29. package/client/src/PackGallery.jsx +5 -4
  30. package/client/src/RTETrans.jsx +1 -1
  31. package/client/src/RelationField.jsx +2 -0
  32. package/client/src/RelationSelectorWidget.jsx +2 -0
  33. package/client/src/RelationValue.jsx +1 -0
  34. package/client/src/RestoreDialog.jsx +1 -0
  35. package/client/src/WorkflowEditor.jsx +3 -0
  36. package/client/src/contexts/CommandContext.jsx +2 -1
  37. package/client/src/contexts/ModelContext.jsx +3 -3
  38. package/client/src/hooks/data.js +1 -0
  39. package/client/src/hooks/useValidation.js +1 -0
  40. package/client/src/translations.js +24 -24
  41. package/doc/AI-assistance.md +87 -63
  42. package/doc/Concepts.md +122 -0
  43. package/doc/Custom-Endpoints.md +31 -0
  44. package/doc/Event-system.md +13 -14
  45. package/doc/Home.md +33 -0
  46. package/doc/Modules.md +83 -0
  47. package/doc/Packs-gallery.md +8 -23
  48. package/doc/Workflows.md +32 -0
  49. package/doc/automation-workflows.md +141 -102
  50. package/doc/dashboards-kpis-charts.md +47 -49
  51. package/doc/data-management.md +126 -120
  52. package/doc/data-models.md +68 -75
  53. package/doc/roles-permissions.md +144 -43
  54. package/doc/sharding-replication.md +158 -0
  55. package/doc/users.md +54 -30
  56. package/package.json +1 -1
  57. package/server.js +37 -37
  58. package/src/constants.js +7 -8
  59. package/src/data.js +521 -520
  60. package/src/email.js +157 -154
  61. package/src/engine.js +117 -7
  62. package/src/gameObject.js +6 -0
  63. package/src/i18n.js +0 -1
  64. package/src/modules/auth-google/index.js +53 -50
  65. package/src/modules/bucket.js +346 -339
  66. package/src/modules/data/data.backup.js +400 -376
  67. package/src/modules/data/data.cluster.js +410 -42
  68. package/src/modules/data/data.operations.js +3666 -3635
  69. package/src/modules/data/data.replication.js +106 -64
  70. package/src/modules/data/data.routes.js +87 -64
  71. package/src/modules/data/data.scheduling.js +2 -1
  72. package/src/modules/file.js +248 -247
  73. package/src/modules/user.js +11 -1
  74. package/src/sso.js +91 -25
  75. package/test/cluster.test.js +221 -0
  76. package/test/core.test.js +0 -2
  77. package/test/replication.test.js +163 -0
  78. package/doc/core-concepts.md +0 -33
@@ -1,33 +1,50 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { clusterPeers, getHost, port } from '../../constants.js';
2
+ import { statfs } from 'node:fs/promises';
3
3
  import { Config } from '../../config.js';
4
- import { Logger } from '../../gameObject.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';
5
8
 
6
- const logger = new Logger('DataCluster');
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';
7
11
 
8
- // La liste de tous les nœuds inclut le nœud actuel.
9
- // On la trie pour s'assurer que l'ordre est le même sur toutes les instances.
10
- const selfUrl = `http://${getHost()}:${process.env.PORT || port}`;
11
- const allNodes = Array.from(new Set([selfUrl, ...clusterPeers])).sort();
12
- const REPLICATION_FACTOR = Config.Get('replicationFactor', 2); // Maître + 1 réplique par défaut
12
+ let logger;
13
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
14
17
 
15
- logger.info(`[Cluster] Initialized. Self: ${selfUrl}. All nodes: ${allNodes.join(', ')}`);
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
+ */
16
30
 
17
31
  /**
18
32
  * Diffuse un événement d'invalidation de cache à tous les nœuds du cluster.
19
33
  * @param {string} cacheType - Le type de cache à invalider (ex: 'model').
20
34
  * @param {string} key - La clé spécifique à invalider dans le cache.
21
35
  */
22
- export async function broadcastCacheInvalidation(cacheType, key) {
23
- if (clusterPeers.length === 0) {
36
+ async function broadcastCacheInvalidation(cacheType, key) {
37
+ if (!engine || engine.peers.length <= 1) {
24
38
  return; // Pas de cluster, rien à faire.
25
39
  }
26
40
 
27
41
  logger.info(`[Cluster] Broadcasting direct cache invalidation for '${cacheType}' with key '${key}' to peers.`);
28
42
 
43
+ const activePeers = getMemberList().filter(p => p.id !== engine.selfId && p.status === 'UP');
44
+
29
45
  // On envoie une requête à chaque autre nœud du cluster.
30
- const broadcastPromises = clusterPeers.map(peerUrl => {
46
+ const broadcastPromises = activePeers.map(peer => {
47
+ const peerUrl = peer.public_domain;
31
48
  const targetUrl = `${peerUrl}/api/internal/cache-invalidate`;
32
49
  // On suppose que les nœuds partagent un token/secret pour s'authentifier.
33
50
  // Le token de l'utilisateur actuel est une bonne option s'il est admin ou a des droits étendus.
@@ -52,49 +69,166 @@ export async function broadcastCacheInvalidation(cacheType, key) {
52
69
  await Promise.allSettled(broadcastPromises);
53
70
  }
54
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
+
55
83
  /**
56
84
  * Détermine la liste ordonnée des nœuds responsables pour un utilisateur.
57
85
  * Le premier est le maître, les suivants sont les répliques.
58
86
  * @param {string} username - Le nom de l'utilisateur.
59
87
  * @returns {string[]} Une liste d'URLs de nœuds.
60
88
  */
61
- export function getResponsibleNodesForUser(username) {
62
- if (allNodes.length <= 1) {
63
- return [selfUrl];
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]] : [];
64
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];
65
115
 
66
- const numNodes = allNodes.length;
67
- const numReplicas = Math.min(REPLICATION_FACTOR, numNodes);
68
- const responsibleNodes = [];
116
+ const responsibleNodes = [masterNode];
69
117
 
70
- const hash = createHash('sha256').update(username).digest('hex');
71
- const startIndex = parseInt(hash.substring(0, 8), 16) % numNodes;
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
72
127
 
73
- for (let i = 0; i < numReplicas; i++) {
74
- responsibleNodes.push(allNodes[(startIndex + i) % numNodes]);
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.`);
75
143
  }
76
144
 
77
145
  return responsibleNodes;
78
146
  }
79
147
 
148
+ function getReplicaNodesForUser(username) {
149
+ return getResponsibleNodesForUser(username).slice(1);
150
+ }
80
151
  /**
81
152
  * Détermine l'URL du nœud "maître" pour un utilisateur donné.
82
153
  * @param {string} username - Le nom de l'utilisateur.
83
154
  * @returns {string} L'URL du nœud maître.
84
155
  */
85
156
  export function getMasterNodeForUser(username) {
86
- return getResponsibleNodesForUser(username)[0];
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);
87
169
  }
88
170
 
89
171
  /**
90
172
  * Vérifie si le nœud actuel est le maître pour cet utilisateur.
91
173
  * @param {string} username - Le nom de l'utilisateur.
92
174
  * @returns {boolean}
175
+ * @returns {Promise<boolean>}
93
176
  */
94
- export function isSelfMasterForUser(username) {
95
- return getMasterNodeForUser(username) === selfUrl;
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;
96
197
  }
97
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
+ }
98
232
  /**
99
233
  * Relaye une requête (lecture ou écriture) vers le nœud maître approprié de manière performante (streaming).
100
234
  * @param {object} req - L'objet requête Express.
@@ -102,35 +236,50 @@ export function isSelfMasterForUser(username) {
102
236
  * @param {string} username - Le nom de l'utilisateur concerné.
103
237
  * @returns {Promise<boolean>} - Retourne `true` si la requête a été relayée, `false` sinon.
104
238
  */
105
- export async function proxyRequest(req, res, username) {
106
- const responsibleNodes = getResponsibleNodesForUser(username);
107
- const masterNodeUrl = responsibleNodes[0];
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);
108
242
 
109
- if (masterNodeUrl === selfUrl) {
243
+ if (isMaster) {
110
244
  // Ce nœud est le maître, on ne relaie pas.
111
245
  return false;
112
246
  }
113
247
 
114
248
  // Pour les écritures (POST, PUT, PATCH, DELETE), on cible toujours le maître.
115
249
  if (req.method !== 'GET') {
116
- logger.info(`[Cluster] Proxying WRITE ${req.method} for user ${username} to master node ${masterNodeUrl}`);
117
- await attemptProxy(req, res, masterNodeUrl);
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);
118
267
  return true; // La requête a été traitée (relayée ou a échoué).
119
268
  }
120
269
 
121
270
  // Pour les lectures (GET), on tente le maître, puis les répliques en cas d'échec.
122
- logger.info(`[Cluster] Proxying READ for user ${username}. Node preference: ${responsibleNodes.join(' -> ')}`);
123
- for (const nodeUrl of responsibleNodes) {
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) {
124
274
  try {
125
- // Si le nœud est nous-mêmes, on arrête le proxying et on laisse le handler local prendre le relais.
126
- if (nodeUrl === selfUrl) {
275
+ if (node.id === engine.selfId) {
127
276
  logger.info(`[Cluster] Failover to self. Handling request locally.`);
128
277
  return false;
129
278
  }
130
- await attemptProxy(req, res, nodeUrl);
279
+ await attemptProxy(req, res, node);
131
280
  return true; // Succès, on arrête la boucle de failover.
132
281
  } catch (error) {
133
- logger.warn(`[Cluster] Proxy attempt to ${nodeUrl} failed: ${error.message}. Trying next node...`);
282
+ logger.warn(`[Cluster] Proxy attempt to ${node.public_domain} failed: ${error.message}. Trying next node...`);
134
283
  }
135
284
  }
136
285
 
@@ -140,10 +289,14 @@ export async function proxyRequest(req, res, username) {
140
289
  return true; // La requête a été traitée (a échoué).
141
290
  }
142
291
 
143
- async function attemptProxy(req, res, nodeUrl) {
144
- const targetUrl = new URL(req.originalUrl, nodeUrl);
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.
145
295
  const headers = { ...req.headers };
146
- delete headers['host'];
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
+
147
300
  headers['X-Federation-Proxy'] = 'true';
148
301
  if (!headers['content-type']) {
149
302
  headers['content-type'] = 'application/json';
@@ -158,6 +311,7 @@ async function attemptProxy(req, res, nodeUrl) {
158
311
  method: req.method,
159
312
  headers: headers,
160
313
  body: (req.method !== 'GET' && req.method !== 'HEAD') ? JSON.stringify(req.fields) : undefined,
314
+ "Accept": "application/json", // Indiquer explicitement que nous attendons du JSON
161
315
  signal: controller.signal
162
316
  });
163
317
 
@@ -175,7 +329,21 @@ async function attemptProxy(req, res, nodeUrl) {
175
329
  const { Readable } = await import('node:stream');
176
330
  Readable.fromWeb(proxyResponse.body).pipe(res);
177
331
  } catch (error) {
178
- throw error; // Relancer l'erreur pour que la boucle de failover puisse l'attraper.
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;
179
347
  } finally {
180
348
  clearTimeout(timeoutId);
181
349
  }
@@ -186,6 +354,206 @@ async function attemptProxy(req, res, nodeUrl) {
186
354
  * @param {object} req - L'objet requête Express.
187
355
  * @returns {boolean}
188
356
  */
189
- export function isProxiedRequest(req) {
357
+ function isProxiedRequest(req) {
190
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");
191
559
  }