From 61f5c40ec416c7748aa9576adad9425056881f53 Mon Sep 17 00:00:00 2001 From: Tim Keller Date: Wed, 3 Jun 2026 00:35:09 +0200 Subject: [PATCH 1/2] Reuse HTTP connection and add chunked single-request batch resolve Enable keep-alive on the AyonApi httplib client so the TCP/TLS connection is reused across resolves instead of a fresh handshake per request; over a WAN link the handshake dominates per-call latency. Keep-alive can be disabled with AYON_RESOLVER_NO_KEEPALIVE=1 for benchmarking. Add batchResolvePathSerial(): resolve a whole frontier of uris in one POST over that persistent client, split into capped chunks (m_maxSerialBatchSize) so a large frontier never sends an unbounded request or holds a server DB connection for the full set. --- src/AyonCppApi/AyonCppApi.cpp | 72 +++++++++++++++++++++++++++++++++++ src/AyonCppApi/AyonCppApi.h | 23 ++++++++++- 2 files changed, 94 insertions(+), 1 deletion(-) diff --git a/src/AyonCppApi/AyonCppApi.cpp b/src/AyonCppApi/AyonCppApi.cpp index d2284a5..343d39d 100644 --- a/src/AyonCppApi/AyonCppApi.cpp +++ b/src/AyonCppApi/AyonCppApi.cpp @@ -153,6 +153,14 @@ AyonApi::AyonApi(const std::optional &logFilePos, m_log->info(m_log->key("AyonApi"), "Init AyonServer httplib::Client"); m_ayonServer = std::make_unique(m_serverUrl); + // Reuse the TCP/TLS connection across resolves instead of a fresh handshake per + // request. Over a WAN link the handshake dominates per-call latency, so this is + // a large win for the serial resolve path and the prewarm batched resolves. + // Gated so the keep-alive contribution can be benchmarked: set + // AYON_RESOLVER_NO_KEEPALIVE=1 to fall back to a fresh handshake per request. + if (!std::getenv("AYON_RESOLVER_NO_KEEPALIVE")) { + m_ayonServer->set_keep_alive(true); + } m_log->info(m_log->key("AyonApi"), "After creating httplib::Client - {}", m_serverUrl); if (isSSL()) { @@ -549,6 +557,70 @@ AyonApi::batchResolvePath(std::vector &uriPaths) { return assetIdentGrp; }; +std::unordered_map +AyonApi::batchResolvePathSerial(const std::vector &uriPaths) { + PerfTimer("AyonApi::batchResolvePathSerial"); + m_log->info(m_log->key("AyonApi"), "AyonApi::batchResolvePathSerial({} uris)", uriPaths.size()); + + std::unordered_map assetIdentGrp; + if (uriPaths.empty()) { + return assetIdentGrp; + } + + // Drop empties up front so chunk boundaries are stable and we never POST blank uris. + std::vector cleanUris; + cleanUris.reserve(uriPaths.size()); + for (const auto &uri: uriPaths) { + if (!uri.empty()) { + cleanUris.push_back(uri); + } + } + if (cleanUris.empty()) { + return assetIdentGrp; + } + + const std::string endPoint + = m_pathOnlyResolution ? m_uriResolverEndpoint + m_uriResolverEndpointPathOnlyVar : m_uriResolverEndpoint; + + // Split large frontiers into capped chunks (see m_maxSerialBatchSize). Each chunk is its + // own POST over the keep-alive client, so the server processes and releases a bounded + // batch at a time. For typical frontiers (<= m_maxSerialBatchSize) this is a single + // request, identical to the un-chunked path. + const size_t chunkSize = m_maxSerialBatchSize; + for (size_t start = 0; start < cleanUris.size(); start += chunkSize) { + const size_t end = std::min(start + chunkSize, cleanUris.size()); + + nlohmann::json uriArray = nlohmann::json::array(); + for (size_t i = start; i < end; ++i) { + uriArray.push_back(cleanUris[i]); + } + nlohmann::json jsonPayload = {{"resolveRoots", false}, {"uris", uriArray}}; + std::string payload = jsonPayload.dump(); + + std::string rawResponse; + { + std::lock_guard lock(m_ayonServerMutex); + rawResponse = serialCorePost(endPoint, m_headers, payload, 200); + } + if (rawResponse.empty()) { + m_log->warn("AyonApi::batchResolvePathSerial empty response for chunk [{}, {})", start, end); + continue; + } + + try { + nlohmann::json responseArray = nlohmann::json::parse(rawResponse); + for (const auto &assetRaw: responseArray) { + assetIdentGrp.emplace(getAssetIdent(assetRaw)); + } + } + catch (const nlohmann::json::exception &e) { + m_log->error("AyonApi::batchResolvePathSerial JSON parse failed: {}", e.what()); + } + } + + return assetIdentGrp; +}; + // TODO make it so that hero version is chosen if available std::pair AyonApi::getAssetIdent(const nlohmann::json &uriResolverResponse) { diff --git a/src/AyonCppApi/AyonCppApi.h b/src/AyonCppApi/AyonCppApi.h index 2f7b347..4714496 100644 --- a/src/AyonCppApi/AyonCppApi.h +++ b/src/AyonCppApi/AyonCppApi.h @@ -110,6 +110,21 @@ class AyonApi { */ std::unordered_map batchResolvePath(std::vector &uriPaths); + /** + * @brief Resolves a vector of paths in a SINGLE batched request over the persistent + * keep-alive client. + * + * Unlike batchResolvePath (which fans out parallel requests on fresh, non-keep-alive + * clients), this sends one POST with all URIs through m_ayonServer and parses the whole + * response array. One round-trip, deterministic, connection reused. Intended for the + * prewarm pass where frontiers are modest and determinism + keep-alive matter more than + * request-level parallelism. + * + * @param uriPaths The vector of URI paths to resolve. + * @return An unordered map of URI -> resolved path. + */ + std::unordered_map batchResolvePathSerial(const std::vector &uriPaths); + /** * @brief Takes an AYON path URI response (resolved ayon://path) and returns a pair of * asset identifier (ayon:// path) and the machine local file location. @@ -214,7 +229,13 @@ class AyonApi { uint16_t m_regroupSizeForAsyncRequests = 200; uint16_t m_maxGroupSizeForAsyncRequests = 300; uint16_t m_minVecSizeForGroupSplitAsyncRequests = 50; - + // Max uris per POST in batchResolvePathSerial. The server resolves a batch in one + // sequential transaction, so an unbounded request holds a DB connection for the whole + // frontier (risking its connection-pool 503 guard) and grows the body/response without + // limit. Splitting into capped chunks releases the connection between chunks. Matches + // m_regroupSizeForAsyncRequests so the serial and async paths cap requests alike. + uint16_t m_maxSerialBatchSize = 200; + // Retry and Timeout Configuration uint8_t m_maxCallRetries = 8; uint16_t m_retryWait = 800; From 491d782c6ac15e46e9f2dd5638bf03aee2d211d2 Mon Sep 17 00:00:00 2001 From: Tim Keller Date: Mon, 8 Jun 2026 17:10:37 +0200 Subject: [PATCH 2/2] Only send X-ayon-site-id when a site id is set The AYON server rejects /api/resolve with HTTP 400 when the X-ayon-site-id header is present but empty or names an unregistered site. That breaks resolution on service-account and cloud workstations with no registered site. Add the header only when m_siteId is non-empty. --- src/AyonCppApi/AyonCppApi.cpp | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/AyonCppApi/AyonCppApi.cpp b/src/AyonCppApi/AyonCppApi.cpp index 343d39d..d6b38f5 100644 --- a/src/AyonCppApi/AyonCppApi.cpp +++ b/src/AyonCppApi/AyonCppApi.cpp @@ -200,9 +200,15 @@ AyonApi::AyonApi(const std::optional &logFilePos, m_log->info(m_log->key("AyonApi"), "Status code: {}", res->status); m_headers = { - {"X-Api-Key", m_authKey}, - {"X-ayon-site-id", m_siteId} + {"X-Api-Key", m_authKey} }; + // Only advertise a site id when we actually have one. A service-account + // setup (or any machine without a registered AYON site) has no valid + // site, and the server returns 400 "Invalid site id" if the header is + // present but empty/unknown. Omitting it lets resolution proceed. + if (!m_siteId.empty()) { + m_headers.emplace("X-ayon-site-id", m_siteId); + } auto resMe = m_ayonServer->Get("/api/users/me", m_headers); if (resMe && resMe->status != 200) {