@juspay/neurolink 12.32.2 → 12.32.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.
@@ -60,22 +60,27 @@ function sanitizePersistedCooldown(accountKey, entry) {
60
60
  `${hours(ceiling)}h — the stored wait exceeded what "${entry.reason}" can mean`);
61
61
  return { ...entry, coolingUntil: latest };
62
62
  }
63
+ /**
64
+ * Read the cooldown file as it is now. A missing file is empty; any other
65
+ * failure (unreadable, corrupt) returns `fallback`, so a write never discards
66
+ * entries just because one read failed.
67
+ */
68
+ async function readCooldownsFromDisk(fallback) {
69
+ try {
70
+ const parsed = JSON.parse(await readFile(getCooldownFilePath(), "utf8"));
71
+ return Object.fromEntries(Object.entries(parsed)
72
+ .filter((entry) => isPersistedCooldown(entry[1]))
73
+ .map(([key, entry]) => [key, sanitizePersistedCooldown(key, entry)]));
74
+ }
75
+ catch (error) {
76
+ return error.code === "ENOENT" ? {} : fallback;
77
+ }
78
+ }
63
79
  async function ensureAccountCooldownsLoaded() {
64
80
  if (!cacheLoaded) {
65
81
  if (!cacheLoadPromise) {
66
82
  cacheLoadPromise = (async () => {
67
- try {
68
- const parsed = JSON.parse(await readFile(getCooldownFilePath(), "utf8"));
69
- memoryCache = Object.fromEntries(Object.entries(parsed)
70
- .filter((entry) => isPersistedCooldown(entry[1]))
71
- .map(([key, entry]) => [
72
- key,
73
- sanitizePersistedCooldown(key, entry),
74
- ]));
75
- }
76
- catch {
77
- memoryCache = {};
78
- }
83
+ memoryCache = await readCooldownsFromDisk({});
79
84
  cacheLoaded = true;
80
85
  })().finally(() => {
81
86
  cacheLoadPromise = null;
@@ -91,14 +96,18 @@ export async function loadAccountCooldowns() {
91
96
  export async function saveAccountCooldown(accountKey, coolingUntil, reason) {
92
97
  await mutationMutex.runExclusive(async () => {
93
98
  await ensureAccountCooldownsLoaded();
94
- const current = memoryCache[accountKey];
99
+ // Another worker (a draining one during a rolling restart) may have written
100
+ // the file since this one loaded it, so the change folds onto the file as
101
+ // it is now rather than onto this worker's copy.
102
+ const onDisk = await readCooldownsFromDisk(memoryCache);
103
+ memoryCache = onDisk;
104
+ const current = onDisk[accountKey];
95
105
  if (current && current.coolingUntil > coolingUntil) {
96
106
  return;
97
107
  }
98
- memoryCache[accountKey] = {
99
- coolingUntil,
100
- reason,
101
- updatedAt: Date.now(),
108
+ memoryCache = {
109
+ ...onDisk,
110
+ [accountKey]: { coolingUntil, reason, updatedAt: Date.now() },
102
111
  };
103
112
  await writeJsonSnapshotAtomically(getCooldownFilePath(), memoryCache);
104
113
  });
@@ -106,7 +115,11 @@ export async function saveAccountCooldown(accountKey, coolingUntil, reason) {
106
115
  export async function clearAccountCooldown(accountKey, expectedCoolingUntil) {
107
116
  await mutationMutex.runExclusive(async () => {
108
117
  await ensureAccountCooldownsLoaded();
109
- const current = memoryCache[accountKey];
118
+ // Compare against the file, not this worker's copy: another worker may
119
+ // have extended this cooldown since, and that one must survive.
120
+ const onDisk = await readCooldownsFromDisk(memoryCache);
121
+ memoryCache = onDisk;
122
+ const current = onDisk[accountKey];
110
123
  if (!current) {
111
124
  return;
112
125
  }
@@ -114,7 +127,9 @@ export async function clearAccountCooldown(accountKey, expectedCoolingUntil) {
114
127
  current.coolingUntil !== expectedCoolingUntil) {
115
128
  return;
116
129
  }
117
- delete memoryCache[accountKey];
130
+ const next = { ...onDisk };
131
+ delete next[accountKey];
132
+ memoryCache = next;
118
133
  await writeJsonSnapshotAtomically(getCooldownFilePath(), memoryCache);
119
134
  });
120
135
  }
@@ -308,8 +308,8 @@ const FLUSH_INTERVAL_MS = 5_000; // write to disk at most every 5 seconds
308
308
  let memoryCache = {};
309
309
  let cacheLoaded = false;
310
310
  let cacheLoadPromise = null;
311
- let dirty = false;
312
- let cacheVersion = 0;
311
+ /** Accounts this worker changed since its last flush. */
312
+ let dirtyKeys = new Set();
313
313
  let flushTimer = null;
314
314
  const stateMutex = new AsyncMutex();
315
315
  const flushMutex = new AsyncMutex();
@@ -332,38 +332,86 @@ export function initAccountQuota(quotaFilePath) {
332
332
  memoryCache = {};
333
333
  cacheLoaded = false;
334
334
  cacheLoadPromise = null;
335
- dirty = false;
336
- cacheVersion = 0;
335
+ dirtyKeys = new Set();
337
336
  }
338
337
  function getQuotaFilePath() {
339
338
  return customQuotaFilePath ?? join(homedir(), ".neurolink", QUOTA_FILE);
340
339
  }
341
- /** Flush the in-memory cache to disk (async, non-blocking). */
340
+ /**
341
+ * Read the quota file as it is now. A missing file is empty; any other failure
342
+ * (unreadable, corrupt) returns `fallback`, so a flush never discards entries
343
+ * just because one read failed.
344
+ */
345
+ async function readQuotasFromDisk(filePath, fallback) {
346
+ try {
347
+ const parsed = JSON.parse(await fs.readFile(filePath, "utf-8"));
348
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
349
+ return fallback;
350
+ }
351
+ return parsed;
352
+ }
353
+ catch (error) {
354
+ return error.code === "ENOENT" ? {} : fallback;
355
+ }
356
+ }
357
+ /** Fold two snapshots of one account so the more recently updated one wins,
358
+ * whichever worker wrote it. */
359
+ function foldNewerQuota(onDisk, ours) {
360
+ if (!onDisk) {
361
+ return ours;
362
+ }
363
+ return onDisk.lastUpdated > ours.lastUpdated
364
+ ? mergeQuotaSnapshot(ours, onDisk)
365
+ : mergeQuotaSnapshot(onDisk, ours);
366
+ }
367
+ /** Flush this worker's changed accounts to disk (async, non-blocking). */
342
368
  async function flushToDisk() {
343
369
  await flushMutex.runExclusive(async () => {
344
- let snapshot;
345
- let snapshotVersion = 0;
370
+ let changed;
371
+ let fallback = {};
346
372
  let filePath = "";
347
373
  await stateMutex.runExclusive(async () => {
348
- if (!dirty) {
374
+ if (dirtyKeys.size === 0) {
349
375
  return;
350
376
  }
351
- snapshot = Object.fromEntries(Object.entries(memoryCache).map(([key, quota]) => [key, { ...quota }]));
352
- snapshotVersion = cacheVersion;
377
+ changed = Object.fromEntries([...dirtyKeys].map((key) => [key, { ...memoryCache[key] }]));
378
+ fallback = Object.fromEntries(Object.entries(memoryCache).map(([key, quota]) => [key, { ...quota }]));
379
+ dirtyKeys = new Set();
353
380
  filePath = getQuotaFilePath();
354
381
  });
355
- if (!snapshot) {
382
+ const flushing = changed;
383
+ if (!flushing) {
356
384
  return;
357
385
  }
358
386
  try {
359
- await writeJsonSnapshotAtomically(filePath, snapshot, 0o600);
387
+ // Another worker (a draining one during a rolling restart) may have
388
+ // written the file since this one loaded it: fold only this worker's
389
+ // changes onto the file as it is now, never rewrite it from this copy.
390
+ const onDisk = await readQuotasFromDisk(filePath, fallback);
391
+ const merged = { ...onDisk };
392
+ for (const [key, quota] of Object.entries(flushing)) {
393
+ merged[key] = foldNewerQuota(onDisk[key], quota);
394
+ }
395
+ await writeJsonSnapshotAtomically(filePath, merged, 0o600);
360
396
  await stateMutex.runExclusive(async () => {
361
- if (cacheVersion === snapshotVersion) {
362
- dirty = false;
397
+ if (getQuotaFilePath() !== filePath) {
398
+ return;
363
399
  }
400
+ // Adopt what other workers wrote, keeping changes saved meanwhile.
401
+ memoryCache = {
402
+ ...merged,
403
+ ...Object.fromEntries([...dirtyKeys].map((key) => [key, memoryCache[key]])),
404
+ };
364
405
  });
365
406
  }
366
407
  catch {
408
+ await stateMutex.runExclusive(async () => {
409
+ if (getQuotaFilePath() === filePath) {
410
+ for (const key of Object.keys(flushing)) {
411
+ dirtyKeys.add(key);
412
+ }
413
+ }
414
+ });
367
415
  // Non-fatal — quota is best-effort telemetry
368
416
  }
369
417
  });
@@ -394,13 +442,7 @@ async function ensureAccountQuotasLoaded() {
394
442
  if (!cacheLoaded) {
395
443
  if (!cacheLoadPromise) {
396
444
  cacheLoadPromise = (async () => {
397
- try {
398
- const raw = await fs.readFile(getQuotaFilePath(), "utf-8");
399
- memoryCache = JSON.parse(raw);
400
- }
401
- catch {
402
- memoryCache = {};
403
- }
445
+ memoryCache = await readQuotasFromDisk(getQuotaFilePath(), {});
404
446
  cacheLoaded = true;
405
447
  })().finally(() => {
406
448
  cacheLoadPromise = null;
@@ -436,8 +478,7 @@ export async function saveAccountQuota(accountKey, quota) {
436
478
  // A header capture reports only the windows the served request touched, so
437
479
  // it must fold onto the existing snapshot rather than replace it.
438
480
  memoryCache[accountKey] = mergeQuotaSnapshot(memoryCache[accountKey], quota);
439
- dirty = true;
440
- cacheVersion += 1;
481
+ dirtyKeys.add(accountKey);
441
482
  });
442
483
  scheduleFlush();
443
484
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juspay/neurolink",
3
- "version": "12.32.2",
3
+ "version": "12.32.3",
4
4
  "packageManager": "pnpm@10.15.1",
5
5
  "description": "The pipe layer of an AI nervous system: one interface connecting provider neurons to your application, across three inference types — generate, stream and decide. `decide` returns typed, calibrated judgements from a non-generative model (~400ms, ~$0.00002/call) for routing, tool selection and context budgeting. MCP-native (4 transports), voice TTS/STT/realtime, RAG, agents, memory, compaction, 9 observability exporters. OpenAI · Anthropic · Gemini · Bedrock · Azure · Ollama · TypeSafe Jev and more.",
6
6
  "author": {