latinfo 0.16.0 → 0.18.0
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/dist/index.js +331 -76
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -47,7 +47,7 @@ const local_search_1 = require("./local-search");
|
|
|
47
47
|
const client_search_1 = require("./client-search");
|
|
48
48
|
const odis_search_1 = require("./odis-search");
|
|
49
49
|
const mphf_search_1 = require("./mphf-search");
|
|
50
|
-
const VERSION = '0.
|
|
50
|
+
const VERSION = '0.18.0';
|
|
51
51
|
const API_URL = process.env.LATINFO_API_URL || 'https://api.latinfo.dev';
|
|
52
52
|
const GITHUB_CLIENT_ID = process.env.GITHUB_CLIENT_ID || 'Ov23li5fcQaiCsVtaMKK';
|
|
53
53
|
const CONFIG_DIR = path_1.default.join(os_1.default.homedir(), '.latinfo');
|
|
@@ -748,39 +748,171 @@ function costsSimulate(usersStr, rpmStr, proPctStr) {
|
|
|
748
748
|
printCosts({ users, pro_users: proUsers, requests, cf_tier: cfTier, cf_cost: cfCost, revenue, margin, safe: margin >= 0 });
|
|
749
749
|
}
|
|
750
750
|
// --- Bench ---
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
'
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
751
|
+
// --- Search server status ---
|
|
752
|
+
async function searchServerStatus() {
|
|
753
|
+
const { execSync } = await Promise.resolve().then(() => __importStar(require('child_process')));
|
|
754
|
+
const RUNNER = 'f3mt0@100.109.82.87';
|
|
755
|
+
// Test SSH
|
|
756
|
+
try {
|
|
757
|
+
execSync(`ssh -o ConnectTimeout=5 ${RUNNER} "echo OK"`, { stdio: 'pipe' });
|
|
758
|
+
}
|
|
759
|
+
catch {
|
|
760
|
+
console.error(' Linux Mint not reachable. Is it on?');
|
|
761
|
+
process.exit(1);
|
|
762
|
+
}
|
|
763
|
+
// Check health
|
|
764
|
+
try {
|
|
765
|
+
const health = execSync(`ssh ${RUNNER} "curl -s http://localhost:3001/health"`, { encoding: 'utf-8', stdio: 'pipe' }).trim();
|
|
766
|
+
const data = JSON.parse(health);
|
|
767
|
+
if (data.status === 'ok') {
|
|
768
|
+
console.log(`\n Search server: READY`);
|
|
769
|
+
console.log(` Mode: ${data.mode || 'ram'}`);
|
|
770
|
+
console.log(` Sources: ${data.sources.join(', ')}`);
|
|
771
|
+
console.log(` Disk shards: ${data.diskShards || 0}`);
|
|
772
|
+
console.log(` RAM shards: ${data.ramShards || 0}`);
|
|
773
|
+
console.log(` RAM: ${data.ramMB || '?'} MB`);
|
|
774
|
+
// Quick search test
|
|
775
|
+
const t0 = Date.now();
|
|
776
|
+
const testResult = execSync(`ssh ${RUNNER} "curl -s 'http://localhost:3001/search?source=${data.sources[0]}&q=banco'"`, { encoding: 'utf-8', stdio: 'pipe' }).trim();
|
|
777
|
+
const testData = JSON.parse(testResult);
|
|
778
|
+
console.log(`\n Search test (${data.sources[0]}): ${testData.results?.length || 0} results in ${testData.ms}ms`);
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
catch { }
|
|
783
|
+
// Not ready — show download progress
|
|
784
|
+
console.log(`\n Search server: LOADING\n`);
|
|
785
|
+
const diskUsage = execSync(`ssh ${RUNNER} "du -sm /tmp/latinfo-search-data/ 2>/dev/null | awk '{print \\$1}'"`, { encoding: 'utf-8', stdio: 'pipe' }).trim();
|
|
786
|
+
const currentMB = parseInt(diskUsage) || 0;
|
|
787
|
+
const totalExpected = 6400;
|
|
788
|
+
const pct = Math.min(Math.floor(currentMB / totalExpected * 100), 99);
|
|
789
|
+
const filled = Math.floor(pct * 30 / 100);
|
|
790
|
+
const bar = '█'.repeat(filled) + '░'.repeat(30 - filled);
|
|
791
|
+
console.log(` [${bar}] ${currentMB}/${totalExpected} MB (${pct}%)`);
|
|
792
|
+
// Show last shard activity
|
|
793
|
+
const lastLog = execSync(`ssh ${RUNNER} "sudo journalctl -u latinfo-search --no-pager -n 5 2>/dev/null | grep -oE '(Shard [0-9]+|Loading [^ ]+)' | tail -1"`, { encoding: 'utf-8', stdio: 'pipe' }).trim();
|
|
794
|
+
if (lastLog)
|
|
795
|
+
console.log(` Last: ${lastLog}`);
|
|
796
|
+
// Show RAM
|
|
797
|
+
const ram = execSync(`ssh ${RUNNER} "free -h | awk '/Mem:/{print \\$3\"/\"\\$2}'"`, { encoding: 'utf-8', stdio: 'pipe' }).trim();
|
|
798
|
+
console.log(` RAM: ${ram}`);
|
|
799
|
+
}
|
|
800
|
+
// Seed queries used to discover real data from the API
|
|
801
|
+
const SEED_QUERIES = ['banco', 'empresa', 'servicios', 'comercial', 'grupo'];
|
|
802
|
+
function discoverBenchSources(filterSource) {
|
|
803
|
+
const repo = getRepoPath();
|
|
804
|
+
const sourcesDir = path_1.default.join(repo, 'sources');
|
|
805
|
+
if (!fs_1.default.existsSync(sourcesDir))
|
|
806
|
+
return [];
|
|
807
|
+
const yamls = fs_1.default.readdirSync(sourcesDir).filter(f => f.endsWith('.yaml'));
|
|
808
|
+
const sources = [];
|
|
809
|
+
for (const file of yamls) {
|
|
810
|
+
const content = fs_1.default.readFileSync(path_1.default.join(sourcesDir, file), 'utf-8');
|
|
811
|
+
const nameMatch = content.match(/^name:\s*(.+)/m);
|
|
812
|
+
const countryMatch = content.match(/^country:\s*(.+)/m);
|
|
813
|
+
const institutionMatch = content.match(/^institution:\s*(.+)/m);
|
|
814
|
+
const datasetMatch = content.match(/^dataset:\s*(.+)/m);
|
|
815
|
+
const idNameMatch = content.match(/primary_id:\s*\n\s*name:\s*(.+)/);
|
|
816
|
+
const idLenMatch = content.match(/primary_id:[\s\S]*?length:\s*(\d+)/);
|
|
817
|
+
const smokeIdMatch = content.match(/smoke_test:\s*\n\s*id:\s*"?([^"\n]+)"?/);
|
|
818
|
+
if (!nameMatch || !countryMatch || !institutionMatch || !datasetMatch)
|
|
819
|
+
continue;
|
|
820
|
+
const name = nameMatch[1].trim();
|
|
821
|
+
if (filterSource && name !== filterSource)
|
|
822
|
+
continue;
|
|
823
|
+
const routePath = `/${countryMatch[1].trim()}/${institutionMatch[1].trim()}/${datasetMatch[1].trim()}`;
|
|
824
|
+
const primaryId = {
|
|
825
|
+
name: idNameMatch ? idNameMatch[1].trim() : 'id',
|
|
826
|
+
length: idLenMatch ? parseInt(idLenMatch[1]) : 10,
|
|
827
|
+
};
|
|
828
|
+
const smokeId = smokeIdMatch ? smokeIdMatch[1].trim() : undefined;
|
|
829
|
+
const lookupIds = [];
|
|
830
|
+
if (smokeId)
|
|
831
|
+
lookupIds.push(smokeId);
|
|
832
|
+
sources.push({ name, routePath, primaryId, smokeId, lookupIds, searchQueries: [] });
|
|
833
|
+
}
|
|
834
|
+
return sources;
|
|
835
|
+
}
|
|
836
|
+
/** Generate a non-existent ID based on the primary ID pattern */
|
|
837
|
+
function generateFakeId(length) {
|
|
838
|
+
// All 9s — unlikely to be a real ID
|
|
839
|
+
return '9'.repeat(length);
|
|
840
|
+
}
|
|
841
|
+
/** Seed lookup IDs + search queries from real API data */
|
|
842
|
+
async function seedBenchQueries(source, apiKey) {
|
|
843
|
+
const headers = { Authorization: `Bearer ${apiKey}` };
|
|
844
|
+
const names = [];
|
|
845
|
+
// Fetch real records via search
|
|
846
|
+
for (const seed of SEED_QUERIES) {
|
|
847
|
+
try {
|
|
848
|
+
const res = await fetch(`${API_URL}${source.routePath}/search?q=${encodeURIComponent(seed)}&limit=20`, {
|
|
849
|
+
headers, signal: AbortSignal.timeout(10_000),
|
|
850
|
+
});
|
|
851
|
+
if (!res.ok)
|
|
852
|
+
continue;
|
|
853
|
+
const data = await res.json();
|
|
854
|
+
for (const r of data) {
|
|
855
|
+
const id = r.id || r[source.primaryId.name];
|
|
856
|
+
if (id && !source.lookupIds.includes(String(id)))
|
|
857
|
+
source.lookupIds.push(String(id));
|
|
858
|
+
// Extract the search field (first string field that isn't the ID)
|
|
859
|
+
const name = Object.values(r).find((v, i) => typeof v === 'string' && i > 0);
|
|
860
|
+
if (name && name.length > 3)
|
|
861
|
+
names.push(name);
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
catch { }
|
|
865
|
+
if (source.lookupIds.length >= 20 && names.length >= 20)
|
|
866
|
+
break;
|
|
867
|
+
}
|
|
868
|
+
// Add non-existent IDs for edge case testing
|
|
869
|
+
for (let i = 0; i < 5; i++) {
|
|
870
|
+
source.lookupIds.push(generateFakeId(source.primaryId.length));
|
|
871
|
+
}
|
|
872
|
+
// Generate diverse search queries from real names
|
|
873
|
+
const uniqueNames = [...new Set(names)];
|
|
874
|
+
for (const name of uniqueNames.slice(0, 10)) {
|
|
875
|
+
// Full name (easy)
|
|
876
|
+
source.searchQueries.push(name);
|
|
877
|
+
}
|
|
878
|
+
for (const name of uniqueNames.slice(0, 10)) {
|
|
879
|
+
// Single token (medium)
|
|
880
|
+
const tokens = name.split(/\s+/).filter(t => t.length >= 3);
|
|
881
|
+
if (tokens.length > 0)
|
|
882
|
+
source.searchQueries.push(tokens[0]);
|
|
883
|
+
}
|
|
884
|
+
for (const name of uniqueNames.slice(0, 10)) {
|
|
885
|
+
// 3-letter prefix (hard)
|
|
886
|
+
const token = name.split(/\s+/).find(t => t.length >= 4);
|
|
887
|
+
if (token)
|
|
888
|
+
source.searchQueries.push(token.slice(0, 3));
|
|
889
|
+
}
|
|
890
|
+
for (const name of uniqueNames.slice(0, 5)) {
|
|
891
|
+
// Multi-token substring (hard)
|
|
892
|
+
const tokens = name.split(/\s+/).filter(t => t.length >= 3);
|
|
893
|
+
if (tokens.length >= 2)
|
|
894
|
+
source.searchQueries.push(tokens.slice(0, 2).join(' '));
|
|
895
|
+
}
|
|
896
|
+
// Non-existent search queries (edge case)
|
|
897
|
+
source.searchQueries.push('xyznonexistent', 'qqq999', 'zzznodata');
|
|
898
|
+
// Fallback: if API returned nothing, use seed queries
|
|
899
|
+
if (source.searchQueries.length < 5) {
|
|
900
|
+
source.searchQueries.push(...SEED_QUERIES);
|
|
901
|
+
}
|
|
902
|
+
}
|
|
780
903
|
async function benchStress(args) {
|
|
781
904
|
const config = requireAuth();
|
|
782
905
|
const durationSec = parseInt(parseFlag(args, '--duration') || '120');
|
|
783
906
|
const maxVUs = parseInt(parseFlag(args, '--max-vus') || '500');
|
|
907
|
+
const filterSource = parseFlag(args, '--source');
|
|
908
|
+
const benchSources = discoverBenchSources(filterSource || undefined);
|
|
909
|
+
if (benchSources.length === 0) {
|
|
910
|
+
console.error('No sources found. Check sources/ directory.');
|
|
911
|
+
process.exit(1);
|
|
912
|
+
}
|
|
913
|
+
// Seed lookup IDs from API
|
|
914
|
+
console.log(` Seeding queries from ${benchSources.length} sources...`);
|
|
915
|
+
await Promise.all(benchSources.map(s => seedBenchQueries(s, config.api_key)));
|
|
784
916
|
const stages = [
|
|
785
917
|
{ name: 'warmup', vus: 10, duration: Math.floor(durationSec * 0.08) },
|
|
786
918
|
{ name: 'ramp', vus: Math.floor(maxVUs * 0.5), duration: Math.floor(durationSec * 0.17) },
|
|
@@ -789,16 +921,21 @@ async function benchStress(args) {
|
|
|
789
921
|
{ name: 'hold', vus: maxVUs, duration: Math.floor(durationSec * 0.25) },
|
|
790
922
|
{ name: 'cool', vus: 10, duration: Math.floor(durationSec * 0.08) },
|
|
791
923
|
];
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
924
|
+
// Build endpoints dynamically from discovered sources
|
|
925
|
+
const endpoints = [];
|
|
926
|
+
for (const s of benchSources) {
|
|
927
|
+
if (s.lookupIds.length > 0) {
|
|
928
|
+
for (const id of s.lookupIds.slice(0, 10)) {
|
|
929
|
+
endpoints.push({ url: `${API_URL}${s.routePath}/${s.primaryId.name}/${id}`, type: `${s.name}/lookup` });
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
for (const q of s.searchQueries.slice(0, 10)) {
|
|
933
|
+
endpoints.push({ url: `${API_URL}${s.routePath}/search?q=${encodeURIComponent(q)}&limit=5`, type: `${s.name}/search` });
|
|
934
|
+
}
|
|
935
|
+
}
|
|
799
936
|
const headers = { Authorization: `Bearer ${config.api_key}` };
|
|
800
937
|
const results = [];
|
|
801
|
-
console.log(`\n STRESS TEST — ${durationSec}s, max ${maxVUs} VUs,
|
|
938
|
+
console.log(`\n STRESS TEST — ${durationSec}s, max ${maxVUs} VUs, ${benchSources.map(s => s.name).join(' + ')}\n`);
|
|
802
939
|
console.log(` Stage VUs Duration`);
|
|
803
940
|
for (const s of stages)
|
|
804
941
|
console.log(` ${s.name.padEnd(10)} ${String(s.vus).padEnd(5)} ${s.duration}s`);
|
|
@@ -886,6 +1023,7 @@ async function benchStress(args) {
|
|
|
886
1023
|
console.log(`\n${'═'.repeat(60)}`);
|
|
887
1024
|
console.log(` STRESS TEST RESULTS`);
|
|
888
1025
|
console.log(`${'═'.repeat(60)}\n`);
|
|
1026
|
+
console.log(` Sources: ${benchSources.map(s => s.name).join(', ')}`);
|
|
889
1027
|
console.log(` Duration: ${dur}s`);
|
|
890
1028
|
console.log(` Max VUs: ${maxVUs}`);
|
|
891
1029
|
console.log(` Total reqs: ${total.toLocaleString()}`);
|
|
@@ -918,10 +1056,10 @@ async function benchStress(args) {
|
|
|
918
1056
|
}
|
|
919
1057
|
console.log();
|
|
920
1058
|
console.log(` Per Endpoint`);
|
|
921
|
-
console.log(` ${'Type'.padEnd(
|
|
1059
|
+
console.log(` ${'Type'.padEnd(25)} ${'Reqs'.padEnd(8)} ${'p50'.padEnd(8)} ${'p95'.padEnd(8)} p99`);
|
|
922
1060
|
for (const [t, d] of Object.entries(allByType)) {
|
|
923
1061
|
const sl = d.latencies.sort((a, b) => a - b);
|
|
924
|
-
console.log(` ${t.padEnd(
|
|
1062
|
+
console.log(` ${t.padEnd(25)} ${String(d.count).padEnd(8)} ${(pct(sl, 0.5) + 'ms').padEnd(8)} ${(pct(sl, 0.95) + 'ms').padEnd(8)} ${pct(sl, 0.99)}ms`);
|
|
925
1063
|
}
|
|
926
1064
|
console.log();
|
|
927
1065
|
console.log(` HTTP Status: ${Object.entries(allStat).map(([s, c]) => `${s}: ${c}`).join(' ')}`);
|
|
@@ -996,44 +1134,48 @@ async function bench(args) {
|
|
|
996
1134
|
const count = countIdx !== -1 ? parseInt(args[countIdx + 1]) || 100 : 100;
|
|
997
1135
|
const concurrencyIdx = args.indexOf('--concurrency');
|
|
998
1136
|
const concurrency = concurrencyIdx !== -1 ? parseInt(args[concurrencyIdx + 1]) || 10 : 10;
|
|
999
|
-
const
|
|
1000
|
-
const
|
|
1001
|
-
const
|
|
1002
|
-
const type = typeIdx !== -1 ? args[typeIdx + 1] : (country === 'pe' ? 'ruc' : 'nit');
|
|
1137
|
+
const filterSource = parseFlag(args, '--source');
|
|
1138
|
+
const searchOnly = args.includes('--search-only');
|
|
1139
|
+
const lookupOnly = args.includes('--lookup-only');
|
|
1003
1140
|
const config = requireAuth();
|
|
1004
|
-
const
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
console.error(`Unknown combination: --country ${country} --type ${type}`);
|
|
1008
|
-
console.error(`Supported: ${Object.keys(BENCH_SAMPLES).map(k => '--country ' + k.replace('/', ' --type ')).join(', ')}`);
|
|
1141
|
+
const benchSources = discoverBenchSources(filterSource || undefined);
|
|
1142
|
+
if (benchSources.length === 0) {
|
|
1143
|
+
console.error('No sources found. Check sources/ directory or use --source <name>.');
|
|
1009
1144
|
process.exit(1);
|
|
1010
1145
|
}
|
|
1011
|
-
|
|
1012
|
-
'pe/ruc': '/pe/sunat/padron/ruc',
|
|
1013
|
-
'pe/search': '/pe/sunat/padron/search',
|
|
1014
|
-
'pe/oece/tenders': '/pe/oece/tenders',
|
|
1015
|
-
'co/nit': '/co/rues/registry/nit',
|
|
1016
|
-
'co/search': '/co/rues/registry/search',
|
|
1017
|
-
};
|
|
1018
|
-
const getUrl = (sample) => {
|
|
1019
|
-
const route = ROUTE_MAP[key];
|
|
1020
|
-
if (type === 'search' || type === 'oece/tenders')
|
|
1021
|
-
return `${API_URL}${route}?q=${encodeURIComponent(sample)}&limit=5`;
|
|
1022
|
-
return `${API_URL}${route}/${sample}`;
|
|
1023
|
-
};
|
|
1024
|
-
const tasks = Array.from({ length: count }, (_, i) => samples[i % samples.length]);
|
|
1146
|
+
// Seed queries + IDs from real API data
|
|
1025
1147
|
if (!jsonFlag)
|
|
1026
|
-
console.log(
|
|
1148
|
+
console.log(` Seeding queries from ${benchSources.length} sources...`);
|
|
1149
|
+
await Promise.all(benchSources.map(s => seedBenchQueries(s, config.api_key)));
|
|
1150
|
+
// Build URL list
|
|
1151
|
+
const urls = [];
|
|
1152
|
+
for (const s of benchSources) {
|
|
1153
|
+
if (!searchOnly && s.lookupIds.length > 0) {
|
|
1154
|
+
for (const id of s.lookupIds) {
|
|
1155
|
+
urls.push({ url: `${API_URL}${s.routePath}/${s.primaryId.name}/${id}`, type: `${s.name}/lookup` });
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
if (!lookupOnly) {
|
|
1159
|
+
for (const q of s.searchQueries) {
|
|
1160
|
+
urls.push({ url: `${API_URL}${s.routePath}/search?q=${encodeURIComponent(q)}&limit=5`, type: `${s.name}/search` });
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
const sourceNames = benchSources.map(s => s.name).join(', ');
|
|
1165
|
+
if (!jsonFlag)
|
|
1166
|
+
console.log(`\n Bench: ${sourceNames} — ${count} requests, ${concurrency} concurrent\n`);
|
|
1027
1167
|
const latencies = [];
|
|
1028
1168
|
const statusCounts = {};
|
|
1169
|
+
const byType = {};
|
|
1029
1170
|
let completed = 0;
|
|
1030
1171
|
const startAll = Date.now();
|
|
1172
|
+
const tasks = Array.from({ length: count }, (_, i) => urls[i % urls.length]);
|
|
1031
1173
|
for (let i = 0; i < tasks.length; i += concurrency) {
|
|
1032
1174
|
const batch = tasks.slice(i, i + concurrency);
|
|
1033
|
-
await Promise.all(batch.map(async (
|
|
1175
|
+
await Promise.all(batch.map(async (task) => {
|
|
1034
1176
|
const t0 = Date.now();
|
|
1035
1177
|
try {
|
|
1036
|
-
const res = await fetch(
|
|
1178
|
+
const res = await fetch(task.url, {
|
|
1037
1179
|
headers: { Authorization: `Bearer ${config.api_key}` },
|
|
1038
1180
|
signal: AbortSignal.timeout(15_000),
|
|
1039
1181
|
});
|
|
@@ -1044,6 +1186,10 @@ async function bench(args) {
|
|
|
1044
1186
|
latencies.push(Date.now() - t0);
|
|
1045
1187
|
statusCounts[0] = (statusCounts[0] || 0) + 1;
|
|
1046
1188
|
}
|
|
1189
|
+
if (!byType[task.type])
|
|
1190
|
+
byType[task.type] = { count: 0, latencies: [] };
|
|
1191
|
+
byType[task.type].count++;
|
|
1192
|
+
byType[task.type].latencies.push(latencies[latencies.length - 1]);
|
|
1047
1193
|
completed++;
|
|
1048
1194
|
if (process.stdout.isTTY && !jsonFlag) {
|
|
1049
1195
|
const pct = Math.floor(completed / count * 30);
|
|
@@ -1060,11 +1206,14 @@ async function bench(args) {
|
|
|
1060
1206
|
const rps = (count / (totalMs / 1000)).toFixed(1);
|
|
1061
1207
|
if (jsonFlag) {
|
|
1062
1208
|
console.log(JSON.stringify({
|
|
1063
|
-
|
|
1064
|
-
total_ms: totalMs,
|
|
1065
|
-
rps: parseFloat(rps),
|
|
1066
|
-
status: statusCounts,
|
|
1209
|
+
sources: benchSources.map(s => s.name), count, concurrency,
|
|
1210
|
+
total_ms: totalMs, rps: parseFloat(rps), status: statusCounts,
|
|
1067
1211
|
latency: { p50: p(0.5), p95: p(0.95), p99: p(0.99), max: latencies[latencies.length - 1] },
|
|
1212
|
+
byType: Object.fromEntries(Object.entries(byType).map(([t, d]) => {
|
|
1213
|
+
const sl = d.latencies.sort((a, b) => a - b);
|
|
1214
|
+
const pp = (pct) => sl[Math.floor(sl.length * pct)] ?? 0;
|
|
1215
|
+
return [t, { count: d.count, p50: pp(0.5), p95: pp(0.95), p99: pp(0.99) }];
|
|
1216
|
+
})),
|
|
1068
1217
|
}));
|
|
1069
1218
|
return;
|
|
1070
1219
|
}
|
|
@@ -1080,6 +1229,7 @@ async function bench(args) {
|
|
|
1080
1229
|
rateLimit ? `429: ${rateLimit}` : '',
|
|
1081
1230
|
errors ? `Errors: ${errors}` : '',
|
|
1082
1231
|
].filter(Boolean).join(' ');
|
|
1232
|
+
console.log(` Sources: ${sourceNames}`);
|
|
1083
1233
|
console.log(` Total: ${(totalMs / 1000).toFixed(1)}s Req/sec: ${rps}`);
|
|
1084
1234
|
console.log(` Status: ${statusLine}`);
|
|
1085
1235
|
console.log();
|
|
@@ -1087,6 +1237,65 @@ async function bench(args) {
|
|
|
1087
1237
|
console.log(` p95: ${p(0.95)}ms`);
|
|
1088
1238
|
console.log(` p99: ${p(0.99)}ms`);
|
|
1089
1239
|
console.log(` max: ${latencies[latencies.length - 1]}ms`);
|
|
1240
|
+
if (Object.keys(byType).length > 1) {
|
|
1241
|
+
console.log();
|
|
1242
|
+
console.log(` Per Endpoint`);
|
|
1243
|
+
console.log(` ${'Type'.padEnd(25)} ${'Reqs'.padEnd(8)} ${'p50'.padEnd(8)} ${'p95'.padEnd(8)} p99`);
|
|
1244
|
+
for (const [t, d] of Object.entries(byType)) {
|
|
1245
|
+
const sl = d.latencies.sort((a, b) => a - b);
|
|
1246
|
+
const pp = (pct) => sl[Math.floor(sl.length * pct)] ?? 0;
|
|
1247
|
+
console.log(` ${t.padEnd(25)} ${String(d.count).padEnd(8)} ${(pp(0.5) + 'ms').padEnd(8)} ${(pp(0.95) + 'ms').padEnd(8)} ${pp(0.99)}ms`);
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
/** Run production bench: 500 concurrent against api.latinfo.dev, returns success rate */
|
|
1252
|
+
async function benchProduction(sourceName, apiKey, concurrency = 500) {
|
|
1253
|
+
const benchSources = discoverBenchSources(sourceName);
|
|
1254
|
+
if (benchSources.length === 0)
|
|
1255
|
+
throw new Error(`Source ${sourceName} not found`);
|
|
1256
|
+
const source = benchSources[0];
|
|
1257
|
+
// Seed IDs
|
|
1258
|
+
await seedBenchQueries(source, apiKey);
|
|
1259
|
+
// Build mixed URL list (search + lookup)
|
|
1260
|
+
const urls = [];
|
|
1261
|
+
for (const q of source.searchQueries) {
|
|
1262
|
+
urls.push(`${API_URL}${source.routePath}/search?q=${encodeURIComponent(q)}&limit=5`);
|
|
1263
|
+
}
|
|
1264
|
+
for (const id of source.lookupIds) {
|
|
1265
|
+
urls.push(`${API_URL}${source.routePath}/${source.primaryId.name}/${id}`);
|
|
1266
|
+
}
|
|
1267
|
+
const total = concurrency;
|
|
1268
|
+
const headers = { Authorization: `Bearer ${apiKey}` };
|
|
1269
|
+
let idx = 0, success = 0, fails = 0;
|
|
1270
|
+
const lats = [];
|
|
1271
|
+
const t0 = Date.now();
|
|
1272
|
+
async function worker() {
|
|
1273
|
+
while (idx < total) {
|
|
1274
|
+
const i = idx++;
|
|
1275
|
+
const url = urls[i % urls.length];
|
|
1276
|
+
const start = Date.now();
|
|
1277
|
+
try {
|
|
1278
|
+
const res = await fetch(url, { headers, signal: AbortSignal.timeout(15_000) });
|
|
1279
|
+
lats.push(Date.now() - start);
|
|
1280
|
+
if (res.status === 200 || res.status === 404)
|
|
1281
|
+
success++;
|
|
1282
|
+
else
|
|
1283
|
+
fails++;
|
|
1284
|
+
}
|
|
1285
|
+
catch {
|
|
1286
|
+
lats.push(Date.now() - start);
|
|
1287
|
+
fails++;
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
await Promise.all(Array.from({ length: concurrency }, () => worker()));
|
|
1292
|
+
lats.sort((a, b) => a - b);
|
|
1293
|
+
const p = (pct) => lats[Math.floor(lats.length * pct)] ?? 0;
|
|
1294
|
+
return {
|
|
1295
|
+
success_rate: (success / total) * 100,
|
|
1296
|
+
p50: p(0.5), p95: p(0.95), p99: p(0.99),
|
|
1297
|
+
qps: Math.round(total / ((Date.now() - t0) / 1000)),
|
|
1298
|
+
};
|
|
1090
1299
|
}
|
|
1091
1300
|
// --- Licitaciones ---
|
|
1092
1301
|
function parseFlag(args, flag) {
|
|
@@ -2356,18 +2565,61 @@ async function pipePublish(args) {
|
|
|
2356
2565
|
catch {
|
|
2357
2566
|
console.log(`[pipe] Could not trigger workflow automatically.`);
|
|
2358
2567
|
}
|
|
2359
|
-
// 4.
|
|
2360
|
-
console.log(`[pipe]
|
|
2568
|
+
// 4. Update search server SOURCES env + restart
|
|
2569
|
+
console.log(`[pipe] Updating search server SOURCES on Linux Mint...`);
|
|
2570
|
+
try {
|
|
2571
|
+
// Read current SOURCES from systemd service
|
|
2572
|
+
const currentSources = run(`ssh -o ConnectTimeout=10 ${RUNNER} "grep -oP 'SOURCES=\\K.*' /etc/systemd/system/latinfo-search.service 2>/dev/null || echo ''"`, {
|
|
2573
|
+
encoding: 'utf-8', stdio: 'pipe',
|
|
2574
|
+
}).trim().replace(/^"|"$/g, '');
|
|
2575
|
+
const sourceList = currentSources ? currentSources.split(',').map(s => s.trim()) : [];
|
|
2576
|
+
if (!sourceList.includes(sourceName)) {
|
|
2577
|
+
sourceList.push(sourceName);
|
|
2578
|
+
const newSources = sourceList.join(',');
|
|
2579
|
+
console.log(`[pipe] Adding ${sourceName} to SOURCES: ${newSources}`);
|
|
2580
|
+
run(`ssh ${RUNNER} "sudo sed -i 's|^Environment=.*SOURCES=.*|Environment=SOURCES=${newSources}|' /etc/systemd/system/latinfo-search.service && sudo systemctl daemon-reload"`, { stdio: 'pipe' });
|
|
2581
|
+
}
|
|
2582
|
+
else {
|
|
2583
|
+
console.log(`[pipe] ${sourceName} already in SOURCES.`);
|
|
2584
|
+
}
|
|
2585
|
+
run(`ssh ${RUNNER} "sudo systemctl restart latinfo-search"`, { stdio: 'inherit' });
|
|
2586
|
+
console.log(`[pipe] Search server restarted.`);
|
|
2587
|
+
}
|
|
2588
|
+
catch {
|
|
2589
|
+
console.log(`[pipe] Could not update search server (not critical).`);
|
|
2590
|
+
}
|
|
2591
|
+
// 5. Production bench: 500 concurrent against api.latinfo.dev
|
|
2592
|
+
console.log(`\n[pipe] Running production bench (500 concurrent)...`);
|
|
2361
2593
|
try {
|
|
2362
|
-
|
|
2594
|
+
const config = loadConfig();
|
|
2595
|
+
if (!config?.api_key)
|
|
2596
|
+
throw new Error('No API key');
|
|
2597
|
+
const bench = await benchProduction(sourceName, config.api_key, 500);
|
|
2598
|
+
console.log(`\n Production bench: ${bench.qps} q/s, ${bench.success_rate.toFixed(1)}% success`);
|
|
2599
|
+
console.log(` p50: ${bench.p50}ms p95: ${bench.p95}ms p99: ${bench.p99}ms`);
|
|
2600
|
+
if (bench.success_rate < 99.9) {
|
|
2601
|
+
console.error(`\n[pipe] PRODUCTION BENCH FAILED — ${bench.success_rate.toFixed(1)}% < 99.9%`);
|
|
2602
|
+
console.error(`[pipe] Rolling back...`);
|
|
2603
|
+
try {
|
|
2604
|
+
run(`git revert HEAD --no-edit && git push`, { cwd: repo, stdio: 'pipe' });
|
|
2605
|
+
run(`npx wrangler deploy`, { cwd: repo, stdio: 'pipe' });
|
|
2606
|
+
}
|
|
2607
|
+
catch { }
|
|
2608
|
+
status.publish = { passed: false, timestamp: new Date().toISOString() };
|
|
2609
|
+
savePipeStatus(status);
|
|
2610
|
+
process.exit(1);
|
|
2611
|
+
}
|
|
2612
|
+
status.publish = { passed: true, timestamp: new Date().toISOString(), bench: { concurrent: 500, success_rate: bench.success_rate, p50: bench.p50, p95: bench.p95, p99: bench.p99 } };
|
|
2363
2613
|
}
|
|
2364
|
-
catch {
|
|
2614
|
+
catch (e) {
|
|
2615
|
+
console.log(`[pipe] Skipping production bench (no API key or network error).`);
|
|
2616
|
+
status.publish = { passed: true, timestamp: new Date().toISOString() };
|
|
2617
|
+
}
|
|
2618
|
+
savePipeStatus(status);
|
|
2365
2619
|
console.log(`\n[pipe] Gate 4 PASSED ✓`);
|
|
2366
2620
|
console.log(`[pipe] ${sourceName} is LIVE`);
|
|
2367
2621
|
console.log(` API: https://api.latinfo.dev/${sourceName.replace(/-/g, '/')}/`);
|
|
2368
2622
|
console.log(` CLI: latinfo ${sourceName.replace(/-/g, ' ')}`);
|
|
2369
|
-
status.publish = { passed: true, timestamp: new Date().toISOString() };
|
|
2370
|
-
savePipeStatus(status);
|
|
2371
2623
|
}
|
|
2372
2624
|
async function pipeStatus(args) {
|
|
2373
2625
|
const [sourceName] = args;
|
|
@@ -2965,6 +3217,9 @@ else {
|
|
|
2965
3217
|
case 'bench':
|
|
2966
3218
|
bench(args).catch(e => { console.error(e); process.exit(1); });
|
|
2967
3219
|
break;
|
|
3220
|
+
case 'search-server':
|
|
3221
|
+
searchServerStatus().catch(e => { console.error(e); process.exit(1); });
|
|
3222
|
+
break;
|
|
2968
3223
|
case 'pipe':
|
|
2969
3224
|
pipe(args).catch(e => { console.error(e); process.exit(1); });
|
|
2970
3225
|
break;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "latinfo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"description": "Tax registry & procurement API for Latin America. Query RUC, DNI, NIT, licitaciones from Peru & Colombia. Offline MPHF search, full OCDS data, updated daily.",
|
|
5
5
|
"homepage": "https://latinfo.dev",
|
|
6
6
|
"repository": {
|