mirror of
https://github.com/zerotier/ZeroTierOne.git
synced 2026-09-22 15:29:03 -07:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c92adec320 | ||
|
|
2871d293ff | ||
|
|
36b5e93494 | ||
|
|
d889c7badb | ||
|
|
13ef0b4374 | ||
|
|
305717ca80 | ||
|
|
d41fac0fd7 | ||
|
|
feefda3dc9 | ||
|
|
ce16dcbc67 | ||
|
|
cb0764d498 | ||
|
|
b96d4fc1ee |
No files matched your search
@@ -1,9 +1,4 @@
|
||||
ALTER TABLE sso_expiry RENAME COLUMN member_id TO device_id;
|
||||
-- NOTE (2026-08-03): sso_expiry_network_member_ix is dropped again in 0008. It looks
|
||||
-- correct but could never serve the ON DELETE CASCADE from network_memberships_ctl --
|
||||
-- sso_expiry's columns are bpchar against a varchar parent, so the RI trigger compares
|
||||
-- network_id::text and no bare-column index can match it. Do not re-add a bare-column
|
||||
-- index here expecting it to help; see 0008_sso_expiry_indexes.up.sql for the full story.
|
||||
CREATE INDEX IF NOT EXISTS sso_expiry_network_member_ix ON public.sso_expiry (network_id, device_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS oidc_config (
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
-- Reverse of 0008: drop the two new indexes and restore sso_expiry_network_member_ix
|
||||
-- exactly as 0006 created it.
|
||||
--
|
||||
-- WARNING: this reinstates the defect 0008 fixed. With sso_expiry_ri_ix gone, the
|
||||
-- ON DELETE CASCADE from network_memberships_ctl falls back to a full sequential scan of
|
||||
-- sso_expiry for every member deleted; with sso_expiry_lookup_ix gone the netconf-path
|
||||
-- nonce lookups go back to heap-fetching every accumulated row per member; and with
|
||||
-- sso_expiry_creation_time_ix gone the retention pruner full-scans the table every run.
|
||||
-- See the header of 0008_sso_expiry_indexes.up.sql for the measurements.
|
||||
|
||||
DROP INDEX IF EXISTS sso_expiry_creation_time_ix;
|
||||
DROP INDEX IF EXISTS sso_expiry_lookup_ix;
|
||||
DROP INDEX IF EXISTS sso_expiry_ri_ix;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS sso_expiry_network_member_ix ON public.sso_expiry (network_id, device_id);
|
||||
@@ -1,71 +0,0 @@
|
||||
-- sso_expiry index overhaul (2026-08-03).
|
||||
--
|
||||
-- WHY: sso_expiry.network_id / device_id are CHARACTER(16) / CHARACTER(10) (bpchar), while
|
||||
-- the parent columns they reference -- network_memberships_ctl.network_id / device_id --
|
||||
-- are character varying(22). There is no varchar = bpchar operator, so when the FK in 0005
|
||||
-- was created PostgreSQL coerced both sides to text and stored texteq as the constraint's
|
||||
-- comparison operator. The ON DELETE CASCADE trigger consequently emits:
|
||||
--
|
||||
-- DELETE FROM ONLY public.sso_expiry x
|
||||
-- WHERE $1::pg_catalog.text OPERATOR(pg_catalog.=) x.network_id::pg_catalog.text
|
||||
-- AND $2::pg_catalog.text OPERATOR(pg_catalog.=) x.device_id::pg_catalog.text
|
||||
--
|
||||
-- bpchar->text is a real function call (it strips trailing blanks), not a binary-coercible
|
||||
-- relabel, so a btree index on the bare columns can NEVER match that qual. This is why
|
||||
-- sso_expiry_network_member_ix (added in 0006, on exactly the right two columns) was never
|
||||
-- once used by the cascade: every single member delete seq-scanned the entire table.
|
||||
--
|
||||
-- Measured in prod 2026-08-01: 64,974 buffers / 1.33 s per deleted member, at idle. A user
|
||||
-- bulk-deleting ~2,100 members from one network pinned the 4-vCPU AlloyDB instance at 100%
|
||||
-- CPU for 27 minutes and paged on controller_db_commit_latency_ms.
|
||||
--
|
||||
-- Note the asymmetry: the reverse RI direction (checking the parent exists on INSERT into
|
||||
-- sso_expiry) stays fast at ~17 buffers, because varchar->text IS binary-coercible and the
|
||||
-- planner strips it as a RelabelType. Same operator, same cast syntax, opposite outcomes.
|
||||
--
|
||||
-- WHAT:
|
||||
-- sso_expiry_ri_ix -- built on the cast expressions so the cascade can match it.
|
||||
-- Prod: 61,268 -> ~15 buffers, 1,450 ms -> 0.4 ms per delete.
|
||||
-- sso_expiry_lookup_ix -- adds nonce_expiration as a third key column so the netconf-path
|
||||
-- nonce lookups (CentralDB.cpp, "SELECT nonce FROM sso_expiry
|
||||
-- WHERE network_id = $1 AND device_id = $2 AND ... <= ...") can
|
||||
-- range-seek past expired rows instead of heap-fetching every
|
||||
-- accumulated row for that member. A member that never completes
|
||||
-- SSO accrues one row per 5 minutes and rows are retained ~30
|
||||
-- days (sso_expiry doubles as the SSO log), so those groups run
|
||||
-- to thousands of rows.
|
||||
-- Prod: 4,850 -> 5 buffers, 16 ms -> 0.03 ms per lookup, which
|
||||
-- returned ~1.2 of the 4 vCPUs.
|
||||
-- sso_expiry_creation_time_ix
|
||||
-- -- the retention pruner in central-v2/apps/controller-manager/
|
||||
-- core/controller/ssocleanup.go ("DELETE FROM sso_expiry WHERE
|
||||
-- creation_time < NOW() - INTERVAL '32 days'") had no index on
|
||||
-- creation_time and full-scanned the table on every run, roughly
|
||||
-- every 5 minutes, forever.
|
||||
-- Prod: 58,000 -> ~900 buffers, 880 ms -> 1.0 ms per run.
|
||||
-- sso_expiry_network_member_ix is dropped: it is a strict prefix of sso_expiry_lookup_ix,
|
||||
-- so it serves nothing the new index does not, while costing
|
||||
-- maintenance on every insert and every pruner delete.
|
||||
--
|
||||
-- LOCKING: prod already has all three changes, applied by hand on 2026-08-03 using
|
||||
-- CREATE INDEX CONCURRENTLY / DROP INDEX CONCURRENTLY, so nothing blocked. This file
|
||||
-- deliberately uses plain, non-concurrent DDL: golang-migrate sends the whole file as a
|
||||
-- single Exec, which PostgreSQL runs as an implicit transaction block, and
|
||||
-- CREATE INDEX CONCURRENTLY cannot run inside a transaction block. That is the right
|
||||
-- trade-off for the databases this file will actually run against -- dev, staging, and
|
||||
-- freshly provisioned controllers -- where sso_expiry is small or empty.
|
||||
--
|
||||
-- Do NOT run this file against a large populated database. CREATE INDEX takes a SHARE lock
|
||||
-- (blocking writes to sso_expiry for the duration of each build) and DROP INDEX takes
|
||||
-- ACCESS EXCLUSIVE. At prod scale, do it by hand with CONCURRENTLY instead.
|
||||
|
||||
CREATE INDEX IF NOT EXISTS sso_expiry_ri_ix
|
||||
ON public.sso_expiry ((network_id::text), (device_id::text));
|
||||
|
||||
CREATE INDEX IF NOT EXISTS sso_expiry_lookup_ix
|
||||
ON public.sso_expiry (network_id, device_id, nonce_expiration);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS sso_expiry_creation_time_ix
|
||||
ON public.sso_expiry (creation_time);
|
||||
|
||||
DROP INDEX IF EXISTS sso_expiry_network_member_ix;
|
||||
@@ -144,8 +144,6 @@ prometheus::simpleapi::counter_metric_t pgsql_mem_notification { "controller_pgs
|
||||
prometheus::simpleapi::counter_metric_t pgsql_net_notification { "controller_pgsql_network_notifications_received", "number of network change notifications received via pgsql NOTIFY" };
|
||||
prometheus::simpleapi::counter_metric_t pgsql_node_checkin { "controller_pgsql_node_checkin_count", "number of node check-ins (pgsql)" };
|
||||
prometheus::simpleapi::counter_metric_t pgsql_commit_ticks { "controller_pgsql_commit_ticks", "number of commit ticks run (pgsql)" };
|
||||
prometheus::simpleapi::gauge_metric_t db_commit_queue_size { "controller_db_commit_queue", "number of entries in the controller db commit queue" };
|
||||
prometheus::simpleapi::gauge_metric_t db_commit_latency_ms { "controller_db_commit_latency_ms", "milliseconds between enqueue and commit for the most recently dequeued db change" };
|
||||
prometheus::simpleapi::counter_metric_t db_get_sso_info { "controller_db_get_sso_info", "counter" };
|
||||
|
||||
prometheus::simpleapi::counter_metric_t redis_mem_notification { "controller_redis_member_notifications_received", "number of member change notifications received via redis" };
|
||||
|
||||
@@ -142,8 +142,6 @@ extern prometheus::simpleapi::counter_metric_t pgsql_mem_notification;
|
||||
extern prometheus::simpleapi::counter_metric_t pgsql_net_notification;
|
||||
extern prometheus::simpleapi::counter_metric_t pgsql_node_checkin;
|
||||
extern prometheus::simpleapi::counter_metric_t pgsql_commit_ticks;
|
||||
extern prometheus::simpleapi::gauge_metric_t db_commit_queue_size;
|
||||
extern prometheus::simpleapi::gauge_metric_t db_commit_latency_ms;
|
||||
extern prometheus::simpleapi::counter_metric_t db_get_sso_info;
|
||||
|
||||
extern prometheus::simpleapi::counter_metric_t redis_mem_notification;
|
||||
|
||||
@@ -9,18 +9,12 @@
|
||||
#include "CtlUtil.hpp"
|
||||
#include "PubSubWriter.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <cstddef>
|
||||
#include <functional>
|
||||
#include <google/cloud/bigtable/mutations.h>
|
||||
#include <google/cloud/bigtable/row.h>
|
||||
#include <google/cloud/bigtable/table.h>
|
||||
#include <google/cloud/status.h>
|
||||
#include <iterator>
|
||||
#include <opentelemetry/trace/provider.h>
|
||||
#include <thread>
|
||||
|
||||
namespace cbt = google::cloud::bigtable;
|
||||
|
||||
@@ -44,16 +38,6 @@ static const int64_t kNodeInfoRefreshMs = 6LL * 24 * 60 * 60 * 1000; // 6 days
|
||||
// offline), so the cache tracks the active set rather than every node ever seen.
|
||||
static const int64_t kCacheEntryTtlMs = 24LL * 60 * 60 * 1000; // 24 hours
|
||||
static const int64_t kEvictionIntervalMs = 60LL * 60 * 1000; // sweep at most hourly
|
||||
// Bigtable's MutateRows API rejects requests containing more than 100,000
|
||||
// mutations (SetCells) total, and google-cloud-cpp sends a BulkMutation as a
|
||||
// single request -- it never splits by mutation count. Send small batches
|
||||
// (500 rows x <=5 SetCells each, far below the limit) from a small pool of
|
||||
// worker threads. Each worker uses the *synchronous* BulkApply on purpose:
|
||||
// the async client path makes progress only on the connection's shared
|
||||
// background event threads, which stall for tens of seconds at a time in this
|
||||
// environment, while a sync call is driven entirely by its own thread.
|
||||
static const size_t kRowsPerBatch = 500;
|
||||
static const size_t kWriterThreads = 8;
|
||||
|
||||
BigTableStatusWriter::BigTableStatusWriter(
|
||||
const std::string& project_id,
|
||||
@@ -126,174 +110,88 @@ void BigTableStatusWriter::writePending()
|
||||
const int64_t nowMs = OSUtils::now();
|
||||
const std::hash<std::string> hasher;
|
||||
|
||||
// Build every batch on this thread first (all _lastNodeInfo reads and
|
||||
// updates stay here, lock-free), then fan the RPCs out to worker threads.
|
||||
// Workers only perform the RPC and record its outcome; the results --
|
||||
// cache invalidation, re-queueing, logging -- are processed back on this
|
||||
// thread after the workers are joined.
|
||||
struct WriteBatch {
|
||||
cbt::BulkMutation bulk;
|
||||
// Row-key hash per row, aligned with the batch's indices, so a failed
|
||||
// mutation can invalidate its cache entry
|
||||
// (FailedMutation::original_index() is relative to this batch's request).
|
||||
std::vector<uint64_t> rowHashes;
|
||||
size_t entryStart = 0; // index into toWrite of this batch's first entry
|
||||
size_t entryCount = 0;
|
||||
size_t mutationCount = 0;
|
||||
std::vector<cbt::FailedMutation> failures;
|
||||
bool threw = false;
|
||||
std::string error;
|
||||
int64_t durationMs = 0;
|
||||
};
|
||||
std::vector<WriteBatch> batches;
|
||||
batches.reserve((toWrite.size() + kRowsPerBatch - 1) / kRowsPerBatch);
|
||||
cbt::BulkMutation bulk;
|
||||
// Row-key hash for each mutation, aligned with bulk's indices, so a failed
|
||||
// mutation can invalidate its cache entry (FailedMutation::original_index()).
|
||||
std::vector<uint64_t> bulkRowHashes;
|
||||
bulkRowHashes.reserve(toWrite.size());
|
||||
|
||||
for (size_t batchStart = 0; batchStart < toWrite.size(); batchStart += kRowsPerBatch) {
|
||||
const size_t batchEnd = std::min(batchStart + kRowsPerBatch, toWrite.size());
|
||||
for (const auto& entry : toWrite) {
|
||||
std::string row_key = entry.network_id + "#" + entry.node_id;
|
||||
const uint64_t keyHash = hasher(row_key);
|
||||
|
||||
WriteBatch batch;
|
||||
batch.entryStart = batchStart;
|
||||
batch.entryCount = batchEnd - batchStart;
|
||||
batch.rowHashes.reserve(batch.entryCount);
|
||||
// Use the member's last-seen time (epoch ms) as the explicit cell timestamp for
|
||||
// every cell in this row. With a server-set timestamp each SetCell lands at a
|
||||
// fresh server clock value, so re-applying the same mutation creates a *new* cell
|
||||
// version instead of overwriting -- non-idempotent, and google-cloud-cpp won't
|
||||
// retry such writes. A deterministic timestamp makes a re-applied update an exact
|
||||
// overwrite (idempotent + safely retriable). Bigtable cell timestamps are
|
||||
// millisecond-granular, so last_seen (already ms) aligns exactly; it's also
|
||||
// monotonic per member, so a later check-in always reads as the newest version.
|
||||
const std::chrono::milliseconds cellTs(entry.last_seen);
|
||||
|
||||
for (size_t i = batchStart; i < batchEnd; ++i) {
|
||||
const auto& entry = toWrite[i];
|
||||
cbt::SingleRowMutation m(row_key);
|
||||
|
||||
std::string row_key = entry.network_id + "#" + entry.node_id;
|
||||
const uint64_t keyHash = hasher(row_key);
|
||||
// node_info (os/arch/version) changes rarely. Write it only when our
|
||||
// last-written value for this row differs, or hasn't been refreshed in a
|
||||
// while -- no read RPC, the controller is the sole writer of node_info.
|
||||
const uint64_t valueHash = hasher(entry.os + "|" + entry.arch + "|" + entry.version);
|
||||
auto it = _lastNodeInfo.find(keyHash);
|
||||
const bool writeNodeInfo = (it == _lastNodeInfo.end()) || (it->second.valueHash != valueHash)
|
||||
|| ((nowMs - it->second.lastWrittenMs) > kNodeInfoRefreshMs);
|
||||
|
||||
// Use the member's last-seen time (epoch ms) as the explicit cell timestamp for
|
||||
// every cell in this row. With a server-set timestamp each SetCell lands at a
|
||||
// fresh server clock value, so re-applying the same mutation creates a *new* cell
|
||||
// version instead of overwriting -- non-idempotent, and google-cloud-cpp won't
|
||||
// retry such writes. A deterministic timestamp makes a re-applied update an exact
|
||||
// overwrite (idempotent + safely retriable). Bigtable cell timestamps are
|
||||
// millisecond-granular, so last_seen (already ms) aligns exactly; it's also
|
||||
// monotonic per member, so a later check-in always reads as the newest version.
|
||||
// It also makes duplicate rows racing across parallel batches order-independent.
|
||||
const std::chrono::milliseconds cellTs(entry.last_seen);
|
||||
|
||||
cbt::SingleRowMutation m(row_key);
|
||||
size_t rowMutations = 0;
|
||||
|
||||
// node_info (os/arch/version) changes rarely. Write it only when our
|
||||
// last-written value for this row differs, or hasn't been refreshed in a
|
||||
// while -- no read RPC, the controller is the sole writer of node_info.
|
||||
const uint64_t valueHash = hasher(entry.os + "|" + entry.arch + "|" + entry.version);
|
||||
auto it = _lastNodeInfo.find(keyHash);
|
||||
const bool writeNodeInfo = (it == _lastNodeInfo.end()) || (it->second.valueHash != valueHash)
|
||||
|| ((nowMs - it->second.lastWrittenMs) > kNodeInfoRefreshMs);
|
||||
|
||||
if (writeNodeInfo) {
|
||||
m.emplace_back(cbt::SetCell(nodeInfoColumnFamily, osColumn, cellTs, entry.os));
|
||||
m.emplace_back(cbt::SetCell(nodeInfoColumnFamily, archColumn, cellTs, entry.arch));
|
||||
m.emplace_back(cbt::SetCell(nodeInfoColumnFamily, versionColumn, cellTs, entry.version));
|
||||
rowMutations += 3;
|
||||
_lastNodeInfo[keyHash] = NodeInfoState { valueHash, nowMs, nowMs };
|
||||
}
|
||||
else {
|
||||
it->second.lastSeenMs = nowMs;
|
||||
}
|
||||
|
||||
// check_in (ip + last_seen) changes every cycle, so it's always written.
|
||||
char buf[64] = { 0 };
|
||||
std::string addressStr = entry.address.toString(buf);
|
||||
if (entry.address.ss_family == AF_INET) {
|
||||
m.emplace_back(cbt::SetCell(checkInColumnFamily, ipv4Column, cellTs, std::move(addressStr)));
|
||||
rowMutations++;
|
||||
}
|
||||
else if (entry.address.ss_family == AF_INET6) {
|
||||
m.emplace_back(cbt::SetCell(checkInColumnFamily, ipv6Column, cellTs, std::move(addressStr)));
|
||||
rowMutations++;
|
||||
}
|
||||
m.emplace_back(cbt::SetCell(checkInColumnFamily, lastSeenColumn, cellTs, entry.last_seen));
|
||||
rowMutations++;
|
||||
|
||||
batch.bulk.emplace_back(m);
|
||||
batch.rowHashes.push_back(keyHash);
|
||||
batch.mutationCount += rowMutations;
|
||||
if (writeNodeInfo) {
|
||||
m.emplace_back(cbt::SetCell(nodeInfoColumnFamily, osColumn, cellTs, entry.os));
|
||||
m.emplace_back(cbt::SetCell(nodeInfoColumnFamily, archColumn, cellTs, entry.arch));
|
||||
m.emplace_back(cbt::SetCell(nodeInfoColumnFamily, versionColumn, cellTs, entry.version));
|
||||
_lastNodeInfo[keyHash] = NodeInfoState { valueHash, nowMs, nowMs };
|
||||
}
|
||||
else {
|
||||
it->second.lastSeenMs = nowMs;
|
||||
}
|
||||
|
||||
batches.push_back(std::move(batch));
|
||||
}
|
||||
|
||||
ZTC_LOG("Applying %zu rows to BigTable in %zu batches of up to %zu\n", toWrite.size(), batches.size(), kRowsPerBatch);
|
||||
|
||||
std::atomic<size_t> nextBatch { 0 };
|
||||
std::vector<std::thread> workers;
|
||||
workers.reserve(std::min(kWriterThreads, batches.size()));
|
||||
for (size_t t = 0; t < std::min(kWriterThreads, batches.size()); ++t) {
|
||||
workers.emplace_back([this, &batches, &nextBatch]() {
|
||||
for (size_t i = nextBatch.fetch_add(1); i < batches.size(); i = nextBatch.fetch_add(1)) {
|
||||
WriteBatch& b = batches[i];
|
||||
const auto start = std::chrono::steady_clock::now();
|
||||
try {
|
||||
b.failures = _table->BulkApply(std::move(b.bulk));
|
||||
}
|
||||
catch (const std::exception& e) {
|
||||
b.threw = true;
|
||||
b.error = e.what();
|
||||
}
|
||||
b.durationMs =
|
||||
std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::steady_clock::now() - start)
|
||||
.count();
|
||||
}
|
||||
});
|
||||
}
|
||||
for (auto& w : workers) {
|
||||
w.join();
|
||||
}
|
||||
|
||||
// Failed entries are re-queued for the next cycle rather than dropped: the
|
||||
// deterministic cell timestamps make replays idempotent and
|
||||
// requeuePendingStatus caps the backlog. This covers transient
|
||||
// per-mutation failures (expired auth tokens, UNAVAILABLE, ...) that the
|
||||
// client does not retry internally.
|
||||
std::vector<PendingStatusEntry> toRequeue;
|
||||
size_t totalFailures = 0;
|
||||
for (WriteBatch& batch : batches) {
|
||||
if (batch.threw) {
|
||||
// The batch's outcome is unknown, so clear the rows it covered to force a
|
||||
// node_info rewrite next cycle rather than trusting an optimistic update,
|
||||
// and re-queue all of its entries.
|
||||
ZTC_LOG("Exception writing to BigTable after %lld ms: %s\n", (long long)batch.durationMs, batch.error.c_str());
|
||||
span->SetAttribute("error", batch.error);
|
||||
span->SetStatus(opentelemetry::trace::StatusCode::kError, batch.error);
|
||||
for (uint64_t keyHash : batch.rowHashes) {
|
||||
_lastNodeInfo.erase(keyHash);
|
||||
}
|
||||
toRequeue.insert(
|
||||
toRequeue.end(), std::make_move_iterator(toWrite.begin() + static_cast<std::ptrdiff_t>(batch.entryStart)),
|
||||
std::make_move_iterator(
|
||||
toWrite.begin() + static_cast<std::ptrdiff_t>(batch.entryStart + batch.entryCount)));
|
||||
continue;
|
||||
// check_in (ip + last_seen) changes every cycle, so it's always written.
|
||||
char buf[64] = { 0 };
|
||||
std::string addressStr = entry.address.toString(buf);
|
||||
if (entry.address.ss_family == AF_INET) {
|
||||
m.emplace_back(cbt::SetCell(checkInColumnFamily, ipv4Column, cellTs, std::move(addressStr)));
|
||||
}
|
||||
ZTC_LOG(
|
||||
"BigTable batch of %zu rows (%zu mutations) completed with %zu failures in %lld ms\n",
|
||||
batch.rowHashes.size(), batch.mutationCount, batch.failures.size(), (long long)batch.durationMs);
|
||||
totalFailures += batch.failures.size();
|
||||
for (auto const& r : batch.failures) {
|
||||
else if (entry.address.ss_family == AF_INET6) {
|
||||
m.emplace_back(cbt::SetCell(checkInColumnFamily, ipv6Column, cellTs, std::move(addressStr)));
|
||||
}
|
||||
m.emplace_back(cbt::SetCell(checkInColumnFamily, lastSeenColumn, cellTs, entry.last_seen));
|
||||
|
||||
bulk.emplace_back(m);
|
||||
bulkRowHashes.push_back(keyHash);
|
||||
}
|
||||
|
||||
ZTC_LOG("Applying %zu mutations to BigTable\n", bulk.size());
|
||||
|
||||
try {
|
||||
std::vector<cbt::FailedMutation> failures = _table->BulkApply(std::move(bulk));
|
||||
ZTC_LOG("BigTable write completed with %zu failures\n", failures.size());
|
||||
for (auto const& r : failures) {
|
||||
std::cerr << ::ZeroTier::controllerLogId() << " Error writing to BigTable: " << r.status() << "\n";
|
||||
const int idx = r.original_index();
|
||||
if (idx < 0 || static_cast<size_t>(idx) >= batch.rowHashes.size()) {
|
||||
continue;
|
||||
}
|
||||
// Drop the cache entry for any failed row so its node_info is rewritten
|
||||
// next cycle rather than being assumed durably written.
|
||||
_lastNodeInfo.erase(batch.rowHashes[idx]);
|
||||
// Re-queue the failed entry unless the server called it malformed --
|
||||
// batches are far below the request limits, so INVALID_ARGUMENT here
|
||||
// means the row itself is unprocessable and would just loop forever.
|
||||
if (r.status().code() != google::cloud::StatusCode::kInvalidArgument) {
|
||||
toRequeue.push_back(std::move(toWrite[batch.entryStart + static_cast<size_t>(idx)]));
|
||||
const int idx = r.original_index();
|
||||
if (idx >= 0 && static_cast<size_t>(idx) < bulkRowHashes.size()) {
|
||||
_lastNodeInfo.erase(bulkRowHashes[idx]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ZTC_LOG("BigTable write completed with %zu failures\n", totalFailures);
|
||||
if (! toRequeue.empty()) {
|
||||
ZTC_LOG("BigTableStatusWriter: re-queueing %zu entries for retry next cycle\n", toRequeue.size());
|
||||
requeuePendingStatus(_pending, _lock, std::move(toRequeue), "BigTableStatusWriter");
|
||||
catch (const std::exception& e) {
|
||||
ZTC_LOG("Exception writing to BigTable: %s\n", e.what());
|
||||
span->SetAttribute("error", e.what());
|
||||
span->SetStatus(opentelemetry::trace::StatusCode::kError, e.what());
|
||||
// The batch's outcome is unknown, so clear the rows it covered to force a
|
||||
// node_info rewrite next cycle rather than trusting an optimistic update.
|
||||
for (uint64_t keyHash : bulkRowHashes) {
|
||||
_lastNodeInfo.erase(keyHash);
|
||||
}
|
||||
// Don't drop the batch — re-queue it for the next cycle.
|
||||
requeuePendingStatus(_pending, _lock, std::move(toWrite), "BigTableStatusWriter");
|
||||
return;
|
||||
}
|
||||
|
||||
// Periodically evict rows we haven't seen lately so the cache tracks the
|
||||
|
||||
+115
-140
@@ -43,6 +43,26 @@ using Attrs = std::vector<std::pair<std::string, std::string> >;
|
||||
using Item = std::pair<std::string, Attrs>;
|
||||
using ItemStream = std::vector<Item>;
|
||||
|
||||
namespace ZeroTier {
|
||||
|
||||
// Per-thread hand-off backing setPendingCompletion()/takePendingCompletion() (declared in
|
||||
// NotificationListener.hpp). The PubSub subscriber callback sets it around its synchronous
|
||||
// onNotification() call; the commit-queue enqueue path (save/eraseNetwork/eraseMember) takes
|
||||
// it when it defers work, moving ownership into the _queueItem so the commit thread can ack.
|
||||
static thread_local std::shared_ptr<NotificationCompletion> t_pendingCompletion;
|
||||
|
||||
void setPendingCompletion(std::shared_ptr<NotificationCompletion> completion)
|
||||
{
|
||||
t_pendingCompletion = std::move(completion);
|
||||
}
|
||||
|
||||
std::shared_ptr<NotificationCompletion> takePendingCompletion()
|
||||
{
|
||||
return std::move(t_pendingCompletion); // a moved-from shared_ptr is left empty
|
||||
}
|
||||
|
||||
} // namespace ZeroTier
|
||||
|
||||
CentralDB::CentralDB(const Identity& myId,
|
||||
const char* connString,
|
||||
int listenPort,
|
||||
@@ -128,13 +148,6 @@ CentralDB::CentralDB(const Identity& myId,
|
||||
ZTC_LOG("NOTICE: PostgreSQL waiting for initial data download..." ZT_EOL_S);
|
||||
_waitNoticePrinted = true;
|
||||
|
||||
// Zero the watchdog stamps before ANY thread that reads them starts (the
|
||||
// heartbeat thread and the PubSub listeners below both do) -- default-constructed
|
||||
// atomics are indeterminate in C++17.
|
||||
for (int i = 0; i < ZT_CENTRAL_CONTROLLER_COMMIT_THREADS; ++i) {
|
||||
_commitThreadLastActive[i].store(0, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
initializeNetworks();
|
||||
initializeMembers();
|
||||
|
||||
@@ -239,7 +252,7 @@ CentralDB::CentralDB(const Identity& myId,
|
||||
|
||||
// start background threads
|
||||
for (int i = 0; i < ZT_CENTRAL_CONTROLLER_COMMIT_THREADS; ++i) {
|
||||
_commitThread[i] = std::thread(&CentralDB::commitThread, this, (unsigned int)i);
|
||||
_commitThread[i] = std::thread(&CentralDB::commitThread, this);
|
||||
}
|
||||
_onlineNotificationThread = std::thread(&CentralDB::onlineNotificationThread, this);
|
||||
}
|
||||
@@ -286,28 +299,13 @@ bool CentralDB::waitForReady()
|
||||
bool CentralDB::isReady()
|
||||
{ return ((_ready == 2) && (_connected)); }
|
||||
|
||||
bool CentralDB::commitPipelineHealthy()
|
||||
{
|
||||
// Unhealthy when a commit thread has stopped making progress (a wedged thread
|
||||
// means changes it holds -- and, given the shared notification locks, soon the
|
||||
// whole pipeline -- will never commit) or when the queue is too deep to drain
|
||||
// promptly. The PubSub listeners nack in either case so changes are redelivered
|
||||
// instead of acked into a queue that may never apply them.
|
||||
if (_commitQueue.size() >= (size_t)ZT_CENTRAL_CONTROLLER_COMMIT_QUEUE_NACK_DEPTH) {
|
||||
return false;
|
||||
}
|
||||
const int64_t now = OSUtils::now();
|
||||
for (int i = 0; i < ZT_CENTRAL_CONTROLLER_COMMIT_THREADS; ++i) {
|
||||
const int64_t last = _commitThreadLastActive[i].load(std::memory_order_relaxed);
|
||||
if ((last > 0) && ((now - last) > ZT_CENTRAL_CONTROLLER_COMMIT_STALL_WARN_MS)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool CentralDB::save(nlohmann::json& record, bool notifyListeners)
|
||||
{
|
||||
auto provider = opentelemetry::trace::Provider::GetTracerProvider();
|
||||
auto tracer = provider->GetTracer("CentralDB");
|
||||
auto span = tracer->StartSpan("CentralDB::save");
|
||||
auto scope = tracer->WithActiveSpan(span);
|
||||
|
||||
bool modified = false;
|
||||
try {
|
||||
if (! record.is_object()) {
|
||||
@@ -316,6 +314,9 @@ bool CentralDB::save(nlohmann::json& record, bool notifyListeners)
|
||||
}
|
||||
const std::string objtype = OSUtils::jsonString(record["objtype"], "");
|
||||
if (objtype == "network") {
|
||||
auto span = tracer->StartSpan("CentralDB::save::network");
|
||||
auto scope = tracer->WithActiveSpan(span);
|
||||
|
||||
const uint64_t nwid = OSUtils::jsonIntHex(record["id"], 0ULL);
|
||||
if (nwid) {
|
||||
nlohmann::json old;
|
||||
@@ -325,24 +326,23 @@ bool CentralDB::save(nlohmann::json& record, bool notifyListeners)
|
||||
_queueItem qi;
|
||||
qi.jsonData = record;
|
||||
qi.notifyListeners = notifyListeners;
|
||||
// Take the deferred ack (if any) so the commit thread acks/nacks this change
|
||||
// only after the DB write; null for internal (non-PubSub) callers.
|
||||
qi.completion = takePendingCompletion();
|
||||
OtelCarrier<std::map<std::string, std::string> > carrier(qi.traceContext);
|
||||
auto current_ctx = opentelemetry::context::RuntimeContext::GetCurrent();
|
||||
auto propagator =
|
||||
opentelemetry::context::propagation::GlobalTextMapPropagator::GetGlobalPropagator();
|
||||
propagator->Inject(carrier, current_ctx);
|
||||
qi.enqueuedAt = OSUtils::now();
|
||||
_commitQueue.post(qi);
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
else {
|
||||
// Correct to skip (0 is never a valid id) but must never be silent:
|
||||
// a malformed inbound message would otherwise vanish untraceably.
|
||||
ZTC_LOG("WARNING: dropping network save with invalid id '%s'\n",
|
||||
OSUtils::jsonString(record["id"], "?").c_str());
|
||||
}
|
||||
}
|
||||
else if (objtype == "member") {
|
||||
auto span = tracer->StartSpan("CentralDB::save::member");
|
||||
auto scope = tracer->WithActiveSpan(span);
|
||||
|
||||
const uint64_t nwid = OSUtils::jsonIntHex(record["nwid"], 0ULL);
|
||||
const uint64_t id = OSUtils::jsonIntHex(record["id"], 0ULL);
|
||||
if ((id) && (nwid)) {
|
||||
@@ -354,12 +354,14 @@ bool CentralDB::save(nlohmann::json& record, bool notifyListeners)
|
||||
_queueItem qi;
|
||||
qi.jsonData = record;
|
||||
qi.notifyListeners = notifyListeners;
|
||||
// Take the deferred ack (if any) so the commit thread acks/nacks this change
|
||||
// only after the DB write; null for internal (non-PubSub) callers.
|
||||
qi.completion = takePendingCompletion();
|
||||
OtelCarrier<std::map<std::string, std::string> > carrier(qi.traceContext);
|
||||
auto current_ctx = opentelemetry::context::RuntimeContext::GetCurrent();
|
||||
auto propagator =
|
||||
opentelemetry::context::propagation::GlobalTextMapPropagator::GetGlobalPropagator();
|
||||
propagator->Inject(carrier, current_ctx);
|
||||
qi.enqueuedAt = OSUtils::now();
|
||||
_commitQueue.post(qi);
|
||||
|
||||
modified = true;
|
||||
@@ -368,16 +370,9 @@ bool CentralDB::save(nlohmann::json& record, bool notifyListeners)
|
||||
// fprintf(stderr, "no change\n");
|
||||
}
|
||||
}
|
||||
else {
|
||||
// Correct to skip (0 is never a valid member/network id) but must never
|
||||
// be silent -- this is the one path where a delivered-and-acked change
|
||||
// could otherwise vanish with no trace.
|
||||
ZTC_LOG("WARNING: dropping member save with invalid ids: nwid='%s' id='%s'\n",
|
||||
OSUtils::jsonString(record["nwid"], "?").c_str(), OSUtils::jsonString(record["id"], "?").c_str());
|
||||
}
|
||||
}
|
||||
else {
|
||||
ZTC_LOG("WARNING: dropping save with unknown objtype '%s'\n", objtype.c_str());
|
||||
ZTC_LOG("uhh waaat\n");
|
||||
}
|
||||
}
|
||||
catch (std::exception& e) {
|
||||
@@ -391,6 +386,13 @@ bool CentralDB::save(nlohmann::json& record, bool notifyListeners)
|
||||
|
||||
void CentralDB::eraseNetwork(const uint64_t networkId)
|
||||
{
|
||||
auto provider = opentelemetry::trace::Provider::GetTracerProvider();
|
||||
auto tracer = provider->GetTracer("CentralDB");
|
||||
auto span = tracer->StartSpan("CentralDB::eraseNetwork");
|
||||
auto scope = tracer->WithActiveSpan(span);
|
||||
char networkIdStr[17];
|
||||
span->SetAttribute("network_id", Utils::hex(networkId, networkIdStr));
|
||||
|
||||
char tmp2[24];
|
||||
waitForReady();
|
||||
Utils::hex(networkId, tmp2);
|
||||
@@ -399,11 +401,11 @@ void CentralDB::eraseNetwork(const uint64_t networkId)
|
||||
qi.jsonData["id"] = tmp2;
|
||||
qi.jsonData["objtype"] = "_delete_network";
|
||||
qi.notifyListeners = true;
|
||||
qi.completion = takePendingCompletion();
|
||||
OtelCarrier<std::map<std::string, std::string> > carrier(qi.traceContext);
|
||||
auto current_ctx = opentelemetry::context::RuntimeContext::GetCurrent();
|
||||
auto propagator = opentelemetry::context::propagation::GlobalTextMapPropagator::GetGlobalPropagator();
|
||||
propagator->Inject(carrier, current_ctx);
|
||||
qi.enqueuedAt = OSUtils::now();
|
||||
_commitQueue.post(qi);
|
||||
// The commit thread fires _networkChanged once with the real (cached) old config when
|
||||
// it processes the _delete_network item, so we don't notify here — doing so would
|
||||
@@ -412,10 +414,14 @@ void CentralDB::eraseNetwork(const uint64_t networkId)
|
||||
|
||||
void CentralDB::eraseMember(const uint64_t networkId, const uint64_t memberId)
|
||||
{
|
||||
auto provider = opentelemetry::trace::Provider::GetTracerProvider();
|
||||
auto tracer = provider->GetTracer("CentralDB");
|
||||
auto span = tracer->StartSpan("CentralDB::eraseMember");
|
||||
auto scope = tracer->WithActiveSpan(span);
|
||||
char networkIdStr[17];
|
||||
char memberIdStr[11];
|
||||
Utils::hex(networkId, networkIdStr);
|
||||
Utils::hex10(memberId, memberIdStr);
|
||||
span->SetAttribute("network_id", Utils::hex(networkId, networkIdStr));
|
||||
span->SetAttribute("member_id", Utils::hex10(memberId, memberIdStr));
|
||||
|
||||
waitForReady();
|
||||
|
||||
@@ -424,11 +430,11 @@ void CentralDB::eraseMember(const uint64_t networkId, const uint64_t memberId)
|
||||
qi.jsonData["id"] = memberIdStr;
|
||||
qi.jsonData["objtype"] = "_delete_member";
|
||||
qi.notifyListeners = true;
|
||||
qi.completion = takePendingCompletion();
|
||||
OtelCarrier<std::map<std::string, std::string> > carrier(qi.traceContext);
|
||||
auto current_ctx = opentelemetry::context::RuntimeContext::GetCurrent();
|
||||
auto propagator = opentelemetry::context::propagation::GlobalTextMapPropagator::GetGlobalPropagator();
|
||||
propagator->Inject(carrier, current_ctx);
|
||||
qi.enqueuedAt = OSUtils::now();
|
||||
_commitQueue.post(qi);
|
||||
// The commit thread fires _memberChanged once with the real (cached) old config when
|
||||
// it processes the _delete_member item, so we don't notify here — doing so would
|
||||
@@ -440,9 +446,20 @@ void CentralDB::nodeIsOnline(const uint64_t networkId,
|
||||
const InetAddress& physicalAddress,
|
||||
const char* osArch)
|
||||
{
|
||||
auto provider = opentelemetry::trace::Provider::GetTracerProvider();
|
||||
auto tracer = provider->GetTracer("CentralDB");
|
||||
auto span = tracer->StartSpan("CentralDB::nodeIsOnline");
|
||||
auto scope = tracer->WithActiveSpan(span);
|
||||
char networkIdStr[17];
|
||||
char memberIdStr[11];
|
||||
char ipStr[INET6_ADDRSTRLEN];
|
||||
// osArch may be NULL (public virtual taking a raw const char*); guard so the
|
||||
// std::string construction below can't hit UB on the hot heartbeat path.
|
||||
// SetAttribute / std::string construction below can't hit UB on the hot heartbeat path.
|
||||
const char* oa = osArch ? osArch : "unknown/unknown";
|
||||
span->SetAttribute("network_id", Utils::hex(networkId, networkIdStr));
|
||||
span->SetAttribute("member_id", Utils::hex10(memberId, memberIdStr));
|
||||
span->SetAttribute("physical_address", physicalAddress.toString(ipStr));
|
||||
span->SetAttribute("os_arch", oa);
|
||||
|
||||
std::lock_guard<std::mutex> l(_lastOnline_l);
|
||||
NodeOnlineRecord& i = _lastOnline[std::pair<uint64_t, uint64_t>(networkId, memberId)];
|
||||
@@ -923,7 +940,6 @@ void CentralDB::initializeMembers()
|
||||
>
|
||||
row;
|
||||
|
||||
auto tmp = std::chrono::high_resolution_clock::now();
|
||||
uint64_t count = 0;
|
||||
uint64_t total = 0;
|
||||
while (stream >> row) {
|
||||
@@ -1066,7 +1082,6 @@ void CentralDB::initializeMembers()
|
||||
|
||||
void CentralDB::heartbeat()
|
||||
{
|
||||
setCurrentThreadName("ctl-heartbeat");
|
||||
char publicId[1024];
|
||||
char hostnameTmp[1024];
|
||||
_myId.toString(false, publicId);
|
||||
@@ -1139,49 +1154,6 @@ void CentralDB::heartbeat()
|
||||
ZTC_LOG("ERROR: Redis error in heartbeat thread: %s\n", e.what());
|
||||
}
|
||||
|
||||
// Commit-pipeline liveness watchdog. A commit thread that stops making
|
||||
// progress is wedged (historically: a lock cycle in the change-notification
|
||||
// fan-out) and the process becomes a zombie that keeps acking PubSub
|
||||
// messages into a queue nothing drains. Restarting is the only safe
|
||||
// recovery, so log loudly and abort; the orchestrator brings up a fresh pod
|
||||
// that reloads state from the DB.
|
||||
//
|
||||
// Abort only when EVERY started commit thread is past the threshold (the
|
||||
// incident signature: a pipeline-wide freeze) so a single legitimately slow
|
||||
// item -- e.g. the member-deauthorize fan-out of a huge network deletion --
|
||||
// can't kill a controller whose other threads are still draining. A single
|
||||
// thread holding one item hostage far past that (3x) is never legitimate
|
||||
// and also aborts.
|
||||
{
|
||||
const int64_t wdNow = OSUtils::now();
|
||||
int started = 0;
|
||||
int overAbort = 0;
|
||||
int64_t maxStalled = 0;
|
||||
for (int i = 0; i < ZT_CENTRAL_CONTROLLER_COMMIT_THREADS; ++i) {
|
||||
const int64_t last = _commitThreadLastActive[i].load(std::memory_order_relaxed);
|
||||
if (last <= 0)
|
||||
continue;
|
||||
++started;
|
||||
const int64_t stalled = wdNow - last;
|
||||
if (stalled > maxStalled)
|
||||
maxStalled = stalled;
|
||||
if (stalled > ZT_CENTRAL_CONTROLLER_COMMIT_STALL_ABORT_MS)
|
||||
++overAbort;
|
||||
if (stalled > ZT_CENTRAL_CONTROLLER_COMMIT_STALL_WARN_MS) {
|
||||
ZTC_LOG("WARNING: commit thread %d has made no progress in %lld ms (commit queue depth %llu)\n", i,
|
||||
(long long)stalled, (unsigned long long)_commitQueue.size());
|
||||
}
|
||||
}
|
||||
if ((started > 0)
|
||||
&& ((overAbort == started) || (maxStalled > (3LL * ZT_CENTRAL_CONTROLLER_COMMIT_STALL_ABORT_MS)))) {
|
||||
ZTC_LOG("FATAL: commit pipeline wedged (%d/%d threads stalled past %d ms, worst %lld ms, queue depth "
|
||||
"%llu); aborting so the controller restarts instead of silently dropping changes\n",
|
||||
overAbort, started, ZT_CENTRAL_CONTROLLER_COMMIT_STALL_ABORT_MS, (long long)maxStalled,
|
||||
(unsigned long long)_commitQueue.size());
|
||||
abort();
|
||||
}
|
||||
}
|
||||
|
||||
span->End();
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(1000));
|
||||
}
|
||||
@@ -1190,20 +1162,18 @@ void CentralDB::heartbeat()
|
||||
|
||||
void CentralDB::_requeueFailedCommit(_queueItem& qitem)
|
||||
{
|
||||
// The PubSub message that produced this change was already acked when it was
|
||||
// enqueued, so a failed DB write would otherwise be lost. Re-queue it (with
|
||||
// capped backoff) so transient failures -- e.g. an AlloyDB maintenance restart
|
||||
// -- are retried rather than dropped. A genuinely poison item is dropped after
|
||||
// a bounded number of attempts so it can't spin a commit thread forever.
|
||||
// Reached only for INTERNAL (non-PubSub) writes -- items carrying a delivery completion are
|
||||
// nacked for redelivery by _finishCommit instead. Internal writes have no redelivery, so
|
||||
// re-queue them (with capped backoff) to ride out transient DB failures (e.g. an AlloyDB
|
||||
// maintenance restart) rather than dropping them. A genuinely poison item is dropped after a
|
||||
// bounded number of attempts so it can't spin a commit thread forever.
|
||||
if (_run != 1) {
|
||||
// shutting down -- don't requeue
|
||||
return;
|
||||
}
|
||||
if (qitem.retryCount >= ZT_CENTRAL_CONTROLLER_MAX_COMMIT_RETRIES) {
|
||||
ZTC_LOG("ERROR: dropping %s change for nwid=%s id=%s after %d failed commit attempts\n",
|
||||
OSUtils::jsonString(qitem.jsonData["objtype"], "?").c_str(),
|
||||
OSUtils::jsonString(qitem.jsonData["nwid"], "-").c_str(),
|
||||
OSUtils::jsonString(qitem.jsonData["id"], "-").c_str(), qitem.retryCount);
|
||||
ZTC_LOG("ERROR: dropping %s change after %d failed commit attempts\n",
|
||||
OSUtils::jsonString(qitem.jsonData["objtype"], "?").c_str(), qitem.retryCount);
|
||||
return;
|
||||
}
|
||||
++qitem.retryCount;
|
||||
@@ -1224,24 +1194,28 @@ void CentralDB::_requeueFailedCommit(_queueItem& qitem)
|
||||
_commitQueue.post(qitem);
|
||||
}
|
||||
|
||||
void CentralDB::commitThread(unsigned int idx)
|
||||
void CentralDB::_finishCommit(_queueItem& qitem, NotificationResult result)
|
||||
{
|
||||
if (qitem.completion) {
|
||||
// Delivery-backed (PubSub): ack on success / not-applicable, nack to redeliver on a
|
||||
// transient failure. This replaces re-queuing for these items -- Pub/Sub's own
|
||||
// redelivery handles retries, and a persistently failing message is routed to the
|
||||
// dead-letter topic by the subscription policy rather than dropped in-memory.
|
||||
qitem.completion->complete(result);
|
||||
return;
|
||||
}
|
||||
// Internal write (no delivery message): keep the in-memory retry path for transient
|
||||
// failures; nothing to do on success.
|
||||
if (result == NotificationResult::TransientFailure) {
|
||||
_requeueFailedCommit(qitem);
|
||||
}
|
||||
}
|
||||
|
||||
void CentralDB::commitThread()
|
||||
{
|
||||
setCurrentThreadName("ctl-commit");
|
||||
ZTC_LOG("commitThread start\n");
|
||||
_queueItem qitem;
|
||||
while (_run == 1) {
|
||||
// Stamp progress on every pass -- idle timeouts included -- so the watchdog
|
||||
// in heartbeat() can tell a wedged thread from a quiet one.
|
||||
_commitThreadLastActive[idx].store(OSUtils::now(), std::memory_order_relaxed);
|
||||
Metrics::db_commit_queue_size = _commitQueue.size();
|
||||
const BlockingQueue<_queueItem>::TimedWaitResult wr = _commitQueue.get(qitem, 1000);
|
||||
if (wr == BlockingQueue<_queueItem>::STOP)
|
||||
break;
|
||||
if (wr == BlockingQueue<_queueItem>::TIMED_OUT)
|
||||
continue;
|
||||
_commitThreadLastActive[idx].store(OSUtils::now(), std::memory_order_relaxed);
|
||||
if (qitem.enqueuedAt > 0)
|
||||
Metrics::db_commit_latency_ms = OSUtils::now() - qitem.enqueuedAt;
|
||||
while (_commitQueue.get(qitem) && (_run == 1)) {
|
||||
auto provider = opentelemetry::trace::Provider::GetTracerProvider();
|
||||
auto tracer = provider->GetTracer("CentralDB");
|
||||
|
||||
@@ -1258,6 +1232,7 @@ void CentralDB::commitThread(unsigned int idx)
|
||||
|
||||
if (! qitem.jsonData.is_object()) {
|
||||
ZTC_LOG("commitThread tick: skipping non-object queue item\n");
|
||||
_finishCommit(qitem, NotificationResult::Ok);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1283,13 +1258,13 @@ void CentralDB::commitThread(unsigned int idx)
|
||||
}
|
||||
catch (std::exception& e) {
|
||||
ZTC_LOG("ERROR: %s\n", e.what());
|
||||
_requeueFailedCommit(qitem);
|
||||
_finishCommit(qitem, NotificationResult::TransientFailure);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! c) {
|
||||
ZTC_LOG("Error getting database connection\n");
|
||||
_requeueFailedCommit(qitem);
|
||||
_finishCommit(qitem, NotificationResult::TransientFailure);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1318,19 +1293,19 @@ void CentralDB::commitThread(unsigned int idx)
|
||||
target = config["remoteTraceTarget"].get<std::string>();
|
||||
}
|
||||
|
||||
// Get the network's frontend. id is the PK, so this returns at most one
|
||||
// row: present ⇒ the network exists, empty ⇒ it doesn't (a permanent
|
||||
// condition -- e.g. nodes still join-looping a deleted network -- so skip
|
||||
// cleanly rather than throw; the old COUNT+GROUP BY+one_row() form threw
|
||||
// on zero rows, turning every such item into a 100-attempt retry storm
|
||||
// that occupied the commit threads). NULL frontend reads as "".
|
||||
// Get the network's frontend. If the network doesn't exist, this member change
|
||||
// isn't applicable -- it arrived before the network's create (an ordering race)
|
||||
// or after its delete -- so ack-drop it. Treating the absence as a retryable
|
||||
// failure would poison-pill it into repeated redeliveries + a DLQ entry. Use a
|
||||
// plain result + empty() check, not one_row(): a missing network returns zero
|
||||
// rows, which one_row() throws on -> the wrong (retry) path.
|
||||
pqxx::result nwres =
|
||||
w.exec("SELECT frontend FROM networks_ctl WHERE id = $1", pqxx::params { networkId });
|
||||
if (nwres.empty()) {
|
||||
ZTC_LOG("network %s does not exist; skipping member upsert for %s-%s\n", networkId.c_str(),
|
||||
networkId.c_str(), memberId.c_str());
|
||||
ZTC_LOG("network %s does not exist. skipping member upsert\n", networkId.c_str());
|
||||
w.abort();
|
||||
_pool->unborrow(c);
|
||||
_finishCommit(qitem, NotificationResult::Ok);
|
||||
continue;
|
||||
}
|
||||
std::string frontend = nwres[0][0].as<std::optional<std::string> >().value_or("");
|
||||
@@ -1350,11 +1325,11 @@ void CentralDB::commitThread(unsigned int idx)
|
||||
if (! isNewMember && change_source != "controller" && frontend != change_source) {
|
||||
// if it is not a new member and the change source is not the controller and doesn't match
|
||||
// the frontend, don't apply the change.
|
||||
ZTC_LOG("WARNING: dropping member change %s-%s: change_source '%s' does not match network "
|
||||
"frontend '%s'\n",
|
||||
networkId.c_str(), memberId.c_str(), change_source.c_str(), frontend.c_str());
|
||||
w.abort();
|
||||
_pool->unborrow(c);
|
||||
// Change intentionally not applied (network absent, or a non-owning
|
||||
// change source): ack so the message isn't redelivered.
|
||||
_finishCommit(qitem, NotificationResult::Ok);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1508,11 +1483,11 @@ void CentralDB::commitThread(unsigned int idx)
|
||||
if (! isNewNetwork && change_source != "controller" && frontend != change_source) {
|
||||
// if it is not a new network and the change source is not the controller and doesn't match
|
||||
// the frontend, don't apply the change.
|
||||
ZTC_LOG("WARNING: dropping network change %s: change_source '%s' does not match network "
|
||||
"frontend '%s'\n",
|
||||
id.c_str(), change_source.c_str(), frontend.c_str());
|
||||
w.abort();
|
||||
_pool->unborrow(c);
|
||||
// Change intentionally not applied (network absent, or a non-owning
|
||||
// change source): ack so the message isn't redelivered.
|
||||
_finishCommit(qitem, NotificationResult::Ok);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1694,11 +1669,12 @@ void CentralDB::commitThread(unsigned int idx)
|
||||
_pool->unborrow(c);
|
||||
c.reset();
|
||||
|
||||
// Re-queue on a failed DB write so the (already-acked) change isn't lost.
|
||||
// Done after the connection is returned so we never sleep holding one.
|
||||
if (commitFailed) {
|
||||
_requeueFailedCommit(qitem);
|
||||
}
|
||||
// Resolve the item after the DB write, once the connection is returned (so we
|
||||
// never sleep holding one): ack a PubSub-backed change on success / nack on a
|
||||
// transient failure to redeliver, or re-queue an internal write. This replaces
|
||||
// the old unconditional re-queue -- with ack-after-commit the change is no longer
|
||||
// acked at enqueue time, so a failed write is redelivered rather than lost.
|
||||
_finishCommit(qitem, commitFailed ? NotificationResult::TransientFailure : NotificationResult::Ok);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1707,7 +1683,6 @@ void CentralDB::commitThread(unsigned int idx)
|
||||
|
||||
void CentralDB::onlineNotificationThread()
|
||||
{
|
||||
setCurrentThreadName("ctl-online");
|
||||
waitForReady();
|
||||
while (_run == 1) {
|
||||
{
|
||||
|
||||
@@ -9,25 +9,12 @@
|
||||
|
||||
#define ZT_CENTRAL_CONTROLLER_COMMIT_THREADS 8
|
||||
|
||||
// Max times a change is re-queued after a failed DB write before it is dropped.
|
||||
// Stopgap durability: the PubSub message was already acked at enqueue time, so a
|
||||
// dropped commit would otherwise be lost. Re-queuing rides out transient DB
|
||||
// outages (e.g. an AlloyDB maintenance restart) at the cost of in-memory buffering.
|
||||
// Max times an INTERNAL (non-PubSub) write is re-queued after a failed DB write before it is
|
||||
// dropped. PubSub-sourced changes are acked/nacked after the commit (ack-after-commit): a failed
|
||||
// write nacks so Pub/Sub redelivers, and the subscription's dead-letter policy bounds retries --
|
||||
// so this cap governs only internal writes, which have no redelivery and are buffered in memory.
|
||||
#define ZT_CENTRAL_CONTROLLER_MAX_COMMIT_RETRIES 100
|
||||
|
||||
// Commit-thread liveness watchdog thresholds. A commit thread that stops making
|
||||
// progress (historically: wedged on a lock cycle in the change-notification
|
||||
// fan-out) turns the controller into a zombie that keeps acking PubSub messages
|
||||
// into a queue nothing drains. heartbeat() warns past the first threshold and
|
||||
// aborts the process past the second so the orchestrator restarts it.
|
||||
#define ZT_CENTRAL_CONTROLLER_COMMIT_STALL_WARN_MS 60000
|
||||
#define ZT_CENTRAL_CONTROLLER_COMMIT_STALL_ABORT_MS 300000
|
||||
|
||||
// Above this commit-queue depth the PubSub listeners nack instead of enqueueing:
|
||||
// the backlog is beyond prompt drainage and an acked-but-uncommitted change is
|
||||
// lost if the process dies. Redelivery retries once the queue recovers.
|
||||
#define ZT_CENTRAL_CONTROLLER_COMMIT_QUEUE_NACK_DEPTH 50000
|
||||
|
||||
#include "../../node/Metrics.hpp"
|
||||
#include "ConnectionPool.hpp"
|
||||
#include "DB.hpp"
|
||||
@@ -35,7 +22,6 @@
|
||||
#include "PostgreSQL.hpp"
|
||||
#include "StatusWriter.hpp"
|
||||
|
||||
#include <atomic>
|
||||
#include <condition_variable>
|
||||
#include <memory>
|
||||
#include <pqxx/pqxx>
|
||||
@@ -44,7 +30,7 @@
|
||||
namespace ZeroTier {
|
||||
struct RedisConfig;
|
||||
struct ControllerConfig;
|
||||
struct ControllerChangeNotifier;
|
||||
class ControllerChangeNotifier;
|
||||
class PubSubWriter;
|
||||
|
||||
class CentralDB : public DB {
|
||||
@@ -71,7 +57,6 @@ class CentralDB : public DB {
|
||||
|
||||
virtual bool waitForReady();
|
||||
virtual bool isReady();
|
||||
virtual bool commitPipelineHealthy();
|
||||
virtual bool save(nlohmann::json& record, bool notifyListeners);
|
||||
virtual void eraseNetwork(const uint64_t networkId);
|
||||
virtual void eraseMember(const uint64_t networkId, const uint64_t memberId);
|
||||
@@ -102,7 +87,7 @@ class CentralDB : public DB {
|
||||
void initializeMembers();
|
||||
void heartbeat();
|
||||
|
||||
void commitThread(unsigned int idx);
|
||||
void commitThread();
|
||||
void onlineNotificationThread();
|
||||
|
||||
nlohmann::json _getNetworkMember(pqxx::work& tx, const std::string networkID, const std::string memberID);
|
||||
@@ -124,7 +109,7 @@ class CentralDB : public DB {
|
||||
std::string _connString;
|
||||
|
||||
struct _queueItem {
|
||||
_queueItem() : jsonData(), notifyListeners(false), traceContext(), retryCount(0), enqueuedAt(0)
|
||||
_queueItem() : jsonData(), notifyListeners(false), traceContext(), retryCount(0), completion()
|
||||
{
|
||||
}
|
||||
|
||||
@@ -136,16 +121,25 @@ class CentralDB : public DB {
|
||||
bool notifyListeners;
|
||||
std::map<std::string, std::string> traceContext;
|
||||
int retryCount;
|
||||
// First-enqueue time; retries keep the original so the commit-latency gauge
|
||||
// reflects the full enqueue-to-commit delay.
|
||||
int64_t enqueuedAt;
|
||||
// Deferred ack for the delivery mechanism (PubSub). Null for internal writes
|
||||
// (no delivery message) and for backends without redelivery semantics. When set,
|
||||
// the commit thread completes it (ack on success, nack on transient failure)
|
||||
// instead of re-queuing -- Pub/Sub's own redelivery replaces _requeueFailedCommit.
|
||||
std::shared_ptr<NotificationCompletion> completion;
|
||||
};
|
||||
BlockingQueue<_queueItem> _commitQueue;
|
||||
|
||||
// Re-queue a change whose DB write failed, with capped backoff, until it
|
||||
// succeeds or ZT_CENTRAL_CONTROLLER_MAX_COMMIT_RETRIES attempts is reached.
|
||||
// Used only for items with no delivery completion (internal writes); items that
|
||||
// carry one are nacked for redelivery instead (see _finishCommit).
|
||||
void _requeueFailedCommit(_queueItem& qitem);
|
||||
|
||||
// Resolve a commit-queue item's outcome exactly once: if it carries a delivery
|
||||
// completion (PubSub), ack on Ok/PermanentFailure or nack on TransientFailure;
|
||||
// otherwise fall back to _requeueFailedCommit on TransientFailure (internal writes).
|
||||
void _finishCommit(_queueItem& qitem, NotificationResult result);
|
||||
|
||||
std::thread _heartbeatThread;
|
||||
std::shared_ptr<NotificationListener> _membersDbWatcher;
|
||||
std::shared_ptr<NotificationListener> _networksDbWatcher;
|
||||
@@ -154,9 +148,6 @@ class CentralDB : public DB {
|
||||
std::shared_ptr<NotificationListener> _ssoAuthListener;
|
||||
std::shared_ptr<PubSubWriter> _ssoNonceWriter;
|
||||
std::thread _commitThread[ZT_CENTRAL_CONTROLLER_COMMIT_THREADS];
|
||||
// Per-commit-thread last-progress timestamps for the liveness watchdog in
|
||||
// heartbeat(). Stamped every loop pass (idle timeouts included).
|
||||
std::atomic<int64_t> _commitThreadLastActive[ZT_CENTRAL_CONTROLLER_COMMIT_THREADS];
|
||||
std::thread _onlineNotificationThread;
|
||||
|
||||
std::unordered_map<std::pair<uint64_t, uint64_t>, NodeOnlineRecord, _PairHasher> _lastOnline;
|
||||
|
||||
@@ -2,19 +2,8 @@
|
||||
* See LICENSE.txt in nonfree/
|
||||
*/
|
||||
|
||||
// The two-argument pthread_setname_np is glibc's GNU extension; the strict
|
||||
// -std=c++17 make build doesn't define _GNU_SOURCE for us (the CMake build's
|
||||
// gnu++17 does). Must precede every include.
|
||||
#if defined(__linux__) && ! defined(_GNU_SOURCE)
|
||||
#define _GNU_SOURCE
|
||||
#endif
|
||||
|
||||
#include "CtlUtil.hpp"
|
||||
|
||||
#ifndef _WIN32
|
||||
#include <pthread.h>
|
||||
#endif
|
||||
|
||||
namespace ZeroTier {
|
||||
|
||||
// Defined unconditionally (outside ZT_CONTROLLER_USE_LIBPQ): the always-compiled controller
|
||||
@@ -28,18 +17,6 @@ void setControllerLogId(const std::string& id)
|
||||
const char* controllerLogId()
|
||||
{ return s_controllerLogId.empty() ? "----------" : s_controllerLogId.c_str(); }
|
||||
|
||||
void setCurrentThreadName(const char* name)
|
||||
{
|
||||
// Linux truncates at 15 chars + NUL; keep names within that.
|
||||
#if defined(__APPLE__)
|
||||
pthread_setname_np(name);
|
||||
#elif defined(__linux__)
|
||||
pthread_setname_np(pthread_self(), name);
|
||||
#else
|
||||
(void)name;
|
||||
#endif
|
||||
}
|
||||
|
||||
} // namespace ZeroTier
|
||||
|
||||
#ifdef ZT_CONTROLLER_USE_LIBPQ
|
||||
|
||||
@@ -20,10 +20,6 @@ const char* _timestr();
|
||||
void setControllerLogId(const std::string& id);
|
||||
const char* controllerLogId();
|
||||
|
||||
// Name the calling thread so /proc wait-channel and stack dumps can identify the
|
||||
// controller's thread pools. Linux limits names to 15 characters.
|
||||
void setCurrentThreadName(const char* name);
|
||||
|
||||
std::vector<std::string> split(std::string str, char delim);
|
||||
|
||||
std::string url_encode(const std::string& value);
|
||||
|
||||
+93
-20
@@ -19,6 +19,11 @@ namespace ZeroTier {
|
||||
|
||||
void DB::initNetwork(nlohmann::json& network)
|
||||
{
|
||||
auto provider = opentelemetry::trace::Provider::GetTracerProvider();
|
||||
auto tracer = provider->GetTracer("db");
|
||||
auto span = tracer->StartSpan("db::initNetwork");
|
||||
auto scope = tracer->WithActiveSpan(span);
|
||||
|
||||
if (! network.count("private"))
|
||||
network["private"] = true;
|
||||
if (! network.count("creationTime"))
|
||||
@@ -77,6 +82,11 @@ void DB::initNetwork(nlohmann::json& network)
|
||||
|
||||
void DB::initMember(nlohmann::json& member)
|
||||
{
|
||||
auto provider = opentelemetry::trace::Provider::GetTracerProvider();
|
||||
auto tracer = provider->GetTracer("db");
|
||||
auto span = tracer->StartSpan("db::initMember");
|
||||
auto scope = tracer->WithActiveSpan(span);
|
||||
|
||||
if (! member.count("authorized"))
|
||||
member["authorized"] = false;
|
||||
if (! member.count("ssoExempt"))
|
||||
@@ -94,12 +104,7 @@ void DB::initMember(nlohmann::json& member)
|
||||
if (! member.count("noAutoAssignIps"))
|
||||
member["noAutoAssignIps"] = false;
|
||||
if (! member.count("revision"))
|
||||
// Start at 1 (matching Central's ztc_member.revision DEFAULT 1) so the first
|
||||
// save() bumps to 2 and the resulting echo passes Central's strictly-less-than
|
||||
// revision guard against a manually created row's default of 1. With 0 here the
|
||||
// first echo carried 1 and 1 < 1 never applied -- identity/revision for manually
|
||||
// created members never synced back to Central.
|
||||
member["revision"] = 1ULL;
|
||||
member["revision"] = 0ULL;
|
||||
if (! member.count("lastDeauthorizedTime"))
|
||||
member["lastDeauthorizedTime"] = 0ULL;
|
||||
if (! member.count("lastAuthorizedTime"))
|
||||
@@ -127,6 +132,11 @@ void DB::initMember(nlohmann::json& member)
|
||||
|
||||
void DB::cleanNetwork(nlohmann::json& network)
|
||||
{
|
||||
auto provider = opentelemetry::trace::Provider::GetTracerProvider();
|
||||
auto tracer = provider->GetTracer("db");
|
||||
auto span = tracer->StartSpan("db::cleanNetwork");
|
||||
auto scope = tracer->WithActiveSpan(span);
|
||||
|
||||
network.erase("clock");
|
||||
network.erase("authorizedMemberCount");
|
||||
network.erase("activeMemberCount");
|
||||
@@ -136,6 +146,11 @@ void DB::cleanNetwork(nlohmann::json& network)
|
||||
|
||||
void DB::cleanMember(nlohmann::json& member)
|
||||
{
|
||||
auto provider = opentelemetry::trace::Provider::GetTracerProvider();
|
||||
auto tracer = provider->GetTracer("db");
|
||||
auto span = tracer->StartSpan("db::cleanMember");
|
||||
auto scope = tracer->WithActiveSpan(span);
|
||||
|
||||
member.erase("clock");
|
||||
member.erase("physicalAddr");
|
||||
member.erase("recentLog");
|
||||
@@ -143,12 +158,6 @@ void DB::cleanMember(nlohmann::json& member)
|
||||
member.erase("lastRequestMetaData");
|
||||
member.erase("authenticationURL"); // computed
|
||||
member.erase("authenticationClientID"); // computed
|
||||
// Provenance of the PREVIOUS write, not of this one. A record read from the
|
||||
// cache can carry the frontend's change_source (e.g. "cv1"); a controller save
|
||||
// that inherits it is mis-attributed downstream -- CentralDB skips the
|
||||
// controller->Central echo for non-controller sources, silently desyncing the
|
||||
// frontend. Saves that don't set change_source default to "controller".
|
||||
member.erase("change_source");
|
||||
}
|
||||
|
||||
DB::DB()
|
||||
@@ -160,6 +169,14 @@ DB::~DB()
|
||||
|
||||
bool DB::get(const uint64_t networkId, nlohmann::json& network)
|
||||
{
|
||||
auto provider = opentelemetry::trace::Provider::GetTracerProvider();
|
||||
auto tracer = provider->GetTracer("db");
|
||||
auto span = tracer->StartSpan("db::getNetwork");
|
||||
auto scope = tracer->WithActiveSpan(span);
|
||||
char networkIdStr[32];
|
||||
memset(networkIdStr, 0, sizeof(networkIdStr));
|
||||
span->SetAttribute("network_id", Utils::hex(networkId, networkIdStr));
|
||||
|
||||
waitForReady();
|
||||
Metrics::db_get_network++;
|
||||
std::shared_ptr<_Network> nw;
|
||||
@@ -179,6 +196,17 @@ bool DB::get(const uint64_t networkId, nlohmann::json& network)
|
||||
|
||||
bool DB::get(const uint64_t networkId, nlohmann::json& network, const uint64_t memberId, nlohmann::json& member)
|
||||
{
|
||||
auto provider = opentelemetry::trace::Provider::GetTracerProvider();
|
||||
auto tracer = provider->GetTracer("db");
|
||||
auto span = tracer->StartSpan("db::getNetworkAndMember");
|
||||
auto scope = tracer->WithActiveSpan(span);
|
||||
char networkIdStr[32];
|
||||
memset(networkIdStr, 0, sizeof(networkIdStr));
|
||||
char memberIdStr[32];
|
||||
memset(memberIdStr, 0, sizeof(memberIdStr));
|
||||
span->SetAttribute("network_id", Utils::hex(networkId, networkIdStr));
|
||||
span->SetAttribute("member_id", Utils::hex(networkId, memberIdStr));
|
||||
|
||||
waitForReady();
|
||||
Metrics::db_get_network_and_member++;
|
||||
std::shared_ptr<_Network> nw;
|
||||
@@ -207,6 +235,17 @@ bool DB::get(
|
||||
nlohmann::json& member,
|
||||
NetworkSummaryInfo& info)
|
||||
{
|
||||
auto provider = opentelemetry::trace::Provider::GetTracerProvider();
|
||||
auto tracer = provider->GetTracer("db");
|
||||
auto span = tracer->StartSpan("db::getNetworkAndMemberAndSummary");
|
||||
auto scope = tracer->WithActiveSpan(span);
|
||||
char networkIdStr[32];
|
||||
memset(networkIdStr, 0, sizeof(networkIdStr));
|
||||
char memberIdStr[32];
|
||||
memset(memberIdStr, 0, sizeof(memberIdStr));
|
||||
span->SetAttribute("network_id", Utils::hex(networkId, networkIdStr));
|
||||
span->SetAttribute("member_id", Utils::hex(memberId, memberIdStr));
|
||||
|
||||
waitForReady();
|
||||
Metrics::db_get_network_and_member_and_summary++;
|
||||
std::shared_ptr<_Network> nw;
|
||||
@@ -232,6 +271,14 @@ bool DB::get(
|
||||
|
||||
bool DB::get(const uint64_t networkId, nlohmann::json& network, std::vector<nlohmann::json>& members)
|
||||
{
|
||||
auto provider = opentelemetry::trace::Provider::GetTracerProvider();
|
||||
auto tracer = provider->GetTracer("db");
|
||||
auto span = tracer->StartSpan("db::getNetworkAndMembers");
|
||||
auto scope = tracer->WithActiveSpan(span);
|
||||
char networkIdStr[32];
|
||||
memset(networkIdStr, 0, sizeof(networkIdStr));
|
||||
span->SetAttribute("network_id", Utils::hex(networkId, networkIdStr));
|
||||
|
||||
waitForReady();
|
||||
Metrics::db_get_member_list++;
|
||||
std::shared_ptr<_Network> nw;
|
||||
@@ -254,6 +301,11 @@ bool DB::get(const uint64_t networkId, nlohmann::json& network, std::vector<nloh
|
||||
|
||||
void DB::networks(std::set<uint64_t>& networks)
|
||||
{
|
||||
auto provider = opentelemetry::trace::Provider::GetTracerProvider();
|
||||
auto tracer = provider->GetTracer("db");
|
||||
auto span = tracer->StartSpan("db::networks");
|
||||
auto scope = tracer->WithActiveSpan(span);
|
||||
|
||||
waitForReady();
|
||||
Metrics::db_get_network_list++;
|
||||
std::shared_lock<std::shared_mutex> l(_networks_l);
|
||||
@@ -263,6 +315,11 @@ void DB::networks(std::set<uint64_t>& networks)
|
||||
|
||||
void DB::_memberChanged(nlohmann::json& old, nlohmann::json& memberConfig, bool notifyListeners)
|
||||
{
|
||||
auto provider = opentelemetry::trace::Provider::GetTracerProvider();
|
||||
auto tracer = provider->GetTracer("db");
|
||||
auto span = tracer->StartSpan("db::_memberChanged");
|
||||
auto scope = tracer->WithActiveSpan(span);
|
||||
|
||||
Metrics::db_member_change++;
|
||||
uint64_t memberId = 0;
|
||||
uint64_t networkId = 0;
|
||||
@@ -353,8 +410,8 @@ void DB::_memberChanged(nlohmann::json& old, nlohmann::json& memberConfig, bool
|
||||
}
|
||||
|
||||
if (notifyListeners) {
|
||||
const std::vector<DB::ChangeListener*> listeners(_listenersSnapshot());
|
||||
for (auto i = listeners.begin(); i != listeners.end(); ++i) {
|
||||
std::unique_lock<std::shared_mutex> ll(_changeListeners_l);
|
||||
for (auto i = _changeListeners.begin(); i != _changeListeners.end(); ++i) {
|
||||
(*i)->onNetworkMemberUpdate(this, networkId, memberId, memberConfig);
|
||||
}
|
||||
}
|
||||
@@ -398,8 +455,8 @@ void DB::_memberChanged(nlohmann::json& old, nlohmann::json& memberConfig, bool
|
||||
}
|
||||
|
||||
if ((notifyListeners) && ((wasAuth) && (! isAuth) && (networkId) && (memberId))) {
|
||||
const std::vector<DB::ChangeListener*> listeners(_listenersSnapshot());
|
||||
for (auto i = listeners.begin(); i != listeners.end(); ++i) {
|
||||
std::unique_lock<std::shared_mutex> ll(_changeListeners_l);
|
||||
for (auto i = _changeListeners.begin(); i != _changeListeners.end(); ++i) {
|
||||
(*i)->onNetworkMemberDeauthorize(this, networkId, memberId);
|
||||
}
|
||||
}
|
||||
@@ -407,6 +464,17 @@ void DB::_memberChanged(nlohmann::json& old, nlohmann::json& memberConfig, bool
|
||||
|
||||
void DB::_networkChanged(nlohmann::json& old, nlohmann::json& networkConfig, bool notifyListeners)
|
||||
{
|
||||
auto provider = opentelemetry::trace::Provider::GetTracerProvider();
|
||||
auto tracer = provider->GetTracer("db");
|
||||
auto span = tracer->StartSpan("db::_networkChanged");
|
||||
// The full network config is intentionally NOT attached to this span.
|
||||
// old.dump()/networkConfig.dump() serialize the entire (potentially multi-hundred-KB)
|
||||
// config on every network change; carrying that on sampled spans is a large, low-value
|
||||
// memory cost, and it was being built even for un-sampled NoopSpans. The config is always
|
||||
// available in the DB if it's ever needed for debugging.
|
||||
span->SetAttribute("notify_listeners", notifyListeners);
|
||||
auto scope = tracer->WithActiveSpan(span);
|
||||
|
||||
Metrics::db_network_change++;
|
||||
if (notifyListeners) {
|
||||
if (old.is_object() && old.contains("id") && networkConfig.is_object() && networkConfig.contains("id")) {
|
||||
@@ -437,8 +505,8 @@ void DB::_networkChanged(nlohmann::json& old, nlohmann::json& networkConfig, boo
|
||||
nw->config = networkConfig;
|
||||
}
|
||||
if (notifyListeners) {
|
||||
const std::vector<DB::ChangeListener*> listeners(_listenersSnapshot());
|
||||
for (auto i = listeners.begin(); i != listeners.end(); ++i) {
|
||||
std::unique_lock<std::shared_mutex> ll(_changeListeners_l);
|
||||
for (auto i = _changeListeners.begin(); i != _changeListeners.end(); ++i) {
|
||||
(*i)->onNetworkUpdate(this, networkId, networkConfig);
|
||||
}
|
||||
}
|
||||
@@ -453,14 +521,14 @@ void DB::_networkChanged(nlohmann::json& old, nlohmann::json& networkConfig, boo
|
||||
nlohmann::json network;
|
||||
std::vector<nlohmann::json> members;
|
||||
this->get(networkId, network, members);
|
||||
const std::vector<DB::ChangeListener*> listeners(_listenersSnapshot());
|
||||
for (auto i = members.begin(); i != members.end(); ++i) {
|
||||
const std::string nodeID = (*i)["id"];
|
||||
fprintf(
|
||||
stderr, "Deauthorizing member %s on network %s due to network deletion\n", nodeID.c_str(),
|
||||
ids.c_str());
|
||||
const uint64_t memberId = Utils::hexStrToU64(nodeID.c_str());
|
||||
for (auto j = listeners.begin(); j != listeners.end(); ++j) {
|
||||
std::unique_lock<std::shared_mutex> ll(_changeListeners_l);
|
||||
for (auto j = _changeListeners.begin(); j != _changeListeners.end(); ++j) {
|
||||
fprintf(
|
||||
stderr, "Notifying listener of deauthorization of %s on %s\n", nodeID.c_str(), ids.c_str());
|
||||
(*j)->onNetworkMemberDeauthorize(this, networkId, memberId);
|
||||
@@ -481,6 +549,11 @@ void DB::_networkChanged(nlohmann::json& old, nlohmann::json& networkConfig, boo
|
||||
|
||||
void DB::_fillSummaryInfo(const std::shared_ptr<_Network>& nw, NetworkSummaryInfo& info)
|
||||
{
|
||||
auto provider = opentelemetry::trace::Provider::GetTracerProvider();
|
||||
auto tracer = provider->GetTracer("db");
|
||||
auto span = tracer->StartSpan("db::_fillSummaryInfo");
|
||||
auto scope = tracer->WithActiveSpan(span);
|
||||
|
||||
for (auto ab = nw->activeBridgeMembers.begin(); ab != nw->activeBridgeMembers.end(); ++ab)
|
||||
info.activeBridges.push_back(Address(*ab));
|
||||
std::sort(info.activeBridges.begin(), info.activeBridges.end());
|
||||
|
||||
@@ -94,15 +94,6 @@ class DB {
|
||||
virtual bool waitForReady() = 0;
|
||||
virtual bool isReady() = 0;
|
||||
|
||||
// Whether the backend can promptly and durably apply changes handed to save().
|
||||
// Backends with an async commit pipeline override this so message listeners can
|
||||
// defer (nack for redelivery) instead of acknowledging changes into a stalled or
|
||||
// drowning queue, where they would be lost if the process died or wedged.
|
||||
virtual bool commitPipelineHealthy()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
inline bool hasNetwork(const uint64_t networkId) const
|
||||
{
|
||||
std::shared_lock<std::shared_mutex> l(_networks_l);
|
||||
@@ -149,18 +140,6 @@ class DB {
|
||||
_changeListeners.push_back(listener);
|
||||
}
|
||||
|
||||
// Snapshot the listener list so callbacks can be invoked without holding
|
||||
// _changeListeners_l. Listener callbacks fan out into the controller (mirror
|
||||
// saves, netconf re-requests) and must never run under this lock: one blocked
|
||||
// callback would otherwise wedge every thread that needs to notify, which is
|
||||
// how the whole commit pipeline froze in prod. Listeners are only ever added,
|
||||
// so the snapshotted pointers stay valid.
|
||||
inline std::vector<DB::ChangeListener*> _listenersSnapshot() const
|
||||
{
|
||||
std::shared_lock<std::shared_mutex> l(_changeListeners_l);
|
||||
return _changeListeners;
|
||||
}
|
||||
|
||||
virtual void _memberChanged(nlohmann::json& old, nlohmann::json& memberConfig, bool notifyListeners);
|
||||
virtual void _networkChanged(nlohmann::json& old, nlohmann::json& networkConfig, bool notifyListeners);
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
|
||||
#include "DBMirrorSet.hpp"
|
||||
|
||||
#include "CtlUtil.hpp"
|
||||
#include "opentelemetry/trace/provider.h"
|
||||
|
||||
namespace ZeroTier {
|
||||
@@ -12,7 +11,6 @@ namespace ZeroTier {
|
||||
DBMirrorSet::DBMirrorSet(DB::ChangeListener* listener) : _listener(listener), _running(true), _syncCheckerThread(), _dbs(), _dbs_l()
|
||||
{
|
||||
_syncCheckerThread = std::thread([this]() {
|
||||
setCurrentThreadName("ctl-dbsync");
|
||||
for (;;) {
|
||||
for (int i = 0; i < 120; ++i) { // 1 minute delay between checks
|
||||
if (! _running)
|
||||
@@ -232,7 +230,7 @@ bool DBMirrorSet::save(nlohmann::json& record, bool notifyListeners)
|
||||
|
||||
std::vector<std::shared_ptr<DB> > dbs;
|
||||
{
|
||||
std::shared_lock<std::shared_mutex> l(_dbs_l);
|
||||
std::unique_lock<std::shared_mutex> l(_dbs_l);
|
||||
dbs = _dbs;
|
||||
}
|
||||
if (notifyListeners) {
|
||||
@@ -260,12 +258,8 @@ void DBMirrorSet::eraseNetwork(const uint64_t networkId)
|
||||
char networkIdStr[17];
|
||||
span->SetAttribute("network_id", Utils::hex(networkId, networkIdStr));
|
||||
|
||||
std::vector<std::shared_ptr<DB> > dbs;
|
||||
{
|
||||
std::shared_lock<std::shared_mutex> l(_dbs_l);
|
||||
dbs = _dbs;
|
||||
}
|
||||
for (auto d = dbs.begin(); d != dbs.end(); ++d) {
|
||||
std::unique_lock<std::shared_mutex> l(_dbs_l);
|
||||
for (auto d = _dbs.begin(); d != _dbs.end(); ++d) {
|
||||
(*d)->eraseNetwork(networkId);
|
||||
}
|
||||
}
|
||||
@@ -281,12 +275,8 @@ void DBMirrorSet::eraseMember(const uint64_t networkId, const uint64_t memberId)
|
||||
span->SetAttribute("network_id", Utils::hex(networkId, networkIdStr));
|
||||
span->SetAttribute("member_id", Utils::hex10(memberId, memberIdStr));
|
||||
|
||||
std::vector<std::shared_ptr<DB> > dbs;
|
||||
{
|
||||
std::shared_lock<std::shared_mutex> l(_dbs_l);
|
||||
dbs = _dbs;
|
||||
}
|
||||
for (auto d = dbs.begin(); d != dbs.end(); ++d) {
|
||||
std::unique_lock<std::shared_mutex> l(_dbs_l);
|
||||
for (auto d = _dbs.begin(); d != _dbs.end(); ++d) {
|
||||
(*d)->eraseMember(networkId, memberId);
|
||||
}
|
||||
}
|
||||
@@ -325,17 +315,9 @@ void DBMirrorSet::onNetworkUpdate(const void* db, uint64_t networkId, const nloh
|
||||
char networkIdStr[17];
|
||||
span->SetAttribute("network_id", Utils::hex(networkId, networkIdStr));
|
||||
|
||||
// Snapshot the db list and call save()/the listener with _dbs_l released.
|
||||
// This runs on the DB commit threads; holding _dbs_l (a writer lock) across
|
||||
// the fan-out blocks every reader (netconf gets/saves) and, if the listener
|
||||
// callback ever stalls, freezes the whole commit pipeline.
|
||||
nlohmann::json record(network);
|
||||
std::vector<std::shared_ptr<DB> > dbs;
|
||||
{
|
||||
std::shared_lock<std::shared_mutex> l(_dbs_l);
|
||||
dbs = _dbs;
|
||||
}
|
||||
for (auto d = dbs.begin(); d != dbs.end(); ++d) {
|
||||
std::unique_lock<std::shared_mutex> l(_dbs_l);
|
||||
for (auto d = _dbs.begin(); d != _dbs.end(); ++d) {
|
||||
if (d->get() != db) {
|
||||
(*d)->save(record, false);
|
||||
}
|
||||
@@ -354,14 +336,9 @@ void DBMirrorSet::onNetworkMemberUpdate(const void* db, uint64_t networkId, uint
|
||||
span->SetAttribute("network_id", Utils::hex(networkId, networkIdStr));
|
||||
span->SetAttribute("member_id", Utils::hex10(memberId, memberIdStr));
|
||||
|
||||
// Same pattern as onNetworkUpdate: fan out with _dbs_l released.
|
||||
nlohmann::json record(member);
|
||||
std::vector<std::shared_ptr<DB> > dbs;
|
||||
{
|
||||
std::shared_lock<std::shared_mutex> l(_dbs_l);
|
||||
dbs = _dbs;
|
||||
}
|
||||
for (auto d = dbs.begin(); d != dbs.end(); ++d) {
|
||||
std::unique_lock<std::shared_mutex> l(_dbs_l);
|
||||
for (auto d = _dbs.begin(); d != _dbs.end(); ++d) {
|
||||
if (d->get() != db) {
|
||||
(*d)->save(record, false);
|
||||
}
|
||||
|
||||
@@ -262,6 +262,11 @@ static json _renderRule(ZT_VirtualNetworkRule& rule)
|
||||
|
||||
static bool _parseRule(json& r, ZT_VirtualNetworkRule& rule)
|
||||
{
|
||||
auto provider = opentelemetry::trace::Provider::GetTracerProvider();
|
||||
auto tracer = provider->GetTracer("embedded_controller");
|
||||
auto span = tracer->StartSpan("embedded_controller::parseRule");
|
||||
auto scope = tracer->WithActiveSpan(span);
|
||||
|
||||
if (! r.is_object())
|
||||
return false;
|
||||
|
||||
@@ -1653,34 +1658,12 @@ void EmbeddedNetworkController::onNetworkUpdate(const void* db, uint64_t network
|
||||
auto span = tracer->StartSpan("embedded_controller::onNetworkUpdate");
|
||||
auto scope = tracer->WithActiveSpan(span);
|
||||
|
||||
// Send an update to all members of the network that are online.
|
||||
//
|
||||
// Collect the targets first and re-request with _memberStatus_l released. This
|
||||
// callback runs on the DB commit threads (via _memberChanged), and both the
|
||||
// netconf workers and request() itself need _memberStatus_l -- holding it
|
||||
// across a full-network fan-out stalls them, and any stall here freezes the
|
||||
// commit pipeline behind the notification locks.
|
||||
// Send an update to all members of the network that are online
|
||||
const int64_t now = OSUtils::now();
|
||||
std::vector<_MemberStatusKey> targets;
|
||||
{
|
||||
std::lock_guard<std::mutex> l(_memberStatus_l);
|
||||
for (auto i = _memberStatus.begin(); i != _memberStatus.end(); ++i) {
|
||||
if ((i->first.networkId == networkId) && (i->second.online(now)) && (i->second.lastRequestMetaData))
|
||||
targets.push_back(i->first);
|
||||
}
|
||||
}
|
||||
for (auto k = targets.begin(); k != targets.end(); ++k) {
|
||||
Identity id;
|
||||
std::unique_ptr<Dictionary<ZT_NETWORKCONFIG_METADATA_DICT_CAPACITY> > md;
|
||||
{
|
||||
std::lock_guard<std::mutex> l(_memberStatus_l);
|
||||
auto ms = _memberStatus.find(*k);
|
||||
if ((ms == _memberStatus.end()) || (! ms->second.online(now)) || (! ms->second.lastRequestMetaData))
|
||||
continue;
|
||||
id = ms->second.identity;
|
||||
md.reset(new Dictionary<ZT_NETWORKCONFIG_METADATA_DICT_CAPACITY>(ms->second.lastRequestMetaData));
|
||||
}
|
||||
request(networkId, InetAddress(), 0, id, *md);
|
||||
std::lock_guard<std::mutex> l(_memberStatus_l);
|
||||
for (auto i = _memberStatus.begin(); i != _memberStatus.end(); ++i) {
|
||||
if ((i->first.networkId == networkId) && (i->second.online(now)) && (i->second.lastRequestMetaData))
|
||||
request(networkId, InetAddress(), 0, i->second.identity, i->second.lastRequestMetaData);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1695,23 +1678,12 @@ void EmbeddedNetworkController::onNetworkMemberUpdate(
|
||||
auto span = tracer->StartSpan("embedded_controller::onNetworkMemberUpdate");
|
||||
auto scope = tracer->WithActiveSpan(span);
|
||||
|
||||
// Push update to member if online. Copy what request() needs and call it with
|
||||
// _memberStatus_l released -- this runs on the DB commit threads and must not
|
||||
// hold member-status locks across the netconf machinery. find() instead of
|
||||
// operator[] so members we have never heard from don't get empty entries.
|
||||
// Push update to member if online
|
||||
try {
|
||||
Identity id;
|
||||
std::unique_ptr<Dictionary<ZT_NETWORKCONFIG_METADATA_DICT_CAPACITY> > md;
|
||||
{
|
||||
std::lock_guard<std::mutex> l(_memberStatus_l);
|
||||
auto ms = _memberStatus.find(_MemberStatusKey(networkId, memberId));
|
||||
if ((ms != _memberStatus.end()) && (ms->second.online(OSUtils::now())) && (ms->second.lastRequestMetaData)) {
|
||||
id = ms->second.identity;
|
||||
md.reset(new Dictionary<ZT_NETWORKCONFIG_METADATA_DICT_CAPACITY>(ms->second.lastRequestMetaData));
|
||||
}
|
||||
}
|
||||
if (md)
|
||||
request(networkId, InetAddress(), 0, id, *md);
|
||||
std::lock_guard<std::mutex> l(_memberStatus_l);
|
||||
_MemberStatus& ms = _memberStatus[_MemberStatusKey(networkId, memberId)];
|
||||
if ((ms.online(OSUtils::now())) && (ms.lastRequestMetaData))
|
||||
request(networkId, InetAddress(), 0, ms.identity, ms.lastRequestMetaData);
|
||||
}
|
||||
catch (...) {
|
||||
}
|
||||
@@ -1729,21 +1701,15 @@ void EmbeddedNetworkController::onNetworkMemberDeauthorize(const void* db, uint6
|
||||
(uint32_t)_node->prng(), networkId, 0, now, ZT_REVOCATION_FLAG_FAST_PROPAGATE, Address(memberId),
|
||||
Revocation::CREDENTIAL_TYPE_COM);
|
||||
rev.sign(_signingId);
|
||||
// Collect the online members under the lock, then send revocations with it
|
||||
// released: ncSendRevocation goes through the node core and wire I/O, and this
|
||||
// callback runs on the DB commit threads.
|
||||
std::vector<Address> online;
|
||||
{
|
||||
std::lock_guard<std::mutex> l(_memberStatus_l);
|
||||
for (auto i = _memberStatus.begin(); i != _memberStatus.end(); ++i) {
|
||||
if ((i->first.networkId == networkId) && (i->second.online(now)))
|
||||
online.push_back(Address(i->first.nodeId));
|
||||
if ((i->first.networkId == networkId) && (i->second.online(now))) {
|
||||
_node->ncSendRevocation(Address(i->first.nodeId), rev);
|
||||
// fprintf(stderr, " Sent revocation to %.10llx\n", i->first.nodeId);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (auto a = online.begin(); a != online.end(); ++a) {
|
||||
_node->ncSendRevocation(*a, rev);
|
||||
// fprintf(stderr, " Sent revocation to %.10llx\n", a->toInt());
|
||||
}
|
||||
}
|
||||
|
||||
void EmbeddedNetworkController::_request(
|
||||
@@ -1819,7 +1785,6 @@ void EmbeddedNetworkController::_request(
|
||||
return;
|
||||
}
|
||||
const bool newMember = ((! member.is_object()) || (member.empty()));
|
||||
bool identityLearned = false;
|
||||
DB::initMember(member);
|
||||
_MemberStatusKey msk(nwid, identity.address().toInt());
|
||||
#ifdef CENTRAL_CONTROLLER_REQUEST_BENCHMARK
|
||||
@@ -1862,7 +1827,6 @@ void EmbeddedNetworkController::_request(
|
||||
// If we do not yet know this member's identity, learn it.
|
||||
char idtmp[1024];
|
||||
member["identity"] = identity.toString(false, idtmp);
|
||||
identityLearned = true;
|
||||
}
|
||||
#ifdef CENTRAL_CONTROLLER_REQUEST_BENCHMARK
|
||||
b4.stop();
|
||||
@@ -1974,15 +1938,8 @@ void EmbeddedNetworkController::_request(
|
||||
nwid, requestPacketId, identity.address(), NetworkController::NC_ERROR_AUTHENTICATION_REQUIRED,
|
||||
authInfo.data(), authInfo.sizeBytes());
|
||||
}
|
||||
// Persist only when this request actually created state (first sighting or
|
||||
// a just-learned identity). Re-saving the full record on every SSO-pending
|
||||
// request writes a stale snapshot that can overwrite a fresher concurrent
|
||||
// change (e.g. an authorize arriving via PubSub), and generates constant
|
||||
// commit/echo churn for idle-but-unauthenticated members.
|
||||
if (newMember || identityLearned) {
|
||||
DB::cleanMember(member);
|
||||
_db.save(member, true);
|
||||
}
|
||||
DB::cleanMember(member);
|
||||
_db.save(member, true);
|
||||
#ifdef CENTRAL_CONTROLLER_REQUEST_BENCHMARK
|
||||
b6.stop();
|
||||
#endif
|
||||
@@ -2030,14 +1987,8 @@ void EmbeddedNetworkController::_request(
|
||||
}
|
||||
else {
|
||||
// If they are not authorized, STOP!
|
||||
// Persist only when this request created state (first sighting / identity just
|
||||
// learned). The old unconditional save wrote a full stale snapshot per denied
|
||||
// request, which could race with and overwrite a concurrent authorize arriving
|
||||
// via PubSub -- last-writer-wins with a stale writer.
|
||||
if (newMember || identityLearned) {
|
||||
DB::cleanMember(member);
|
||||
_db.save(member, true);
|
||||
}
|
||||
DB::cleanMember(member);
|
||||
_db.save(member, true);
|
||||
_sender->ncSendError(
|
||||
nwid, requestPacketId, identity.address(), NetworkController::NC_ERROR_ACCESS_DENIED, nullptr, 0);
|
||||
#ifdef CENTRAL_CONTROLLER_REQUEST_BENCHMARK
|
||||
@@ -2535,10 +2486,8 @@ void EmbeddedNetworkController::_request(
|
||||
c10++;
|
||||
b10.start();
|
||||
#endif
|
||||
// cleanMember first: it strips any inherited change_source (provenance of the
|
||||
// previous write); the explicit controller tag set after must survive.
|
||||
DB::cleanMember(member);
|
||||
member["change_source"] = "controller";
|
||||
DB::cleanMember(member);
|
||||
_db.save(member, true);
|
||||
#ifdef CENTRAL_CONTROLLER_REQUEST_BENCHMARK
|
||||
b10.stop();
|
||||
@@ -2558,6 +2507,11 @@ void EmbeddedNetworkController::_request(
|
||||
|
||||
void EmbeddedNetworkController::_startThreads()
|
||||
{
|
||||
auto provider = opentelemetry::trace::Provider::GetTracerProvider();
|
||||
auto tracer = provider->GetTracer("embedded_network_controller");
|
||||
auto span = tracer->StartSpan("embedded_network_controller::_startThreads");
|
||||
auto scope = tracer->WithActiveSpan(span);
|
||||
|
||||
std::lock_guard<std::mutex> l(_threads_l);
|
||||
if (! _threads.empty()) {
|
||||
return;
|
||||
@@ -2570,7 +2524,6 @@ void EmbeddedNetworkController::_startThreads()
|
||||
#endif
|
||||
for (long t = 0; t < hwc; ++t) {
|
||||
_threads.emplace_back([this, t]() {
|
||||
setCurrentThreadName("ctl-netconf");
|
||||
Metrics::network_config_request_threads++;
|
||||
uint64_t processedCount = 0;
|
||||
uint64_t idleIterations = 0;
|
||||
@@ -2625,7 +2578,6 @@ void EmbeddedNetworkController::_startThreads()
|
||||
|
||||
void EmbeddedNetworkController::_ssoExpiryThread()
|
||||
{
|
||||
setCurrentThreadName("ctl-sso-expiry");
|
||||
while (_ssoExpiryRunning) {
|
||||
auto provider = opentelemetry::trace::Provider::GetTracerProvider();
|
||||
auto tracer = provider->GetTracer("embedded_network_controller");
|
||||
|
||||
@@ -35,7 +35,7 @@ class Node;
|
||||
struct RedisConfig;
|
||||
|
||||
#ifdef ZT1_CENTRAL_CONTROLLER
|
||||
class ControllerConfig;
|
||||
struct ControllerConfig;
|
||||
#endif
|
||||
|
||||
class EmbeddedNetworkController
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
#ifndef NOTIFICATION_LISTENER_HPP
|
||||
#define NOTIFICATION_LISTENER_HPP
|
||||
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
namespace ZeroTier {
|
||||
@@ -21,6 +22,40 @@ enum class NotificationResult {
|
||||
TransientFailure, // retryable error (DB unavailable, connection pool exhausted) -> nack to redeliver
|
||||
};
|
||||
|
||||
/**
|
||||
* Completes the ack/nack for a single notification once its effect has been durably
|
||||
* committed (Ok), determined unprocessable/not-applicable (PermanentFailure -> ack/drop),
|
||||
* or has failed transiently (TransientFailure -> nack/redeliver).
|
||||
*
|
||||
* Lets a delivery mechanism that acks (GCP PubSub) defer the ack until *after* the async
|
||||
* DB commit, so an un-committed change is redelivered rather than lost. Delivery
|
||||
* mechanisms without redelivery semantics (Redis, Postgres LISTEN/NOTIFY) don't create one.
|
||||
* Implementations must be safe to complete exactly once from any thread.
|
||||
*/
|
||||
class NotificationCompletion {
|
||||
public:
|
||||
virtual ~NotificationCompletion()
|
||||
{
|
||||
}
|
||||
virtual void complete(NotificationResult result) = 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-thread hand-off used to defer a notification's ack until its asynchronous DB commit
|
||||
* finishes, without threading the completion through every DB/listener signature.
|
||||
*
|
||||
* The delivery callback (e.g. the PubSub subscriber callback) sets the pending completion
|
||||
* around its synchronous onNotification() call. The DB's enqueue path
|
||||
* (CentralDB::save/eraseNetwork/eraseMember) *takes* it -- moving ownership into the
|
||||
* commit-queue item -- when it enqueues async work, and the commit thread completes it after
|
||||
* the write. If the write is a no-op or never reaches the commit queue, the completion is
|
||||
* left in place and the delivery callback completes it synchronously. DB backends that don't
|
||||
* participate (Redis/Postgres, or writes originating off the delivery thread) simply never
|
||||
* find a pending completion and behave as before.
|
||||
*/
|
||||
void setPendingCompletion(std::shared_ptr<NotificationCompletion> completion);
|
||||
std::shared_ptr<NotificationCompletion> takePendingCompletion();
|
||||
|
||||
/**
|
||||
* Base class for notification listeners
|
||||
*
|
||||
|
||||
@@ -81,7 +81,6 @@ void PostgresMemberListener::reconnect()
|
||||
|
||||
void PostgresMemberListener::listen()
|
||||
{
|
||||
setCurrentThreadName("ctl-pg-member");
|
||||
while (_run) {
|
||||
// await_notification dispatches into onNotification, which can throw on a
|
||||
// malformed payload; libpqxx propagates that out here. An exception escaping
|
||||
@@ -216,7 +215,6 @@ void PostgresNetworkListener::reconnect()
|
||||
|
||||
void PostgresNetworkListener::listen()
|
||||
{
|
||||
setCurrentThreadName("ctl-pg-network");
|
||||
while (_run) {
|
||||
// See PostgresMemberListener::listen — an exception escaping this thread would
|
||||
// terminate the controller, so everything must be caught here; a broken
|
||||
|
||||
@@ -42,43 +42,9 @@ class PostgresConnection : public Connection {
|
||||
int a;
|
||||
};
|
||||
|
||||
// Add client-side liveness/timeout parameters to a libpq connection string unless
|
||||
// the operator already set them. Without these a connection whose peer silently
|
||||
// vanishes (AlloyDB failover, network partition) blocks its thread in a socket read
|
||||
// indefinitely; with them the call fails within a bounded time and the normal
|
||||
// error/retry paths take over. Handles both key/value conninfo and postgres:// URIs.
|
||||
inline std::string hardenPostgresConnString(std::string cs)
|
||||
{
|
||||
const bool isUri = (cs.rfind("postgres://", 0) == 0) || (cs.rfind("postgresql://", 0) == 0);
|
||||
auto add = [&cs, isUri](const char* key, const char* kvForm, const char* uriForm) {
|
||||
if (cs.find(key) != std::string::npos)
|
||||
return;
|
||||
if (isUri) {
|
||||
cs += (cs.find('?') == std::string::npos) ? "?" : "&";
|
||||
cs += uriForm;
|
||||
}
|
||||
else {
|
||||
cs += " ";
|
||||
cs += kvForm;
|
||||
}
|
||||
};
|
||||
add("connect_timeout", "connect_timeout=10", "connect_timeout=10");
|
||||
add("keepalives", "keepalives=1 keepalives_idle=30 keepalives_interval=10 keepalives_count=3",
|
||||
"keepalives=1&keepalives_idle=30&keepalives_interval=10&keepalives_count=3");
|
||||
// Bounds transmit-side hangs (peer never ACKs) at the TCP layer; milliseconds.
|
||||
add("tcp_user_timeout", "tcp_user_timeout=30000", "tcp_user_timeout=30000");
|
||||
// Deliberately NO statement_timeout here: it would also cap the startup bulk
|
||||
// loads (initializeNetworks/initializeMembers stream the full data set in one
|
||||
// COPY, and a timeout there exits the process -> crash loop on a slow/degraded
|
||||
// DB). Dead-peer hangs -- the incident class -- are covered by the keepalive and
|
||||
// TCP settings above; slow-but-alive statements surface via the commit-thread
|
||||
// watchdog instead.
|
||||
return cs;
|
||||
}
|
||||
|
||||
class PostgresConnFactory : public ConnectionFactory {
|
||||
public:
|
||||
PostgresConnFactory(std::string& connString) : m_connString(hardenPostgresConnString(connString))
|
||||
PostgresConnFactory(std::string& connString) : m_connString(connString)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
#include <google/cloud/pubsub/admin/subscription_admin_connection.h>
|
||||
#include <google/cloud/pubsub/admin/topic_admin_client.h>
|
||||
#include <google/cloud/pubsub/message.h>
|
||||
#include <google/cloud/pubsub/options.h>
|
||||
#include <google/cloud/pubsub/subscriber.h>
|
||||
#include <google/cloud/pubsub/subscription.h>
|
||||
#include <google/cloud/pubsub/topic.h>
|
||||
@@ -36,6 +37,36 @@ namespace ZeroTier {
|
||||
nlohmann::json toJson(const pbmessages::NetworkChange_Network& nc, pbmessages::NetworkChange_ChangeSource source);
|
||||
nlohmann::json toJson(const pbmessages::MemberChange_Member& mc, pbmessages::MemberChange_ChangeSource source);
|
||||
|
||||
namespace {
|
||||
// Defers a PubSub message's ack/nack until its DB commit completes. Created in the subscriber
|
||||
// callback holding the moved-in AckHandler, handed off (as a shared_ptr<NotificationCompletion>)
|
||||
// onto the commit-queue item, and completed by the commit thread after the write -- so an
|
||||
// un-committed change is redelivered rather than acked-then-lost. Safe to complete exactly once
|
||||
// from whichever thread finishes first.
|
||||
class PubSubAckCompletion : public NotificationCompletion {
|
||||
public:
|
||||
explicit PubSubAckCompletion(pubsub::AckHandler handler) : _handler(std::move(handler))
|
||||
{
|
||||
}
|
||||
void complete(NotificationResult result) override
|
||||
{
|
||||
if (_done.test_and_set()) {
|
||||
return; // ack/nack exactly once
|
||||
}
|
||||
if (result == NotificationResult::TransientFailure) {
|
||||
std::move(_handler).nack(); // retryable -> redeliver
|
||||
}
|
||||
else {
|
||||
std::move(_handler).ack(); // Ok / PermanentFailure -> ack (drop on permanent)
|
||||
}
|
||||
}
|
||||
|
||||
private:
|
||||
pubsub::AckHandler _handler;
|
||||
std::atomic_flag _done = ATOMIC_FLAG_INIT;
|
||||
};
|
||||
} // namespace
|
||||
|
||||
PubSubListener::PubSubListener(std::string controller_id, std::string project, std::string topic)
|
||||
: _controller_id(controller_id)
|
||||
, _project(project)
|
||||
@@ -60,9 +91,19 @@ PubSubListener::PubSubListener(std::string controller_id, std::string project, s
|
||||
create_gcp_pubsub_subscription_if_needed(_project, _subscription_id, _topic, _controller_id);
|
||||
}
|
||||
|
||||
_subscriber = std::make_shared<pubsub::Subscriber>(
|
||||
pubsub::MakeSubscriberConnection(*_subscription),
|
||||
google::cloud::Options {}.set<google::cloud::OpenTelemetryTracingOption>(true));
|
||||
_subscriber = std::make_shared<pubsub::Subscriber>(pubsub::MakeSubscriberConnection(
|
||||
*_subscription,
|
||||
google::cloud::Options {}
|
||||
.set<google::cloud::OpenTelemetryTracingOption>(true)
|
||||
// Cap the client-side flow-control window. google-cloud-cpp defaults to 1000
|
||||
// outstanding messages / 100 MiB per subscriber; with three controller
|
||||
// subscribers that is up to ~300 MiB of un-acked messages buffered in-process
|
||||
// during a change burst. A smaller window bounds that, and together with
|
||||
// ack-after-commit it also bounds how many uncommitted changes can pile up.
|
||||
// Flow-control options belong on MakeSubscriberConnection (the SubscriberOptionList
|
||||
// consumer), not on the Subscriber constructor.
|
||||
.set<pubsub::MaxOutstandingMessagesOption>(200)
|
||||
.set<pubsub::MaxOutstandingBytesOption>(16 * 1024 * 1024)));
|
||||
|
||||
_run = true;
|
||||
_subscriberThread = std::thread(&PubSubListener::subscribe, this);
|
||||
@@ -97,7 +138,6 @@ PubSubListener::~PubSubListener()
|
||||
|
||||
void PubSubListener::subscribe()
|
||||
{
|
||||
setCurrentThreadName("ctl-pubsub-sub");
|
||||
while (_run) {
|
||||
try {
|
||||
ZTC_LOG("PubSubListener::subscribe: starting session for subscription %s\n", _subscription_id.c_str());
|
||||
@@ -105,6 +145,11 @@ void PubSubListener::subscribe()
|
||||
|
||||
auto session = _subscriber->Subscribe([this](pubsub::Message const& m, pubsub::AckHandler h) {
|
||||
_lastMessageTime.store(std::chrono::steady_clock::now());
|
||||
// Defer the ack until the change is durably committed: hand ownership of the
|
||||
// AckHandler to a completion that the DB's commit thread resolves. If the write is
|
||||
// a no-op, never reaches the commit queue, or is handled synchronously (SSO), we
|
||||
// complete it ourselves below with onNotification's reported outcome.
|
||||
setPendingCompletion(std::make_shared<PubSubAckCompletion>(std::move(h)));
|
||||
// Default to transient: if anything throws before onNotification reports an
|
||||
// outcome, redeliver rather than silently dropping the change.
|
||||
NotificationResult result = NotificationResult::TransientFailure;
|
||||
@@ -169,11 +214,27 @@ void PubSubListener::subscribe()
|
||||
result = NotificationResult::PermanentFailure;
|
||||
}
|
||||
|
||||
if (result == NotificationResult::TransientFailure) {
|
||||
std::move(h).nack();
|
||||
}
|
||||
else {
|
||||
std::move(h).ack();
|
||||
// If onNotification enqueued async DB work it took the pending completion (the
|
||||
// commit thread acks/nacks after the write). Otherwise -- a no-op, a
|
||||
// parse/validation failure, or a synchronous handler -- complete it now with the
|
||||
// outcome onNotification reported.
|
||||
if (auto completion = takePendingCompletion()) {
|
||||
// Guard the ack/nack: an exception escaping this subscriber callback can tear
|
||||
// down the pull session. On a throwing path ack()/nack() has already consumed
|
||||
// the handler, so there's nothing to retry here -- just don't let it propagate.
|
||||
try {
|
||||
completion->complete(result);
|
||||
}
|
||||
catch (const std::exception& e) {
|
||||
ZTC_LOG(
|
||||
"PubSubListener: ack/nack failed (message_id=%s): %s\n", m.message_id().c_str(),
|
||||
e.what());
|
||||
}
|
||||
catch (...) {
|
||||
ZTC_LOG(
|
||||
"PubSubListener: ack/nack failed (message_id=%s); unknown exception\n",
|
||||
m.message_id().c_str());
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
@@ -241,19 +302,6 @@ NotificationResult PubSubNetworkListener::onNotification(const std::string& payl
|
||||
auto span = tracer->StartSpan("PubSubNetworkListener::onNotification");
|
||||
auto scope = tracer->WithActiveSpan(span);
|
||||
|
||||
// See PubSubMemberListener::onNotification -- don't ack into an unhealthy
|
||||
// commit pipeline. Log rate-limited.
|
||||
if (! _db->commitPipelineHealthy()) {
|
||||
static std::atomic<int64_t> lastLog(0);
|
||||
const int64_t now = OSUtils::now();
|
||||
int64_t prev = lastLog.load(std::memory_order_relaxed);
|
||||
if (((now - prev) > 5000) && lastLog.compare_exchange_strong(prev, now)) {
|
||||
ZTC_LOG("PubSubNetworkListener: commit pipeline stalled or over depth limit; nacking for redelivery\n");
|
||||
}
|
||||
span->SetStatus(opentelemetry::trace::StatusCode::kError, "commit pipeline unhealthy; redeliver");
|
||||
return NotificationResult::TransientFailure;
|
||||
}
|
||||
|
||||
pbmessages::NetworkChange nc;
|
||||
if (! nc.ParseFromString(payload)) {
|
||||
ZTC_LOG("Failed to parse NetworkChange protobuf message\n");
|
||||
@@ -266,12 +314,12 @@ NotificationResult PubSubNetworkListener::onNotification(const std::string& payl
|
||||
nlohmann::json oldConfig, newConfig;
|
||||
|
||||
if (nc.has_old()) {
|
||||
ZTC_LOG("network change: has old config for %s\n", nc.old().network_id().c_str());
|
||||
ZTC_LOG("has old network config\n");
|
||||
oldConfig = toJson(nc.old(), nc.change_source());
|
||||
}
|
||||
|
||||
if (nc.has_new_()) {
|
||||
ZTC_LOG("network change: has new config for %s\n", nc.new_().network_id().c_str());
|
||||
ZTC_LOG("has new network config\n");
|
||||
newConfig = toJson(nc.new_(), nc.change_source());
|
||||
}
|
||||
|
||||
@@ -350,22 +398,6 @@ NotificationResult PubSubMemberListener::onNotification(const std::string& paylo
|
||||
auto span = tracer->StartSpan("PubSubMemberListener::onNotification");
|
||||
auto scope = tracer->WithActiveSpan(span);
|
||||
|
||||
// Never ack a change into a stalled or drowning commit pipeline -- save() only
|
||||
// enqueues, so an ack here is a promise the commit threads may not keep. Nack
|
||||
// for redelivery instead; the message applies once the pipeline recovers (or
|
||||
// after the liveness watchdog restarts the process). Log rate-limited: during a
|
||||
// stall every redelivery attempt comes back through here.
|
||||
if (! _db->commitPipelineHealthy()) {
|
||||
static std::atomic<int64_t> lastLog(0);
|
||||
const int64_t now = OSUtils::now();
|
||||
int64_t prev = lastLog.load(std::memory_order_relaxed);
|
||||
if (((now - prev) > 5000) && lastLog.compare_exchange_strong(prev, now)) {
|
||||
ZTC_LOG("PubSubMemberListener: commit pipeline stalled or over depth limit; nacking for redelivery\n");
|
||||
}
|
||||
span->SetStatus(opentelemetry::trace::StatusCode::kError, "commit pipeline unhealthy; redeliver");
|
||||
return NotificationResult::TransientFailure;
|
||||
}
|
||||
|
||||
pbmessages::MemberChange mc;
|
||||
if (! mc.ParseFromString(payload)) {
|
||||
ZTC_LOG("Failed to parse MemberChange protobuf message\n");
|
||||
@@ -379,14 +411,12 @@ NotificationResult PubSubMemberListener::onNotification(const std::string& paylo
|
||||
nlohmann::json oldConfig, newConfig;
|
||||
|
||||
if (mc.has_old()) {
|
||||
ZTC_LOG("member change: has old config for %s-%s\n", mc.old().network_id().c_str(),
|
||||
mc.old().device_id().c_str());
|
||||
ZTC_LOG("has old member config\n");
|
||||
oldConfig = toJson(mc.old(), mc.change_source());
|
||||
}
|
||||
|
||||
if (mc.has_new_()) {
|
||||
ZTC_LOG("member change: has new config for %s-%s\n", mc.new_().network_id().c_str(),
|
||||
mc.new_().device_id().c_str());
|
||||
ZTC_LOG("has new member config\n");
|
||||
newConfig = toJson(mc.new_(), mc.change_source());
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ class PubSubListener : public NotificationListener {
|
||||
PubSubListener(std::string controller_id, std::string project, std::string topic);
|
||||
virtual ~PubSubListener();
|
||||
|
||||
virtual NotificationResult onNotification(const std::string& payload) = 0;
|
||||
virtual NotificationResult onNotification(const std::string& payload) override = 0;
|
||||
|
||||
// Stop and join the subscriber thread. Must be called from the most-derived
|
||||
// destructor (before its members are torn down) so an in-flight callback can't
|
||||
|
||||
Loaded 20 of 22 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user