blob: 81d8e7f9b8324d0e445ae0bc000bf61441e390e9 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
#include "iceberg/logging/logger.h"
#include <atomic>
#include <memory>
#include <thread>
#include <tuple>
#include <vector>
#include <gtest/gtest.h>
#include "iceberg/logging/log_level.h"
#include "iceberg/test/logging_test_helpers.h"
#include "iceberg/test/matchers.h"
namespace iceberg {
TEST(LoggerTest, NoopIsSharedImmortalAndSilent) {
auto noop = Logger::Noop();
ASSERT_NE(noop, nullptr);
EXPECT_TRUE(noop->IsNoop());
EXPECT_FALSE(noop->ShouldLog(LogLevel::kFatal));
EXPECT_EQ(noop->level(), LogLevel::kOff);
// Same singleton instance every call.
EXPECT_EQ(noop.get(), Logger::Noop().get());
}
TEST(LoggerTest, DefaultLoggerIsNeverNull) { EXPECT_NE(GetDefaultLogger(), nullptr); }
TEST(LoggerTest, SetAndGetDefaultLogger) {
auto capturing = std::make_shared<CapturingLogger>();
ScopedDefaultLogger guard(capturing);
EXPECT_EQ(GetDefaultLogger().get(), capturing.get());
EXPECT_EQ(internal::CurrentLogger().get(), capturing.get());
}
TEST(LoggerTest, SetNullFallsBackToNoop) {
ScopedDefaultLogger guard(std::make_shared<CapturingLogger>());
SetDefaultLogger(nullptr);
EXPECT_TRUE(GetDefaultLogger()->IsNoop());
}
TEST(LoggerTest, CurrentLoggerTracksSwaps) {
auto first = std::make_shared<CapturingLogger>();
auto second = std::make_shared<CapturingLogger>();
ScopedDefaultLogger guard(first);
EXPECT_EQ(internal::CurrentLogger().get(), first.get());
SetDefaultLogger(second);
// Generation bump must invalidate the thread-local cache.
EXPECT_EQ(internal::CurrentLogger().get(), second.get());
}
TEST(LoggerTest, SetDefaultLevelUpdatesLogger) {
auto capturing = std::make_shared<CapturingLogger>();
ScopedDefaultLogger guard(capturing);
SetDefaultLevel(LogLevel::kError);
EXPECT_EQ(capturing->level(), LogLevel::kError);
}
// Filtering is decided by the logger's own ShouldLog (no separate cached gate),
// so lowering a logger's level out-of-band (not via SetDefaultLevel) takes effect
// immediately -- this is the regression guard for the dropped g_effective_level gate.
TEST(LoggerTest, OutOfBandLevelLoweringTakesEffect) {
auto capturing = std::make_shared<CapturingLogger>();
capturing->SetLevel(LogLevel::kError);
ScopedDefaultLogger guard(capturing);
EXPECT_FALSE(internal::CurrentLogger()->ShouldLog(LogLevel::kInfo));
capturing->SetLevel(LogLevel::kTrace); // lowered directly on the handle
EXPECT_TRUE(internal::CurrentLogger()->ShouldLog(LogLevel::kInfo));
}
TEST(LoggerTest, ConcurrentSwapAndReadIsSafe) {
// Stress CurrentLogger()/GetDefaultLogger() against SetDefaultLogger() swaps.
// Run under TSan in CI; here it asserts no crash and a valid logger throughout.
auto a = std::make_shared<CapturingLogger>();
auto b = std::make_shared<CapturingLogger>();
ScopedDefaultLogger guard(a);
std::atomic<bool> stop{false};
std::atomic<bool> saw_null{false};
std::vector<std::thread> readers;
for (int i = 0; i < 6; ++i) {
readers.emplace_back([&stop, &saw_null] {
// ASSERT_* doesn't propagate from non-main threads; record via a flag.
while (!stop.load(std::memory_order_relaxed)) {
const auto& l = internal::CurrentLogger();
if (!l) saw_null.store(true, std::memory_order_relaxed);
std::ignore = l->ShouldLog(LogLevel::kError);
std::ignore = GetDefaultLogger();
}
});
}
for (int i = 0; i < 2000; ++i) SetDefaultLogger((i & 1) ? a : b);
stop.store(true, std::memory_order_relaxed);
for (auto& t : readers) t.join();
EXPECT_FALSE(saw_null.load()); // CurrentLogger() is never null across swaps
}
TEST(LoggerTest, InitializeAppliesLevelProperty) {
CapturingLogger logger;
auto status = logger.Initialize({{std::string(kLevelProperty), std::string("error")}});
ASSERT_TRUE(status.has_value());
EXPECT_EQ(logger.level(), LogLevel::kError);
}
TEST(LoggerTest, InitializeRejectsInvalidLevel) {
CapturingLogger logger;
auto status =
logger.Initialize({{std::string(kLevelProperty), std::string("not-a-level")}});
ASSERT_FALSE(status.has_value());
EXPECT_THAT(status, IsError(ErrorKind::kInvalidArgument));
}
// Logging during thread teardown (from a thread_local destructor) must not crash.
// The per-thread cache is freed at thread exit, but CurrentLogger()'s teardown
// guard (a trivially-destructible "dead" flag whose storage outlives every
// thread_local) makes it safe even when the logging statement runs from a
// thread_local destroyed AFTER the cache -- the hard case. Probe is constructed
// before CurrentLogger() is first touched, so it is destroyed last. Run under
// ASan/TSan in CI for full signal.
TEST(LoggerTest, LoggingFromThreadLocalDestructorIsSafe) {
std::thread([] {
struct Probe {
~Probe() {
const auto& logger = internal::CurrentLogger();
if (logger) {
internal::Emit(*logger, LogLevel::kInfo, std::source_location::current(),
"from thread_local dtor");
}
}
};
static thread_local Probe probe;
std::ignore = probe; // construct Probe first ...
std::ignore =
internal::CurrentLogger(); // ... then the logger cache (destroyed first)
}).join();
SUCCEED();
}
// Teardown interleaved with concurrent default-logger swaps: many short-lived
// threads each log from a thread_local destructor while another thread swaps the
// default logger. Exercises the per-thread cache being freed at thread exit at
// the same time the global slot is mutated. Run under ASan/TSan in CI.
TEST(LoggerTest, ConcurrentTeardownAndSwapIsSafe) {
auto a = std::make_shared<CapturingLogger>();
ScopedDefaultLogger guard(a);
std::atomic<bool> stop{false};
std::thread swapper([&] {
auto b = std::make_shared<CapturingLogger>();
while (!stop.load(std::memory_order_relaxed)) {
SetDefaultLogger(b);
SetDefaultLogger(a);
}
});
for (int i = 0; i < 100; ++i) {
std::thread worker([] {
struct Probe {
~Probe() {
const auto& l = internal::CurrentLogger();
if (l) l->ShouldLog(LogLevel::kError);
}
};
static thread_local Probe probe;
std::ignore = probe; // constructed before the cache
std::ignore = internal::CurrentLogger(); // touch the cache in normal code
});
worker.join(); // teardown runs concurrently with the swapper
}
stop.store(true, std::memory_order_relaxed);
swapper.join();
SUCCEED();
}
// --- Per-context routing: ScopedLogger + GetCurrentLogger ---
TEST(LoggerTest, ScopedLoggerOverridesDefaultPath) {
auto global = std::make_shared<CapturingLogger>();
auto scoped = std::make_shared<CapturingLogger>();
ScopedDefaultLogger guard(global);
{
ScopedLogger bind(scoped);
EXPECT_EQ(internal::CurrentLogger().get(), scoped.get());
Log(LogLevel::kInfo, "hi {}", 1);
}
EXPECT_EQ(scoped->count(), 1u);
EXPECT_EQ(global->count(), 0u);
}
TEST(LoggerTest, ScopedLoggerRestoresOnScopeExit) {
auto global = std::make_shared<CapturingLogger>();
auto scoped = std::make_shared<CapturingLogger>();
ScopedDefaultLogger guard(global);
{
ScopedLogger bind(scoped);
}
EXPECT_EQ(internal::CurrentLogger().get(), global.get());
Log(LogLevel::kInfo, "back");
EXPECT_EQ(global->count(), 1u);
EXPECT_EQ(scoped->count(), 0u);
}
TEST(LoggerTest, ExplicitLoggerBypassesOverride) {
auto scoped = std::make_shared<CapturingLogger>();
auto explicit_sink = std::make_shared<CapturingLogger>();
ScopedDefaultLogger guard(std::make_shared<CapturingLogger>());
ScopedLogger bind(scoped);
Log(*explicit_sink, LogLevel::kInfo, "e {}", 1);
EXPECT_EQ(explicit_sink->count(), 1u);
EXPECT_EQ(scoped->count(), 0u);
}
TEST(LoggerTest, NestedScopedLoggersRestoreInLifo) {
auto global = std::make_shared<CapturingLogger>();
auto x = std::make_shared<CapturingLogger>();
auto y = std::make_shared<CapturingLogger>();
ScopedDefaultLogger guard(global);
{
ScopedLogger a(x);
EXPECT_EQ(internal::CurrentLogger().get(), x.get());
{
ScopedLogger b(y);
EXPECT_EQ(internal::CurrentLogger().get(), y.get());
}
EXPECT_EQ(internal::CurrentLogger().get(), x.get());
}
EXPECT_EQ(internal::CurrentLogger().get(), global.get());
}
TEST(LoggerTest, ScopedLoggerNullMasksToGlobalDefault) {
auto global = std::make_shared<CapturingLogger>();
auto x = std::make_shared<CapturingLogger>();
ScopedDefaultLogger guard(global);
ScopedLogger a(x);
{
ScopedLogger mask(nullptr);
EXPECT_EQ(internal::CurrentLogger().get(), global.get()); // not x, not Noop
EXPECT_FALSE(internal::CurrentLogger()->IsNoop());
}
EXPECT_EQ(internal::CurrentLogger().get(), x.get()); // enclosing binding restored
}
TEST(LoggerTest, GetCurrentLoggerReturnsOverrideThenDefault) {
auto global = std::make_shared<CapturingLogger>();
auto scoped = std::make_shared<CapturingLogger>();
ScopedDefaultLogger guard(global);
EXPECT_EQ(GetCurrentLogger().get(), global.get());
{
ScopedLogger bind(scoped);
EXPECT_EQ(GetCurrentLogger().get(), scoped.get());
}
EXPECT_EQ(GetCurrentLogger().get(), global.get());
}
TEST(LoggerTest, GetCurrentLoggerOnFreshThreadReturnsDefault) {
auto global = std::make_shared<CapturingLogger>();
ScopedDefaultLogger guard(global);
Logger* seen = nullptr;
std::thread([&] { seen = GetCurrentLogger().get(); }).join(); // never used a scope
EXPECT_EQ(seen, global.get());
}
TEST(LoggerTest, ThreadPoolPropagationPattern) {
auto global = std::make_shared<CapturingLogger>();
auto scoped = std::make_shared<CapturingLogger>();
ScopedDefaultLogger guard(global);
ScopedLogger bind(scoped);
auto captured = GetCurrentLogger(); // capture the effective logger at "submit"
EXPECT_EQ(captured.get(), scoped.get());
std::thread([captured] {
ScopedLogger rebind(captured); // re-bind on the worker thread
Log(LogLevel::kInfo, "task {}", 7);
}).join();
EXPECT_EQ(scoped->count(), 1u);
EXPECT_EQ(global->count(), 0u);
}
TEST(LoggerTest, WorkerOverrideDoesNotLeakAcrossTasksOnReusedThread) {
auto global = std::make_shared<CapturingLogger>();
auto o1 = std::make_shared<CapturingLogger>();
ScopedDefaultLogger guard(global);
Logger* during = nullptr;
Logger* between = nullptr;
std::thread([&] {
{
ScopedLogger b1(o1);
during = GetCurrentLogger().get();
}
between = GetCurrentLogger().get(); // no scope active -> global default
}).join();
EXPECT_EQ(during, o1.get());
EXPECT_EQ(between, global.get());
}
// Binding/unbinding a ScopedLogger from a thread_local destructor that runs after
// the per-thread cache was freed (Probe constructed before CurrentLogger is first
// touched) must no-op against the dead cache, never touch freed memory. Run under
// ASan/TSan in CI for full signal.
TEST(LoggerTest, ScopedLoggerBindUnbindDuringTeardownIsSafe) {
std::thread([] {
struct Probe {
~Probe() {
ScopedLogger late(std::make_shared<CapturingLogger>()); // ctor: no-op on dead
const auto& l = internal::CurrentLogger(); // -> Noop fallback
if (l) l->ShouldLog(LogLevel::kError);
} // ~ScopedLogger: restore is a no-op on dead
};
static thread_local Probe probe;
std::ignore = probe; // constructed first
std::ignore = internal::CurrentLogger(); // cache created after -> freed first
}).join();
SUCCEED();
}
// The override path deliberately skips the generation refresh, but a swap that
// happened while an override was active must still be observed on the first call
// after the override is popped (gen is monotonic).
TEST(LoggerTest, OverrideActiveSkipsGenRefreshButSwapStillSeenAfterPop) {
auto a = std::make_shared<CapturingLogger>();
auto b = std::make_shared<CapturingLogger>();
auto c = std::make_shared<CapturingLogger>();
ScopedDefaultLogger guard(a);
EXPECT_EQ(internal::CurrentLogger().get(), a.get());
{
ScopedLogger bind(b);
SetDefaultLogger(c); // bump gen while the override is active
EXPECT_EQ(internal::CurrentLogger().get(), b.get()); // override wins
}
EXPECT_EQ(internal::CurrentLogger().get(), c.get()); // swap seen after pop
}
// Many short-lived threads each bind a ScopedLogger and tear down while another
// thread swaps the global default. Run under ASan/TSan in CI.
TEST(LoggerTest, ConcurrentOverrideTeardownAndSwapIsSafe) {
auto a = std::make_shared<CapturingLogger>();
ScopedDefaultLogger guard(a);
std::atomic<bool> stop{false};
std::thread swapper([&] {
auto b = std::make_shared<CapturingLogger>();
while (!stop.load(std::memory_order_relaxed)) {
SetDefaultLogger(b);
SetDefaultLogger(a);
}
});
for (int i = 0; i < 100; ++i) {
std::thread worker([] {
auto local = std::make_shared<CapturingLogger>();
ScopedLogger bind(local);
const auto& l = internal::CurrentLogger();
if (l) l->ShouldLog(LogLevel::kError);
});
worker.join();
}
stop.store(true, std::memory_order_relaxed);
swapper.join();
SUCCEED();
}
// --- Function-style API (non-macro): overloaded Log() ---
TEST(LoggerTest, LogToExplicitLoggerFormats) {
auto sink = std::make_shared<CapturingLogger>();
Log(*sink, LogLevel::kInfo, "x={} y={}", 1, 2);
auto records = sink->records();
ASSERT_EQ(records.size(), 1u);
EXPECT_EQ(records[0].level, LogLevel::kInfo);
EXPECT_EQ(records[0].message, "x=1 y=2");
EXPECT_NE(records[0].location.line(), 0u); // call-site location captured
}
TEST(LoggerTest, LogRespectsLevelAndDoesNotFormatWhenDisabled) {
auto sink = std::make_shared<CapturingLogger>();
sink->SetLevel(LogLevel::kError);
Log(*sink, LogLevel::kInfo, "dropped {}", 1);
EXPECT_EQ(sink->count(), 0u);
}
TEST(LoggerTest, LogToDefaultLoggerFormatStyle) {
auto sink = std::make_shared<CapturingLogger>();
ScopedDefaultLogger guard(sink);
Log(LogLevel::kWarn, "v={}", 7);
auto records = sink->records();
ASSERT_EQ(records.size(), 1u);
EXPECT_EQ(records[0].level, LogLevel::kWarn);
EXPECT_EQ(records[0].message, "v=7");
}
// --- LogMessage::Builder (structured attributes) ---
TEST(LoggerTest, BuilderAssemblesMessageAndAttributes) {
auto record = LogMessage::Builder(LogLevel::kInfo)
.Message("scan finished")
.Attribute("table", "db.t")
.Attribute("snapshot_id", "42")
.Build();
EXPECT_EQ(record.level, LogLevel::kInfo);
EXPECT_EQ(record.message, "scan finished");
ASSERT_EQ(record.attributes.size(), 2u);
EXPECT_EQ(record.attributes[0].key, "table");
EXPECT_EQ(record.attributes[0].value, "db.t");
EXPECT_EQ(record.attributes[1].key, "snapshot_id");
EXPECT_EQ(record.attributes[1].value, "42");
}
TEST(LoggerTest, BuilderDefaultsAndEmitToSink) {
auto sink = std::make_shared<CapturingLogger>();
sink->Log(LogMessage::Builder(LogLevel::kError).Message("boom").Build());
auto records = sink->records();
ASSERT_EQ(records.size(), 1u);
EXPECT_EQ(records[0].level, LogLevel::kError);
EXPECT_EQ(records[0].message, "boom");
EXPECT_TRUE(records[0].attributes.empty());
EXPECT_NE(records[0].location.line(), 0u); // location defaulted at build site
}
// The constructor captures source_location as a default argument, so without
// Location() the default is the caller's construction site (this file), not
// logger.h. The Builder is constructed exactly one line below `here`.
TEST(LoggerTest, BuilderDefaultLocationIsCallerSite) {
auto here = std::source_location::current();
auto record = LogMessage::Builder(LogLevel::kInfo).Message("m").Build();
EXPECT_STREQ(record.location.file_name(), here.file_name());
EXPECT_EQ(record.location.line(), here.line() + 1);
}
// Location() replaces the constructor default with the caller's site (file + line).
TEST(LoggerTest, BuilderLocationOverrideUsesCallerSite) {
auto caller = std::source_location::current();
auto record = LogMessage::Builder(LogLevel::kDebug).Location(caller).Build();
EXPECT_EQ(record.location.line(), caller.line());
EXPECT_STREQ(record.location.file_name(), caller.file_name());
}
} // namespace iceberg