Skip to content

Commit a38e0cd

Browse files
feat(logging): add Logger interface and default logger (#723)
Part 2 of the logging stack (builds on #722). Adds the logging API and a swappable default logger — the foundation the backends and macros plug into. **What's here** - `Logger`: the pluggable sink interface (`ShouldLog` / `Log` / `SetLevel` / `Flush` / `Initialize`). `ShouldLog()` is the single source of truth for runtime filtering. - `LogMessage` owns its formatted text so a sink can safely keep a record; reserves an attributes field for future structured logging. - Process-global default logger: `GetDefaultLogger` / `SetDefaultLogger` / `SetDefaultLevel`, with a lock-free thread-local fast path so logging stays cheap. - `Initialize` applies the `level` property, so config-driven levels actually work. `CurrentLogger()` is safe to call even from a `thread_local` destructor during thread shutdown. - `logger.h` stays backend-agnostic (never includes the build config header), so consumers see one stable API regardless of backend. **Examples** — using the API directly (the `LOG_*` macros that wrap it arrive in #725): ```cpp // A custom sink, installed as the process default. class MySink : public Logger { public: bool ShouldLog(LogLevel level) const override { return level >= level_; } void Log(LogMessage&& m) noexcept override { write_line(m.message); } void SetLevel(LogLevel level) override { level_ = level; } LogLevel level() const override { return level_; } private: std::atomic<LogLevel> level_{LogLevel::kInfo}; }; SetDefaultLogger(std::make_shared<MySink>()); // install process-wide SetDefaultLevel(LogLevel::kDebug); // adjust the threshold auto logger = GetDefaultLogger(); // borrow the current default if (logger->ShouldLog(LogLevel::kInfo)) { logger->Log(LogMessage{.level = LogLevel::kInfo, .message = "scan ready"}); } // Or configure from catalog-style properties (applies the "level" key): auto sink = std::make_shared<MySink>(); auto status = sink->Initialize({{std::string(kLevelProperty), "warn"}}); // -> kWarn ``` The same example is documented inline in `logger.h`. **Tests** — `logger_test`: default-logger API, level-from-property, invalid level rejected, concurrent swap/read, and logging during thread teardown. Built and run with clang/libc++ (spdlog ON and OFF). This pull request and its description were written by Isaac.
1 parent 28163ce commit a38e0cd

9 files changed

Lines changed: 1129 additions & 3 deletions

File tree

src/iceberg/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ set(ICEBERG_SOURCES
4949
inheritable_metadata.cc
5050
json_serde.cc
5151
location_provider.cc
52+
logging/logger.cc
5253
manifest/manifest_adapter.cc
5354
manifest/manifest_entry.cc
5455
manifest/manifest_filter_manager.cc

src/iceberg/logging/logger.cc

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
20+
#include "iceberg/logging/logger.h"
21+
22+
#include <atomic>
23+
#include <cstdint>
24+
#include <memory>
25+
#include <mutex>
26+
#include <tuple>
27+
#include <utility>
28+
29+
namespace iceberg {
30+
31+
namespace {
32+
33+
/// \brief Logger that drops every record.
34+
class NoopLogger final : public Logger {
35+
public:
36+
bool ShouldLog(LogLevel /*level*/) const noexcept override { return false; }
37+
void Log(LogMessage&& /*message*/) noexcept override {}
38+
void SetLevel(LogLevel /*level*/) noexcept override {}
39+
LogLevel level() const noexcept override { return LogLevel::kOff; }
40+
bool IsNoop() const override { return true; }
41+
};
42+
43+
/// \brief Construct the process default logger for this build configuration.
44+
///
45+
/// This block ships only the interface and the no-op logger; the concrete
46+
/// std::cerr and spdlog sinks (and the build-config selection between them)
47+
/// arrive in later blocks, which update this factory.
48+
std::shared_ptr<Logger> MakeDefaultLogger() { return std::make_shared<NoopLogger>(); }
49+
50+
/// \brief The process-global default-logger slot.
51+
struct DefaultSlot {
52+
std::mutex mtx;
53+
std::shared_ptr<Logger> logger;
54+
// Seeded to 1 so a fresh thread (tls_gen == 0) always refreshes on first use.
55+
std::atomic<uint64_t> gen{1};
56+
57+
DefaultSlot() : logger(MakeDefaultLogger()) {}
58+
};
59+
60+
/// \brief Immortal (leaked, hence reachable -> LSan-clean) accessor for the slot.
61+
DefaultSlot& Slot() {
62+
static auto* slot = new DefaultSlot();
63+
return *slot;
64+
}
65+
66+
/// \brief A thread's cached view of the default logger and the generation it was
67+
/// cached at. Heap-allocated per thread and freed at thread exit (see
68+
/// AccessThreadCache). `override_` is the active ScopedLogger binding for this
69+
/// thread (empty when none); when set it supersedes the cached default.
70+
struct ThreadCache {
71+
std::shared_ptr<Logger> logger;
72+
uint64_t gen = 0; // 0 != Slot().gen (seeded to 1) -> first use always refreshes
73+
std::shared_ptr<Logger> override_; // active ScopedLogger binding, empty if none
74+
};
75+
76+
} // namespace
77+
78+
std::shared_ptr<Logger> Logger::Noop() {
79+
// Intentionally leaked: reachable via the function-local static (LSan-clean)
80+
// and never destroyed, so logging during static teardown stays safe.
81+
static auto* instance = new std::shared_ptr<Logger>(std::make_shared<NoopLogger>());
82+
return *instance;
83+
}
84+
85+
std::shared_ptr<Logger> GetDefaultLogger() {
86+
DefaultSlot& slot = Slot();
87+
std::lock_guard<std::mutex> lock(slot.mtx);
88+
return slot.logger;
89+
}
90+
91+
void SetDefaultLogger(std::shared_ptr<Logger> logger) {
92+
if (!logger) {
93+
logger = Logger::Noop();
94+
}
95+
DefaultSlot& slot = Slot();
96+
std::lock_guard<std::mutex> lock(slot.mtx);
97+
slot.logger = std::move(logger);
98+
// Publish the swap; the mutex provides the happens-before, gen is a detector.
99+
slot.gen.fetch_add(1, std::memory_order_relaxed);
100+
}
101+
102+
void SetDefaultLevel(LogLevel level) {
103+
DefaultSlot& slot = Slot();
104+
std::lock_guard<std::mutex> lock(slot.mtx);
105+
slot.logger->SetLevel(level);
106+
}
107+
108+
namespace internal {
109+
110+
namespace {
111+
112+
/// \brief The one place the per-thread cache's lifetime is managed; shared by
113+
/// CurrentLogger, GetCurrentLogger, and the ScopedLogger helpers.
114+
///
115+
/// Safe to call from any thread_local destructor during teardown: `dead`/`cache`
116+
/// are trivially destructible so their storage lives the whole thread, and `guard`
117+
/// (the only one with a destructor) sets `dead` before freeing the cache -- so a
118+
/// late caller gets nullptr instead of touching freed memory, in any order.
119+
///
120+
/// \param create allocate the cache if absent (writers pass true; read-only
121+
/// callers pass false so a query never allocates). Returns nullptr while tearing
122+
/// down, or when !create and no cache exists -- caller falls back to global/noop.
123+
ThreadCache* AccessThreadCache(bool create) noexcept {
124+
static thread_local bool dead = false;
125+
static thread_local ThreadCache* cache = nullptr;
126+
static thread_local struct Guard {
127+
~Guard() {
128+
dead = true; // mark BEFORE freeing, so a re-entrant log hits the fallback
129+
delete cache;
130+
cache = nullptr;
131+
}
132+
} guard;
133+
std::ignore = guard; // mark the thread_local as intentionally used (its dtor is
134+
// registered by reaching the declaration above)
135+
136+
if (dead) return nullptr;
137+
if (cache == nullptr && create) cache = new ThreadCache();
138+
return cache;
139+
}
140+
141+
} // namespace
142+
143+
const std::shared_ptr<Logger>& CurrentLogger() noexcept {
144+
ThreadCache* cache = AccessThreadCache(/*create=*/true);
145+
if (cache == nullptr) {
146+
// Thread teardown after the cache was freed: serve an immortal no-op so a log
147+
// from a later thread_local destructor is safe. Such teardown logs are dropped.
148+
static auto* fallback = new std::shared_ptr<Logger>(Logger::Noop());
149+
return *fallback;
150+
}
151+
152+
// A scoped override wins -- no lock, no gen load. After the cache check (deref is
153+
// teardown-safe), before the refresh (keeps the override path cheapest).
154+
if (cache->override_) return cache->override_;
155+
156+
DefaultSlot& slot = Slot();
157+
uint64_t current = slot.gen.load(std::memory_order_relaxed);
158+
if (current != cache->gen) {
159+
std::lock_guard<std::mutex> lock(slot.mtx);
160+
cache->logger = slot.logger;
161+
cache->gen = current;
162+
}
163+
return cache->logger;
164+
}
165+
166+
std::shared_ptr<Logger> ExchangeThreadOverride(std::shared_ptr<Logger> next) noexcept {
167+
ThreadCache* cache = AccessThreadCache(/*create=*/true);
168+
if (cache == nullptr) return {}; // tearing down -> binding a scope is a no-op
169+
std::shared_ptr<Logger> prev = std::move(cache->override_);
170+
cache->override_ = std::move(next);
171+
return prev; // empty == no override was active
172+
}
173+
174+
void RestoreThreadOverride(std::shared_ptr<Logger> prev) noexcept {
175+
ThreadCache* cache = AccessThreadCache(/*create=*/false); // never allocate to restore
176+
if (cache == nullptr) return; // dead or never created -> nothing to restore
177+
cache->override_ = std::move(prev);
178+
}
179+
180+
void Emit(Logger& logger, LogLevel level, const std::source_location& location,
181+
std::string&& message) {
182+
logger.Log(LogMessage{.level = level,
183+
.message = std::move(message),
184+
.location = location,
185+
.attributes = {}});
186+
}
187+
188+
void EmitFormatError(Logger& logger, LogLevel level,
189+
const std::source_location& location) noexcept {
190+
// Fixed short literal (<= 15 bytes, fits SSO on libstdc++/libc++/MSVC -> no heap
191+
// allocation), no std::format, no retry. Cannot throw or recurse.
192+
logger.Log(LogMessage{.level = level,
193+
.message = std::string("<fmt error>"),
194+
.location = location,
195+
.attributes = {}});
196+
}
197+
198+
} // namespace internal
199+
200+
ScopedLogger::ScopedLogger(std::shared_ptr<Logger> logger) noexcept
201+
: previous_(internal::ExchangeThreadOverride(std::move(logger))) {}
202+
203+
ScopedLogger::~ScopedLogger() { internal::RestoreThreadOverride(std::move(previous_)); }
204+
205+
std::shared_ptr<Logger> GetCurrentLogger() {
206+
ThreadCache* cache = internal::AccessThreadCache(/*create=*/false);
207+
if (cache == nullptr) {
208+
// Teardown, or no per-thread cache ever created -> no override possible.
209+
// GetDefaultLogger() touches only the immortal slot (no thread_local), so it
210+
// stays valid during teardown, and is never null.
211+
return GetDefaultLogger();
212+
}
213+
if (cache->override_) return cache->override_;
214+
return GetDefaultLogger();
215+
}
216+
217+
} // namespace iceberg

0 commit comments

Comments
 (0)