@brimble/consul 1.0.0 → 1.0.1
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 +181 -0
- package/lib/consul.d.ts +20 -0
- package/lib/consul.js +6 -0
- package/lib/index.d.ts +16 -1
- package/lib/resolver/algorithms.js +127 -0
- package/lib/resolver/dns.js +182 -0
- package/lib/resolver/health.js +51 -0
- package/lib/resolver/metrics.js +199 -0
- package/lib/resolver/scoring.js +95 -0
- package/lib/resolver/types.js +28 -0
- package/lib/resolver.d.ts +76 -0
- package/lib/resolver.js +290 -0
- package/package.json +6 -1
package/README.md
CHANGED
|
@@ -29,6 +29,7 @@ See the official [HTTP API][consul-docs-api] docs for more information.
|
|
|
29
29
|
* [Intention](#intention)
|
|
30
30
|
* [KV](#kv)
|
|
31
31
|
* [Query](#query)
|
|
32
|
+
* [Resolver](#resolver)
|
|
32
33
|
* [Session](#session)
|
|
33
34
|
* [Status](#status)
|
|
34
35
|
* [Transaction](#transaction)
|
|
@@ -1486,6 +1487,186 @@ Usage
|
|
|
1486
1487
|
await consul.intention.destroy("a0f5dc05-84c3-5f5a-1d88-05b875e524e1");
|
|
1487
1488
|
```
|
|
1488
1489
|
|
|
1490
|
+
<a id="resolver"></a>
|
|
1491
|
+
|
|
1492
|
+
### consul.resolver(config)
|
|
1493
|
+
|
|
1494
|
+
Create a DNS resolver instance for load balancing and service discovery with Consul.
|
|
1495
|
+
|
|
1496
|
+
The resolver provides intelligent service selection using multiple algorithms, DNS resolution, health checks, and Redis-based metrics tracking.
|
|
1497
|
+
|
|
1498
|
+
Options
|
|
1499
|
+
|
|
1500
|
+
- redis (Redis, optional): ioredis instance for caching (required if cacheEnabled is true)
|
|
1501
|
+
- cacheEnabled (Boolean, default: false): enable Redis caching
|
|
1502
|
+
- cachePrefix (String, required): prefix for Redis cache keys
|
|
1503
|
+
- debug (Boolean, default: false): enable debug logging
|
|
1504
|
+
- weights (Object, optional): custom weights for weighted round robin algorithm
|
|
1505
|
+
- health (Number, default: 0.25): weight for health score
|
|
1506
|
+
- responseTime (Number, default: 0.2): weight for response time
|
|
1507
|
+
- errorRate (Number, default: 0.2): weight for error rate
|
|
1508
|
+
- resources (Number, default: 0.15): weight for CPU/memory usage
|
|
1509
|
+
- connections (Number, default: 0.1): weight for active connections
|
|
1510
|
+
- distribution (Number, default: 0.1): weight for distribution fairness
|
|
1511
|
+
- metrics (Object, optional): default metrics for new services
|
|
1512
|
+
- responseTime (Number, default: 100): default response time in ms
|
|
1513
|
+
- errorRate (Number, default: 0): default error rate percentage
|
|
1514
|
+
- cpuUsage (Number, default: 50): default CPU usage percentage
|
|
1515
|
+
- memoryUsage (Number, default: 50): default memory usage percentage
|
|
1516
|
+
- activeConnections (Number, default: 0): default active connections
|
|
1517
|
+
- cacheTTL (Number, default: 60000): cache TTL in milliseconds
|
|
1518
|
+
- dnsEndpoints (String[], optional): custom DNS endpoints
|
|
1519
|
+
- dnsTimeout (Number, default: 1500): DNS query timeout in milliseconds
|
|
1520
|
+
- dnsRetries (Number, default: 2): number of DNS retry attempts
|
|
1521
|
+
|
|
1522
|
+
Usage
|
|
1523
|
+
|
|
1524
|
+
```javascript
|
|
1525
|
+
import Consul from "@brimble/consul";
|
|
1526
|
+
import Redis from "ioredis";
|
|
1527
|
+
|
|
1528
|
+
const consul = new Consul({
|
|
1529
|
+
host: "127.0.0.1",
|
|
1530
|
+
port: 8500,
|
|
1531
|
+
});
|
|
1532
|
+
|
|
1533
|
+
const redis = new Redis({
|
|
1534
|
+
host: "localhost",
|
|
1535
|
+
port: 6379,
|
|
1536
|
+
});
|
|
1537
|
+
|
|
1538
|
+
const resolver = consul.resolver({
|
|
1539
|
+
redis,
|
|
1540
|
+
cacheEnabled: true,
|
|
1541
|
+
cachePrefix: "myapp",
|
|
1542
|
+
debug: false,
|
|
1543
|
+
});
|
|
1544
|
+
```
|
|
1545
|
+
|
|
1546
|
+
<a id="resolver-select"></a>
|
|
1547
|
+
|
|
1548
|
+
### resolver.selectOptimalService(service, algorithm)
|
|
1549
|
+
|
|
1550
|
+
Select the optimal service instance based on the specified algorithm.
|
|
1551
|
+
|
|
1552
|
+
Options
|
|
1553
|
+
|
|
1554
|
+
- service (String, required): service name to resolve
|
|
1555
|
+
- algorithm (String, optional): selection algorithm
|
|
1556
|
+
- `SelectionAlgorithm.RoundRobin` (default): round-robin selection
|
|
1557
|
+
- `SelectionAlgorithm.LeastConnection`: select service with fewest connections
|
|
1558
|
+
- `SelectionAlgorithm.WeightedRoundRobin`: weighted selection based on metrics
|
|
1559
|
+
|
|
1560
|
+
Usage
|
|
1561
|
+
|
|
1562
|
+
```javascript
|
|
1563
|
+
const Consul = require("@brimble/consul");
|
|
1564
|
+
const { SelectionAlgorithm } = Consul.Resolver;
|
|
1565
|
+
|
|
1566
|
+
const result = await resolver.selectOptimalService(
|
|
1567
|
+
"my-service",
|
|
1568
|
+
SelectionAlgorithm.LeastConnection,
|
|
1569
|
+
);
|
|
1570
|
+
|
|
1571
|
+
if (result.selected) {
|
|
1572
|
+
console.log(`Selected: ${result.selected.ip}:${result.selected.port}`);
|
|
1573
|
+
}
|
|
1574
|
+
```
|
|
1575
|
+
|
|
1576
|
+
Result
|
|
1577
|
+
|
|
1578
|
+
```json
|
|
1579
|
+
{
|
|
1580
|
+
"selected": {
|
|
1581
|
+
"ip": "192.168.1.10",
|
|
1582
|
+
"port": 8080
|
|
1583
|
+
},
|
|
1584
|
+
"services": [
|
|
1585
|
+
{
|
|
1586
|
+
"ip": "192.168.1.10",
|
|
1587
|
+
"port": 8080
|
|
1588
|
+
},
|
|
1589
|
+
{
|
|
1590
|
+
"ip": "192.168.1.11",
|
|
1591
|
+
"port": 8080
|
|
1592
|
+
}
|
|
1593
|
+
]
|
|
1594
|
+
}
|
|
1595
|
+
```
|
|
1596
|
+
|
|
1597
|
+
<a id="resolver-increment"></a>
|
|
1598
|
+
|
|
1599
|
+
### resolver.incrementConnections(serviceId)
|
|
1600
|
+
|
|
1601
|
+
Increment the active connection count for a service.
|
|
1602
|
+
|
|
1603
|
+
Options
|
|
1604
|
+
|
|
1605
|
+
- serviceId (String, required): service ID
|
|
1606
|
+
|
|
1607
|
+
Usage
|
|
1608
|
+
|
|
1609
|
+
```javascript
|
|
1610
|
+
await resolver.incrementConnections("service-id-123");
|
|
1611
|
+
```
|
|
1612
|
+
|
|
1613
|
+
<a id="resolver-decrement"></a>
|
|
1614
|
+
|
|
1615
|
+
### resolver.decrementConnections(serviceId)
|
|
1616
|
+
|
|
1617
|
+
Decrement the active connection count for a service.
|
|
1618
|
+
|
|
1619
|
+
Options
|
|
1620
|
+
|
|
1621
|
+
- serviceId (String, required): service ID
|
|
1622
|
+
|
|
1623
|
+
Usage
|
|
1624
|
+
|
|
1625
|
+
```javascript
|
|
1626
|
+
await resolver.decrementConnections("service-id-123");
|
|
1627
|
+
```
|
|
1628
|
+
|
|
1629
|
+
<a id="resolver-metrics"></a>
|
|
1630
|
+
|
|
1631
|
+
### resolver.getSelectionMetrics(serviceId)
|
|
1632
|
+
|
|
1633
|
+
Get current metrics for a specific service.
|
|
1634
|
+
|
|
1635
|
+
Options
|
|
1636
|
+
|
|
1637
|
+
- serviceId (String, required): service ID
|
|
1638
|
+
|
|
1639
|
+
Usage
|
|
1640
|
+
|
|
1641
|
+
```javascript
|
|
1642
|
+
const metrics = await resolver.getSelectionMetrics("service-id-123");
|
|
1643
|
+
```
|
|
1644
|
+
|
|
1645
|
+
Result
|
|
1646
|
+
|
|
1647
|
+
```json
|
|
1648
|
+
{
|
|
1649
|
+
"responseTime": 150,
|
|
1650
|
+
"errorRate": 0.5,
|
|
1651
|
+
"cpuUsage": 45,
|
|
1652
|
+
"memoryUsage": 60,
|
|
1653
|
+
"activeConnections": 10,
|
|
1654
|
+
"lastSelectedTime": 1234567890
|
|
1655
|
+
}
|
|
1656
|
+
```
|
|
1657
|
+
|
|
1658
|
+
<a id="resolver-refresh"></a>
|
|
1659
|
+
|
|
1660
|
+
### resolver.refresh()
|
|
1661
|
+
|
|
1662
|
+
Clear all stored metrics from Redis cache.
|
|
1663
|
+
|
|
1664
|
+
Usage
|
|
1665
|
+
|
|
1666
|
+
```javascript
|
|
1667
|
+
await resolver.refresh();
|
|
1668
|
+
```
|
|
1669
|
+
|
|
1489
1670
|
<a id="kv"></a>
|
|
1490
1671
|
|
|
1491
1672
|
### consul.kv
|
package/lib/consul.d.ts
CHANGED
|
@@ -8,6 +8,14 @@ import { Health } from "./health";
|
|
|
8
8
|
import { Intention } from "./intention";
|
|
9
9
|
import { Kv } from "./kv";
|
|
10
10
|
import { Query } from "./query";
|
|
11
|
+
import {
|
|
12
|
+
Resolver,
|
|
13
|
+
ConsulResolverConfig,
|
|
14
|
+
SelectionAlgorithm,
|
|
15
|
+
ServiceInfo,
|
|
16
|
+
OptimalServiceResult,
|
|
17
|
+
ServiceMetrics,
|
|
18
|
+
} from "./resolver";
|
|
11
19
|
import { Session } from "./session";
|
|
12
20
|
import { Status } from "./status";
|
|
13
21
|
import { Transaction } from "./transaction";
|
|
@@ -60,6 +68,7 @@ declare class Consul {
|
|
|
60
68
|
static Intention: typeof Intention;
|
|
61
69
|
static Kv: typeof Kv;
|
|
62
70
|
static Query: typeof Query;
|
|
71
|
+
static Resolver: typeof Resolver;
|
|
63
72
|
static Session: typeof Session;
|
|
64
73
|
static Status: typeof Status;
|
|
65
74
|
static Transaction: typeof Transaction;
|
|
@@ -68,6 +77,17 @@ declare class Consul {
|
|
|
68
77
|
destroy(): void;
|
|
69
78
|
|
|
70
79
|
watch(options: WatchOptions): Watch;
|
|
80
|
+
|
|
81
|
+
resolver(config: ConsulResolverConfig): Resolver;
|
|
71
82
|
}
|
|
72
83
|
|
|
84
|
+
export {
|
|
85
|
+
SelectionAlgorithm,
|
|
86
|
+
ConsulResolverConfig,
|
|
87
|
+
ServiceInfo,
|
|
88
|
+
OptimalServiceResult,
|
|
89
|
+
ServiceMetrics,
|
|
90
|
+
Resolver,
|
|
91
|
+
};
|
|
92
|
+
|
|
73
93
|
export { Consul };
|
package/lib/consul.js
CHANGED
|
@@ -8,6 +8,7 @@ const Health = require("./health").Health;
|
|
|
8
8
|
const Intention = require("./intention").Intention;
|
|
9
9
|
const Kv = require("./kv").Kv;
|
|
10
10
|
const Query = require("./query").Query;
|
|
11
|
+
const Resolver = require("./resolver").Resolver;
|
|
11
12
|
const Session = require("./session").Session;
|
|
12
13
|
const Status = require("./status").Status;
|
|
13
14
|
const Watch = require("./watch").Watch;
|
|
@@ -71,6 +72,10 @@ class Consul extends papi.Client {
|
|
|
71
72
|
return new Consul.Watch(this, opts);
|
|
72
73
|
}
|
|
73
74
|
|
|
75
|
+
resolver(config) {
|
|
76
|
+
return new Consul.Resolver(this, config);
|
|
77
|
+
}
|
|
78
|
+
|
|
74
79
|
static parseQueryMeta(res) {
|
|
75
80
|
return utils.parseQueryMeta(res);
|
|
76
81
|
}
|
|
@@ -84,6 +89,7 @@ Consul.Health = Health;
|
|
|
84
89
|
Consul.Intention = Intention;
|
|
85
90
|
Consul.Kv = Kv;
|
|
86
91
|
Consul.Query = Query;
|
|
92
|
+
Consul.Resolver = Resolver;
|
|
87
93
|
Consul.Session = Session;
|
|
88
94
|
Consul.Status = Status;
|
|
89
95
|
Consul.Transaction = Transaction;
|
package/lib/index.d.ts
CHANGED
|
@@ -1,3 +1,18 @@
|
|
|
1
|
-
import { Consul } from "./consul";
|
|
1
|
+
import { Consul, SelectionAlgorithm } from "./consul";
|
|
2
|
+
import {
|
|
3
|
+
ConsulResolverConfig,
|
|
4
|
+
ServiceInfo,
|
|
5
|
+
OptimalServiceResult,
|
|
6
|
+
ServiceMetrics,
|
|
7
|
+
Resolver,
|
|
8
|
+
} from "./resolver";
|
|
2
9
|
|
|
3
10
|
export = Consul;
|
|
11
|
+
|
|
12
|
+
export type {
|
|
13
|
+
ConsulResolverConfig,
|
|
14
|
+
ServiceInfo,
|
|
15
|
+
OptimalServiceResult,
|
|
16
|
+
ServiceMetrics,
|
|
17
|
+
};
|
|
18
|
+
export { SelectionAlgorithm, Resolver };
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
function roundRobinSelection(services, currentIndex) {
|
|
4
|
+
const healthyServices = services.filter((service) =>
|
|
5
|
+
service.Checks.every((check) => check.Status === "passing"),
|
|
6
|
+
);
|
|
7
|
+
|
|
8
|
+
if (healthyServices.length === 0) {
|
|
9
|
+
throw new Error("No healthy services available");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const service = healthyServices[currentIndex % healthyServices.length];
|
|
13
|
+
const nextIndex = (currentIndex + 1) % healthyServices.length;
|
|
14
|
+
|
|
15
|
+
return {
|
|
16
|
+
id: service.Service.ID,
|
|
17
|
+
service,
|
|
18
|
+
nextIndex,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function leastConnectionSelection(services, metrics, defaultMetrics) {
|
|
23
|
+
const healthyServices = services
|
|
24
|
+
.filter((service) =>
|
|
25
|
+
service.Checks.every((check) => check.Status === "passing"),
|
|
26
|
+
)
|
|
27
|
+
.map((service) => {
|
|
28
|
+
const serviceMetrics = metrics.get(service.Service.ID) || defaultMetrics;
|
|
29
|
+
return {
|
|
30
|
+
service,
|
|
31
|
+
connections: serviceMetrics.activeConnections || 0,
|
|
32
|
+
};
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
if (healthyServices.length === 0) {
|
|
36
|
+
throw new Error("No healthy services available");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const selectedService = healthyServices.reduce((min, current) =>
|
|
40
|
+
current.connections < min.connections ? current : min,
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
id: selectedService.service.Service.ID,
|
|
45
|
+
service: selectedService.service,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function weightedRandomSelection(rankedServices) {
|
|
50
|
+
if (rankedServices.length === 0) {
|
|
51
|
+
throw new Error("No services available for selection");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const totalScore = rankedServices.reduce(
|
|
55
|
+
(sum, service) => sum + service.score,
|
|
56
|
+
0,
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
if (totalScore <= 0) {
|
|
60
|
+
return {
|
|
61
|
+
id: rankedServices[0].id,
|
|
62
|
+
service: rankedServices[0].service,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let random = Math.random() * totalScore;
|
|
67
|
+
|
|
68
|
+
for (const service of rankedServices) {
|
|
69
|
+
random -= service.score;
|
|
70
|
+
if (random <= 0) {
|
|
71
|
+
return {
|
|
72
|
+
id: service.id,
|
|
73
|
+
service: service.service,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
id: rankedServices[0].id,
|
|
80
|
+
service: rankedServices[0].service,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function roundRobinSrvSelection(records, currentIndex) {
|
|
85
|
+
if (!records || records.length === 0) {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const selected = records[currentIndex % records.length];
|
|
90
|
+
const nextIndex = (currentIndex + 1) % records.length;
|
|
91
|
+
|
|
92
|
+
return { selected, nextIndex };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function weightedSrvRecordSelection(records, currentIndex) {
|
|
96
|
+
if (!records || records.length === 0) {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const hasNonZeroWeights = records.some((record) => (record.weight || 0) > 0);
|
|
101
|
+
|
|
102
|
+
if (!hasNonZeroWeights) {
|
|
103
|
+
return roundRobinSrvSelection(records, currentIndex);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const totalWeight = records.reduce(
|
|
107
|
+
(sum, record) => sum + (record.weight || 1),
|
|
108
|
+
0,
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
let random = Math.random() * totalWeight;
|
|
112
|
+
|
|
113
|
+
for (const record of records) {
|
|
114
|
+
random -= record.weight || 1;
|
|
115
|
+
if (random <= 0) {
|
|
116
|
+
return { selected: record, nextIndex: currentIndex };
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return { selected: records[0], nextIndex: currentIndex };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
exports.roundRobinSelection = roundRobinSelection;
|
|
124
|
+
exports.leastConnectionSelection = leastConnectionSelection;
|
|
125
|
+
exports.weightedRandomSelection = weightedRandomSelection;
|
|
126
|
+
exports.roundRobinSrvSelection = roundRobinSrvSelection;
|
|
127
|
+
exports.weightedSrvRecordSelection = weightedSrvRecordSelection;
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { log } = require("@brimble/utils");
|
|
4
|
+
const { query } = require("dns-query");
|
|
5
|
+
const {
|
|
6
|
+
roundRobinSrvSelection,
|
|
7
|
+
weightedSrvRecordSelection,
|
|
8
|
+
} = require("./algorithms");
|
|
9
|
+
const { SelectionAlgorithm } = require("./types");
|
|
10
|
+
|
|
11
|
+
class DNSManager {
|
|
12
|
+
constructor(
|
|
13
|
+
redis,
|
|
14
|
+
cachePrefix,
|
|
15
|
+
cacheTTL,
|
|
16
|
+
cacheEnabled,
|
|
17
|
+
debug,
|
|
18
|
+
dnsEndpoints,
|
|
19
|
+
dnsTimeout,
|
|
20
|
+
dnsRetries,
|
|
21
|
+
) {
|
|
22
|
+
this.redis = redis;
|
|
23
|
+
this.cachePrefix = cachePrefix;
|
|
24
|
+
this.cacheTTL = cacheTTL;
|
|
25
|
+
this.cacheEnabled = cacheEnabled;
|
|
26
|
+
this.debug = debug;
|
|
27
|
+
this.dnsEndpoints = dnsEndpoints;
|
|
28
|
+
this.dnsTimeout = dnsTimeout;
|
|
29
|
+
this.dnsRetries = dnsRetries;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
getDNSCacheKey(service) {
|
|
33
|
+
return `${this.cachePrefix}:dns:${service}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async resolveDNS(service) {
|
|
37
|
+
const cacheKey = this.getDNSCacheKey(service);
|
|
38
|
+
|
|
39
|
+
if (this.cacheEnabled) {
|
|
40
|
+
try {
|
|
41
|
+
const cachedData = await Promise.race([
|
|
42
|
+
this.redis?.get(cacheKey),
|
|
43
|
+
new Promise((_, reject) =>
|
|
44
|
+
setTimeout(() => reject(new Error("Redis get timeout")), 200),
|
|
45
|
+
),
|
|
46
|
+
]);
|
|
47
|
+
if (cachedData) {
|
|
48
|
+
if (this.debug) {
|
|
49
|
+
log.debug(`DNS cache hit for ${service}`);
|
|
50
|
+
}
|
|
51
|
+
return JSON.parse(cachedData);
|
|
52
|
+
}
|
|
53
|
+
} catch (e) {
|
|
54
|
+
if (this.debug) log.error("Redis get error or timeout:", e);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
const result = await query(
|
|
60
|
+
{
|
|
61
|
+
question: {
|
|
62
|
+
type: "SRV",
|
|
63
|
+
name: service,
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
endpoints: this.dnsEndpoints,
|
|
68
|
+
timeout: this.dnsTimeout ?? 1500,
|
|
69
|
+
retries: this.dnsRetries ?? 2,
|
|
70
|
+
},
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
if (this.debug) {
|
|
74
|
+
log.debug("DNS QUERY RESULT", result);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (!result.answers || result.answers.length === 0) {
|
|
78
|
+
if (this.debug) {
|
|
79
|
+
log.debug(`No SRV records found for ${service}`);
|
|
80
|
+
}
|
|
81
|
+
return [];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const additionalsByName = {};
|
|
85
|
+
if (result.additionals) {
|
|
86
|
+
for (const additional of result.additionals) {
|
|
87
|
+
if (additional.type === "A") {
|
|
88
|
+
additionalsByName[additional.name] = additional;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
const records = result.answers
|
|
93
|
+
.map((answer) => {
|
|
94
|
+
const target = answer.data.target;
|
|
95
|
+
const aRecord = additionalsByName[target];
|
|
96
|
+
return {
|
|
97
|
+
name: target,
|
|
98
|
+
ip: aRecord?.data || "",
|
|
99
|
+
port: answer.data.port,
|
|
100
|
+
priority: answer.data.priority,
|
|
101
|
+
weight: answer.data.weight,
|
|
102
|
+
};
|
|
103
|
+
})
|
|
104
|
+
.filter((record) => record.ip);
|
|
105
|
+
|
|
106
|
+
if (this.cacheEnabled) {
|
|
107
|
+
try {
|
|
108
|
+
await Promise.race([
|
|
109
|
+
this.redis?.set(
|
|
110
|
+
cacheKey,
|
|
111
|
+
JSON.stringify(records),
|
|
112
|
+
"EX",
|
|
113
|
+
this.cacheTTL,
|
|
114
|
+
),
|
|
115
|
+
new Promise((_, reject) =>
|
|
116
|
+
setTimeout(() => reject(new Error("Redis set timeout")), 200),
|
|
117
|
+
),
|
|
118
|
+
]);
|
|
119
|
+
} catch (e) {
|
|
120
|
+
if (this.debug) log.error("Redis set error or timeout:", e);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return records;
|
|
125
|
+
} catch (error) {
|
|
126
|
+
if (this.debug) {
|
|
127
|
+
log.error("DNS resolution error:", error);
|
|
128
|
+
}
|
|
129
|
+
return [];
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
sortByPriority(records) {
|
|
134
|
+
return [...records].sort((a, b) => (a.priority || 0) - (b.priority || 0));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
selectFromSrvRecords(records, algorithm, currentIndex) {
|
|
138
|
+
if (!records || records.length === 0) {
|
|
139
|
+
return { selected: null, nextIndex: currentIndex };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
switch (algorithm) {
|
|
143
|
+
case SelectionAlgorithm.RoundRobin: {
|
|
144
|
+
const rrResult = roundRobinSrvSelection(records, currentIndex);
|
|
145
|
+
if (!rrResult) {
|
|
146
|
+
return { selected: null, nextIndex: currentIndex };
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
selected: rrResult.selected,
|
|
150
|
+
nextIndex: rrResult.nextIndex,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
case SelectionAlgorithm.WeightedRoundRobin: {
|
|
155
|
+
const wrResult = weightedSrvRecordSelection(records, currentIndex);
|
|
156
|
+
if (!wrResult) {
|
|
157
|
+
return { selected: null, nextIndex: currentIndex };
|
|
158
|
+
}
|
|
159
|
+
return {
|
|
160
|
+
selected: wrResult.selected,
|
|
161
|
+
nextIndex: wrResult.nextIndex,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
case SelectionAlgorithm.LeastConnection: {
|
|
166
|
+
const lcResult = roundRobinSrvSelection(records, currentIndex);
|
|
167
|
+
if (!lcResult) {
|
|
168
|
+
return { selected: null, nextIndex: currentIndex };
|
|
169
|
+
}
|
|
170
|
+
return {
|
|
171
|
+
selected: lcResult.selected,
|
|
172
|
+
nextIndex: lcResult.nextIndex,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
default:
|
|
177
|
+
return { selected: records[0], nextIndex: currentIndex };
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
exports.DNSManager = DNSManager;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { log } = require("@brimble/utils");
|
|
4
|
+
|
|
5
|
+
class HealthCheckManager {
|
|
6
|
+
constructor(consul, redis, cachePrefix, cacheTTL, cacheEnabled, debug) {
|
|
7
|
+
this.consul = consul;
|
|
8
|
+
this.redis = redis;
|
|
9
|
+
this.cachePrefix = cachePrefix;
|
|
10
|
+
this.cacheTTL = cacheTTL;
|
|
11
|
+
this.cacheEnabled = cacheEnabled;
|
|
12
|
+
this.debug = debug;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
getHealthCacheKey(service) {
|
|
16
|
+
return `${this.cachePrefix}:health:${service}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async getHealthChecks(service) {
|
|
20
|
+
const cacheKey = this.getHealthCacheKey(service);
|
|
21
|
+
|
|
22
|
+
if (this.cacheEnabled) {
|
|
23
|
+
const cachedHealth = await this.redis?.get(cacheKey);
|
|
24
|
+
if (cachedHealth) {
|
|
25
|
+
return JSON.parse(cachedHealth);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
const healthChecks = await this.consul.health.service(service);
|
|
31
|
+
|
|
32
|
+
if (this.cacheEnabled) {
|
|
33
|
+
await this.redis?.set(
|
|
34
|
+
cacheKey,
|
|
35
|
+
JSON.stringify(healthChecks),
|
|
36
|
+
"EX",
|
|
37
|
+
this.cacheTTL,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return healthChecks;
|
|
42
|
+
} catch (error) {
|
|
43
|
+
if (this.debug) {
|
|
44
|
+
log.error(`Error fetching health checks for ${service}:`, error);
|
|
45
|
+
}
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
exports.HealthCheckManager = HealthCheckManager;
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { log } = require("@brimble/utils");
|
|
4
|
+
|
|
5
|
+
class MetricsManager {
|
|
6
|
+
constructor(redis, cachePrefix, metrics, cacheEnabled, debug) {
|
|
7
|
+
this.redis = redis;
|
|
8
|
+
this.cachePrefix = cachePrefix;
|
|
9
|
+
this.metrics = metrics;
|
|
10
|
+
this.cacheEnabled = cacheEnabled;
|
|
11
|
+
this.debug = debug;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
getConnectionKey(serviceId) {
|
|
15
|
+
return `${this.cachePrefix}:connections:${serviceId}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async getServicesMetrics(services) {
|
|
19
|
+
const metricsMap = new Map();
|
|
20
|
+
const pipeline = this.redis?.pipeline();
|
|
21
|
+
|
|
22
|
+
if (this.cacheEnabled) {
|
|
23
|
+
services.forEach((service) => {
|
|
24
|
+
pipeline?.get(service.Service.ID);
|
|
25
|
+
pipeline?.get(this.getConnectionKey(service.Service.ID));
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
const results = await pipeline?.exec();
|
|
31
|
+
if (!results) {
|
|
32
|
+
services.forEach((service) => {
|
|
33
|
+
metricsMap.set(service.Service.ID, { ...this.metrics });
|
|
34
|
+
});
|
|
35
|
+
return metricsMap;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
services.forEach((service, index) => {
|
|
39
|
+
const serviceId = service.Service.ID;
|
|
40
|
+
const metricsResult = results[index * 2];
|
|
41
|
+
const connectionsResult = results[index * 2 + 1];
|
|
42
|
+
|
|
43
|
+
let metrics;
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
if (metricsResult?.[1]) {
|
|
47
|
+
metrics = JSON.parse(metricsResult[1]);
|
|
48
|
+
} else {
|
|
49
|
+
metrics = { ...this.metrics };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (connectionsResult?.[1]) {
|
|
53
|
+
const connData = JSON.parse(connectionsResult[1]);
|
|
54
|
+
metrics.activeConnections = connData.activeConnections || 0;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
metricsMap.set(serviceId, metrics);
|
|
58
|
+
} catch (error) {
|
|
59
|
+
if (this.debug) {
|
|
60
|
+
log.error(
|
|
61
|
+
`Error processing metrics for service ${serviceId}:`,
|
|
62
|
+
error,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
metricsMap.set(serviceId, { ...this.metrics });
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
} catch (error) {
|
|
69
|
+
if (this.debug) {
|
|
70
|
+
log.error("Error executing Redis pipeline:", error);
|
|
71
|
+
}
|
|
72
|
+
services.forEach((service) => {
|
|
73
|
+
metricsMap.set(service.Service.ID, { ...this.metrics });
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return metricsMap;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async incrementConnections(serviceId) {
|
|
81
|
+
try {
|
|
82
|
+
const connectionKey = this.getConnectionKey(serviceId);
|
|
83
|
+
const existingMetrics = await this.redis?.get(connectionKey);
|
|
84
|
+
let metrics;
|
|
85
|
+
|
|
86
|
+
if (existingMetrics) {
|
|
87
|
+
metrics = JSON.parse(existingMetrics);
|
|
88
|
+
metrics.activeConnections = (metrics.activeConnections || 0) + 1;
|
|
89
|
+
} else {
|
|
90
|
+
metrics = {
|
|
91
|
+
...this.metrics,
|
|
92
|
+
activeConnections: 1,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (this.cacheEnabled) {
|
|
97
|
+
await this.redis?.set(
|
|
98
|
+
connectionKey,
|
|
99
|
+
JSON.stringify(metrics),
|
|
100
|
+
"EX",
|
|
101
|
+
24 * 60 * 60,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
} catch (error) {
|
|
105
|
+
if (this.debug) {
|
|
106
|
+
log.error(
|
|
107
|
+
`Failed to increment connections for service ${serviceId}:`,
|
|
108
|
+
error,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async decrementConnections(serviceId) {
|
|
115
|
+
try {
|
|
116
|
+
const connectionKey = this.getConnectionKey(serviceId);
|
|
117
|
+
const existingMetrics = await this.redis?.get(connectionKey);
|
|
118
|
+
let metrics;
|
|
119
|
+
|
|
120
|
+
if (existingMetrics) {
|
|
121
|
+
metrics = JSON.parse(existingMetrics);
|
|
122
|
+
metrics.activeConnections = Math.max(
|
|
123
|
+
0,
|
|
124
|
+
(metrics.activeConnections || 1) - 1,
|
|
125
|
+
);
|
|
126
|
+
} else {
|
|
127
|
+
metrics = {
|
|
128
|
+
...this.metrics,
|
|
129
|
+
activeConnections: 0,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (this.cacheEnabled) {
|
|
134
|
+
await this.redis?.set(
|
|
135
|
+
connectionKey,
|
|
136
|
+
JSON.stringify(metrics),
|
|
137
|
+
"EX",
|
|
138
|
+
24 * 60 * 60,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
} catch (error) {
|
|
142
|
+
if (this.debug) {
|
|
143
|
+
log.error(
|
|
144
|
+
`Failed to decrement connections for service ${serviceId}:`,
|
|
145
|
+
error,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async updateSelectionMetrics(serviceId) {
|
|
152
|
+
try {
|
|
153
|
+
const connectionKey = this.getConnectionKey(serviceId);
|
|
154
|
+
const existingMetrics = await this.redis?.get(connectionKey);
|
|
155
|
+
let metrics;
|
|
156
|
+
|
|
157
|
+
if (existingMetrics) {
|
|
158
|
+
metrics = JSON.parse(existingMetrics);
|
|
159
|
+
} else {
|
|
160
|
+
metrics = { ...this.metrics };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
metrics.lastSelectedTime = Date.now();
|
|
164
|
+
|
|
165
|
+
if (this.cacheEnabled) {
|
|
166
|
+
await this.redis?.set(
|
|
167
|
+
connectionKey,
|
|
168
|
+
JSON.stringify(metrics),
|
|
169
|
+
"EX",
|
|
170
|
+
24 * 60 * 60,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
} catch (error) {
|
|
174
|
+
if (this.debug) {
|
|
175
|
+
log.error(
|
|
176
|
+
`Failed to update selection metrics for service ${serviceId}:`,
|
|
177
|
+
error,
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async getSelectionMetrics(serviceId) {
|
|
184
|
+
try {
|
|
185
|
+
if (!this.cacheEnabled) {
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
const metrics = await this.redis?.get(this.getConnectionKey(serviceId));
|
|
189
|
+
return metrics ? JSON.parse(metrics) : null;
|
|
190
|
+
} catch (error) {
|
|
191
|
+
if (this.debug) {
|
|
192
|
+
log.error("Error getting service metrics:", error);
|
|
193
|
+
}
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
exports.MetricsManager = MetricsManager;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { DEFAULT_WEIGHTS } = require("./types");
|
|
4
|
+
|
|
5
|
+
function calculateHealthScore(service) {
|
|
6
|
+
const checks = service.Checks;
|
|
7
|
+
const totalChecks = checks.length;
|
|
8
|
+
if (totalChecks === 0) return 0;
|
|
9
|
+
|
|
10
|
+
const passingChecks = checks.filter(
|
|
11
|
+
(check) => check.Status === "passing",
|
|
12
|
+
).length;
|
|
13
|
+
return passingChecks / totalChecks;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function normalizeScore(value, max, inverse = false) {
|
|
17
|
+
const normalized = Math.max(0, Math.min(1, value / max));
|
|
18
|
+
return inverse ? 1 - normalized : normalized;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function calculateResourceScore(metrics) {
|
|
22
|
+
const cpuScore = normalizeScore(metrics.cpuUsage, 100, true);
|
|
23
|
+
const memoryScore = normalizeScore(metrics.memoryUsage, 100, true);
|
|
24
|
+
return (cpuScore + memoryScore) / 2;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function calculateDistributionScore(lastSelectedTime) {
|
|
28
|
+
if (!lastSelectedTime) return 1;
|
|
29
|
+
|
|
30
|
+
const timeSinceLastSelection = Date.now() - lastSelectedTime;
|
|
31
|
+
|
|
32
|
+
return Math.min(timeSinceLastSelection / (5 * 60 * 1000), 1);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function rankServices(services, metrics, weights = DEFAULT_WEIGHTS) {
|
|
36
|
+
return services
|
|
37
|
+
.map((service) => {
|
|
38
|
+
const serviceId = service.Service.ID;
|
|
39
|
+
|
|
40
|
+
const serviceMetrics = metrics.get(serviceId);
|
|
41
|
+
|
|
42
|
+
if (!serviceMetrics) {
|
|
43
|
+
throw new Error(`No metrics found for service ${serviceId}`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const healthScore = calculateHealthScore(service);
|
|
47
|
+
const responseTimeScore = normalizeScore(
|
|
48
|
+
serviceMetrics.responseTime,
|
|
49
|
+
500,
|
|
50
|
+
true,
|
|
51
|
+
);
|
|
52
|
+
const errorRateScore = normalizeScore(
|
|
53
|
+
serviceMetrics.errorRate,
|
|
54
|
+
100,
|
|
55
|
+
true,
|
|
56
|
+
);
|
|
57
|
+
const resourceScore = calculateResourceScore(serviceMetrics);
|
|
58
|
+
const connectionScore = normalizeScore(
|
|
59
|
+
serviceMetrics.activeConnections,
|
|
60
|
+
1000,
|
|
61
|
+
true,
|
|
62
|
+
);
|
|
63
|
+
const distributionScore = calculateDistributionScore(
|
|
64
|
+
serviceMetrics.lastSelectedTime,
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
const totalScore =
|
|
68
|
+
healthScore * weights.health +
|
|
69
|
+
responseTimeScore * weights.responseTime +
|
|
70
|
+
errorRateScore * weights.errorRate +
|
|
71
|
+
resourceScore * weights.resources +
|
|
72
|
+
connectionScore * weights.connections +
|
|
73
|
+
distributionScore * weights.distribution;
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
score: totalScore,
|
|
77
|
+
id: serviceId,
|
|
78
|
+
service,
|
|
79
|
+
};
|
|
80
|
+
})
|
|
81
|
+
.sort((a, b) => b.score - a.score);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function combineHealthAndDNSWeights(service, dnsWeight, maxDNSWeight) {
|
|
85
|
+
const healthScore = calculateHealthScore(service);
|
|
86
|
+
const normalizedDNSWeight = dnsWeight / maxDNSWeight;
|
|
87
|
+
return healthScore * 0.7 + normalizedDNSWeight * 0.3;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
exports.calculateHealthScore = calculateHealthScore;
|
|
91
|
+
exports.calculateResourceScore = calculateResourceScore;
|
|
92
|
+
exports.calculateDistributionScore = calculateDistributionScore;
|
|
93
|
+
exports.normalizeScore = normalizeScore;
|
|
94
|
+
exports.rankServices = rankServices;
|
|
95
|
+
exports.combineHealthAndDNSWeights = combineHealthAndDNSWeights;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const DEFAULT_WEIGHTS = {
|
|
4
|
+
health: 0.25,
|
|
5
|
+
responseTime: 0.2,
|
|
6
|
+
errorRate: 0.2,
|
|
7
|
+
resources: 0.15,
|
|
8
|
+
connections: 0.1,
|
|
9
|
+
distribution: 0.1,
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const DEFAULT_METRICS = {
|
|
13
|
+
responseTime: 100,
|
|
14
|
+
errorRate: 0,
|
|
15
|
+
cpuUsage: 50,
|
|
16
|
+
memoryUsage: 50,
|
|
17
|
+
activeConnections: 0,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const SelectionAlgorithm = {
|
|
21
|
+
RoundRobin: "round-robin",
|
|
22
|
+
LeastConnection: "least-connection",
|
|
23
|
+
WeightedRoundRobin: "weighted-round-robin",
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
exports.DEFAULT_WEIGHTS = DEFAULT_WEIGHTS;
|
|
27
|
+
exports.DEFAULT_METRICS = DEFAULT_METRICS;
|
|
28
|
+
exports.SelectionAlgorithm = SelectionAlgorithm;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { Agent as httpAgent } from "http";
|
|
2
|
+
import { Agent as httpsAgent } from "https";
|
|
3
|
+
import { Redis as IORedis } from "ioredis";
|
|
4
|
+
import { Consul } from "./consul";
|
|
5
|
+
|
|
6
|
+
export interface ConsulResolverConfig {
|
|
7
|
+
redis?: IORedis;
|
|
8
|
+
cacheEnabled: boolean;
|
|
9
|
+
cachePrefix: string;
|
|
10
|
+
debug?: boolean;
|
|
11
|
+
weights?: {
|
|
12
|
+
health: number;
|
|
13
|
+
responseTime: number;
|
|
14
|
+
errorRate: number;
|
|
15
|
+
resources: number;
|
|
16
|
+
connections: number;
|
|
17
|
+
distribution: number;
|
|
18
|
+
};
|
|
19
|
+
metrics?: {
|
|
20
|
+
responseTime: number;
|
|
21
|
+
errorRate: number;
|
|
22
|
+
cpuUsage: number;
|
|
23
|
+
memoryUsage: number;
|
|
24
|
+
activeConnections: number;
|
|
25
|
+
};
|
|
26
|
+
cacheTTL?: number;
|
|
27
|
+
dnsEndpoints?: string[];
|
|
28
|
+
dnsTimeout?: number;
|
|
29
|
+
dnsRetries?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ServiceInfo {
|
|
33
|
+
ip: string;
|
|
34
|
+
port: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface OptimalServiceResult {
|
|
38
|
+
selected: ServiceInfo | null;
|
|
39
|
+
services: ServiceInfo[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ServiceMetrics {
|
|
43
|
+
responseTime: number;
|
|
44
|
+
errorRate: number;
|
|
45
|
+
cpuUsage: number;
|
|
46
|
+
memoryUsage: number;
|
|
47
|
+
activeConnections: number;
|
|
48
|
+
lastSelectedTime?: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export enum SelectionAlgorithm {
|
|
52
|
+
RoundRobin = "round-robin",
|
|
53
|
+
LeastConnection = "least-connection",
|
|
54
|
+
WeightedRoundRobin = "weighted-round-robin",
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
declare class Resolver {
|
|
58
|
+
constructor(consul: Consul, config: ConsulResolverConfig);
|
|
59
|
+
|
|
60
|
+
consul: Consul;
|
|
61
|
+
|
|
62
|
+
selectOptimalService(
|
|
63
|
+
service: string,
|
|
64
|
+
algorithm?: SelectionAlgorithm,
|
|
65
|
+
): Promise<OptimalServiceResult>;
|
|
66
|
+
|
|
67
|
+
incrementConnections(serviceId: string): Promise<void>;
|
|
68
|
+
|
|
69
|
+
decrementConnections(serviceId: string): Promise<void>;
|
|
70
|
+
|
|
71
|
+
getSelectionMetrics(serviceId: string): Promise<ServiceMetrics | null>;
|
|
72
|
+
|
|
73
|
+
refresh(): Promise<void>;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export { Resolver };
|
package/lib/resolver.js
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { log } = require("@brimble/utils");
|
|
4
|
+
const {
|
|
5
|
+
leastConnectionSelection,
|
|
6
|
+
roundRobinSelection,
|
|
7
|
+
weightedRandomSelection,
|
|
8
|
+
} = require("./resolver/algorithms");
|
|
9
|
+
const { DNSManager } = require("./resolver/dns");
|
|
10
|
+
const { HealthCheckManager } = require("./resolver/health");
|
|
11
|
+
const { MetricsManager } = require("./resolver/metrics");
|
|
12
|
+
const {
|
|
13
|
+
combineHealthAndDNSWeights,
|
|
14
|
+
rankServices,
|
|
15
|
+
} = require("./resolver/scoring");
|
|
16
|
+
const {
|
|
17
|
+
DEFAULT_METRICS,
|
|
18
|
+
DEFAULT_WEIGHTS,
|
|
19
|
+
SelectionAlgorithm,
|
|
20
|
+
} = require("./resolver/types");
|
|
21
|
+
|
|
22
|
+
class Resolver {
|
|
23
|
+
constructor(consul, config) {
|
|
24
|
+
this.consul = consul;
|
|
25
|
+
this.currentIndex = 0;
|
|
26
|
+
this.debug = config.debug || false;
|
|
27
|
+
this.cachePrefix = config.cachePrefix;
|
|
28
|
+
this.cacheEnabled = config.cacheEnabled;
|
|
29
|
+
this.weights = config.weights || DEFAULT_WEIGHTS;
|
|
30
|
+
this.metrics = config.metrics || DEFAULT_METRICS;
|
|
31
|
+
this.redis = config.redis;
|
|
32
|
+
|
|
33
|
+
this.cacheTTL = Math.floor((config.cacheTTL || 60 * 1000) / 1000);
|
|
34
|
+
|
|
35
|
+
if (this.cacheEnabled && config.redis) {
|
|
36
|
+
this.redis = config.redis;
|
|
37
|
+
this.cacheEnabled = true;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
this.metricsManager = new MetricsManager(
|
|
41
|
+
this.redis,
|
|
42
|
+
this.cachePrefix,
|
|
43
|
+
this.metrics,
|
|
44
|
+
this.cacheEnabled,
|
|
45
|
+
this.debug,
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
this.dnsManager = new DNSManager(
|
|
49
|
+
this.redis,
|
|
50
|
+
this.cachePrefix,
|
|
51
|
+
this.cacheTTL,
|
|
52
|
+
this.cacheEnabled,
|
|
53
|
+
this.debug,
|
|
54
|
+
config.dnsEndpoints,
|
|
55
|
+
config.dnsTimeout,
|
|
56
|
+
config.dnsRetries,
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
this.healthCheckManager = new HealthCheckManager(
|
|
60
|
+
this.consul,
|
|
61
|
+
this.redis,
|
|
62
|
+
this.cachePrefix,
|
|
63
|
+
this.cacheTTL,
|
|
64
|
+
this.cacheEnabled,
|
|
65
|
+
this.debug,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Select the optimal service based on the specified algorithm
|
|
71
|
+
*/
|
|
72
|
+
async selectOptimalService(
|
|
73
|
+
service,
|
|
74
|
+
algorithm = SelectionAlgorithm.RoundRobin,
|
|
75
|
+
) {
|
|
76
|
+
try {
|
|
77
|
+
const [healthChecks, dnsRecords] = await Promise.all([
|
|
78
|
+
this.healthCheckManager.getHealthChecks(service),
|
|
79
|
+
this.dnsManager.resolveDNS(service),
|
|
80
|
+
]);
|
|
81
|
+
|
|
82
|
+
if (
|
|
83
|
+
(!healthChecks || healthChecks.length === 0) &&
|
|
84
|
+
dnsRecords.length === 0
|
|
85
|
+
) {
|
|
86
|
+
return { selected: null, services: [] };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const sortedByPriority = this.dnsManager.sortByPriority(dnsRecords);
|
|
90
|
+
|
|
91
|
+
const lowestPriorityValue = sortedByPriority[0]?.priority;
|
|
92
|
+
const highestPriorityRecords = sortedByPriority.filter(
|
|
93
|
+
(record) => record.priority === lowestPriorityValue,
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
if (!healthChecks || healthChecks.length === 0) {
|
|
97
|
+
const { selected, nextIndex } = this.dnsManager.selectFromSrvRecords(
|
|
98
|
+
highestPriorityRecords,
|
|
99
|
+
algorithm,
|
|
100
|
+
this.currentIndex,
|
|
101
|
+
);
|
|
102
|
+
this.currentIndex = nextIndex;
|
|
103
|
+
|
|
104
|
+
if (!selected) {
|
|
105
|
+
return { selected: null, services: [] };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
await this.metricsManager.updateSelectionMetrics(selected.name);
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
selected: {
|
|
112
|
+
ip: selected.ip,
|
|
113
|
+
port: selected.port,
|
|
114
|
+
},
|
|
115
|
+
services: sortedByPriority.map((record) => ({
|
|
116
|
+
ip: record.ip,
|
|
117
|
+
port: record.port,
|
|
118
|
+
})),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const dnsWeights = new Map(
|
|
123
|
+
dnsRecords.map((record) => [
|
|
124
|
+
record.ip,
|
|
125
|
+
{
|
|
126
|
+
weight: record.weight,
|
|
127
|
+
port: record.port,
|
|
128
|
+
priority: record.priority,
|
|
129
|
+
},
|
|
130
|
+
]),
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
const matchedHealthChecks = healthChecks.filter((check) =>
|
|
134
|
+
dnsWeights.has(check.Service.Address),
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
if (matchedHealthChecks.length === 0) {
|
|
138
|
+
if (this.debug) {
|
|
139
|
+
log.debug(
|
|
140
|
+
"No matching services found between DNS and Consul health checks",
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
const { selected, nextIndex } = this.dnsManager.selectFromSrvRecords(
|
|
144
|
+
highestPriorityRecords,
|
|
145
|
+
algorithm,
|
|
146
|
+
this.currentIndex,
|
|
147
|
+
);
|
|
148
|
+
this.currentIndex = nextIndex;
|
|
149
|
+
|
|
150
|
+
if (!selected) {
|
|
151
|
+
return { selected: null, services: [] };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
await this.metricsManager.updateSelectionMetrics(selected.name);
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
selected: {
|
|
158
|
+
ip: selected.ip,
|
|
159
|
+
port: selected.port,
|
|
160
|
+
},
|
|
161
|
+
services: sortedByPriority.map((record) => ({
|
|
162
|
+
ip: record.ip,
|
|
163
|
+
port: record.port,
|
|
164
|
+
})),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const highPriorityIPs = new Set(
|
|
169
|
+
highestPriorityRecords.map((record) => record.ip),
|
|
170
|
+
);
|
|
171
|
+
const highPriorityHealthChecks = matchedHealthChecks.filter((check) =>
|
|
172
|
+
highPriorityIPs.has(check.Service.Address),
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
const targetHealthChecks =
|
|
176
|
+
highPriorityHealthChecks.length > 0
|
|
177
|
+
? highPriorityHealthChecks
|
|
178
|
+
: matchedHealthChecks;
|
|
179
|
+
|
|
180
|
+
const maxDNSWeight = Math.max(...dnsRecords.map((r) => r.weight || 1));
|
|
181
|
+
|
|
182
|
+
const enhancedHealthChecks = targetHealthChecks.map((check) => ({
|
|
183
|
+
...check,
|
|
184
|
+
dnsWeight: combineHealthAndDNSWeights(
|
|
185
|
+
check,
|
|
186
|
+
dnsWeights.get(check.Service.Address)?.weight || 0,
|
|
187
|
+
maxDNSWeight,
|
|
188
|
+
),
|
|
189
|
+
}));
|
|
190
|
+
|
|
191
|
+
const metrics =
|
|
192
|
+
await this.metricsManager.getServicesMetrics(targetHealthChecks);
|
|
193
|
+
let selectedService;
|
|
194
|
+
|
|
195
|
+
switch (algorithm) {
|
|
196
|
+
case SelectionAlgorithm.RoundRobin: {
|
|
197
|
+
const rrResult = roundRobinSelection(
|
|
198
|
+
enhancedHealthChecks,
|
|
199
|
+
this.currentIndex,
|
|
200
|
+
);
|
|
201
|
+
this.currentIndex = rrResult.nextIndex;
|
|
202
|
+
selectedService = { id: rrResult.id, service: rrResult.service };
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
205
|
+
case SelectionAlgorithm.LeastConnection:
|
|
206
|
+
selectedService = leastConnectionSelection(
|
|
207
|
+
enhancedHealthChecks,
|
|
208
|
+
metrics,
|
|
209
|
+
this.metrics,
|
|
210
|
+
);
|
|
211
|
+
break;
|
|
212
|
+
case SelectionAlgorithm.WeightedRoundRobin: {
|
|
213
|
+
const rankedServices = rankServices(
|
|
214
|
+
enhancedHealthChecks,
|
|
215
|
+
metrics,
|
|
216
|
+
this.weights,
|
|
217
|
+
);
|
|
218
|
+
rankedServices.forEach((ranked) => {
|
|
219
|
+
const dnsInfo = dnsWeights.get(ranked.service.Service.Address);
|
|
220
|
+
if (dnsInfo) {
|
|
221
|
+
ranked.score *= 1 + dnsInfo.weight / maxDNSWeight;
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
selectedService = weightedRandomSelection(rankedServices);
|
|
225
|
+
break;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
await this.metricsManager.updateSelectionMetrics(selectedService.id);
|
|
230
|
+
|
|
231
|
+
const selectedDNSInfo = dnsWeights.get(
|
|
232
|
+
selectedService.service.Service.Address,
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
selected: {
|
|
237
|
+
ip: selectedService.service.Service.Address,
|
|
238
|
+
port: selectedDNSInfo?.port || selectedService.service.Service.Port,
|
|
239
|
+
},
|
|
240
|
+
services: matchedHealthChecks.map((check) => {
|
|
241
|
+
const dnsInfo = dnsWeights.get(check.Service.Address);
|
|
242
|
+
return {
|
|
243
|
+
ip: check.Service.Address,
|
|
244
|
+
port: dnsInfo?.port || check.Service.Port,
|
|
245
|
+
};
|
|
246
|
+
}),
|
|
247
|
+
};
|
|
248
|
+
} catch (error) {
|
|
249
|
+
if (this.debug) {
|
|
250
|
+
log.error("Error selecting optimal service:", error);
|
|
251
|
+
}
|
|
252
|
+
return { selected: null, services: [] };
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async incrementConnections(serviceId) {
|
|
257
|
+
return this.metricsManager.incrementConnections(serviceId);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async decrementConnections(serviceId) {
|
|
261
|
+
return this.metricsManager.decrementConnections(serviceId);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async getSelectionMetrics(serviceId) {
|
|
265
|
+
return this.metricsManager.getSelectionMetrics(serviceId);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async refresh() {
|
|
269
|
+
try {
|
|
270
|
+
if (!this.cacheEnabled) {
|
|
271
|
+
if (this.debug) {
|
|
272
|
+
log.debug("Cache is disabled, no need to refresh");
|
|
273
|
+
}
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
const pattern = `${this.cachePrefix}:*`;
|
|
277
|
+
const keys = await this.redis?.keys(pattern);
|
|
278
|
+
|
|
279
|
+
if (keys && keys.length > 0) {
|
|
280
|
+
await this.redis?.del(...keys);
|
|
281
|
+
}
|
|
282
|
+
} catch (error) {
|
|
283
|
+
console.log("Error refreshing Redis caches:", error);
|
|
284
|
+
throw new Error(`Failed to refresh caches: ${error.message}`);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
exports.Resolver = Resolver;
|
|
290
|
+
exports.SelectionAlgorithm = SelectionAlgorithm;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@brimble/consul",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.1",
|
|
4
4
|
"description": "Consul client",
|
|
5
5
|
"main": "./lib",
|
|
6
6
|
"types": "./lib/index.d.ts",
|
|
@@ -8,12 +8,17 @@
|
|
|
8
8
|
"./lib"
|
|
9
9
|
],
|
|
10
10
|
"dependencies": {
|
|
11
|
+
"@brimble/utils": "^1.5.59",
|
|
12
|
+
"dns-query": "^0.11.2",
|
|
13
|
+
"ioredis": "^5.3.2",
|
|
14
|
+
"lru-cache": "^11.1.0",
|
|
11
15
|
"papi": "^1.1.0"
|
|
12
16
|
},
|
|
13
17
|
"devDependencies": {
|
|
14
18
|
"@types/node": "^22.7.6",
|
|
15
19
|
"async": "^3.2.0",
|
|
16
20
|
"debug": "^4.3.1",
|
|
21
|
+
"ioredis-mock": "^8.9.0",
|
|
17
22
|
"jshint": "^2.5.5",
|
|
18
23
|
"mocha": "^10.7.3",
|
|
19
24
|
"nock": "^13.0.7",
|