How ClickHouse Splits a Session Context from a Query Context

ClickHouse keeps session state (settings, roles, temporary tables) in one context and per-query state (query id, cancel flag, privileges) in another, and gates every setting change through constraints.

TL;DR: ClickHouse has four nested contexts — global, background, session and query — and a query context is a copy of the session context with the per-query state reset. SET writes to the session context; a SETTINGS clause writes to the query context. Every write is checked against a constraint set before it is applied, and readonly = 1 blocks everything except an explicit allow-list.

A database server has two kinds of state, and confusing them is the source of a whole family of bugs.

The first kind is session state: the user’s current database, the settings they changed with SET, the roles they were granted, the temporary tables they created. It outlives a single query and is supposed to be visible to the next one.

The second kind is query state: the query id, the process-list entry, the cancellation flag, the set of privileges this query used, the throttlers. It must be fresh for every query, and two concurrent queries must never see each other’s.

The naive implementation — one big mutable “connection state” object that queries read from and write to — gets both wrong. It leaks state between concurrent queries, and it makes “cancel this query” indistinguishable from “disconnect this client”.

ClickHouse’s answer is a context object with four nested scopes, copied on every query, with the query scope being the only one that is per-query.

The four contexts

Every Context holds weak pointers to the contexts it descends from:

cpp
// src/Interpreters/Context.h:631
    ContextWeakMutablePtr query_context;
    ContextWeakMutablePtr session_context;      /// Session context or nullptr. Could be equal to this.
    ContextWeakMutablePtr global_context;       /// Global context. Could be equal to this.
    ContextWeakMutablePtr background_context;   /// Context of background operations or a copy of global context. Could be equal to this.

The global context is the singleton that holds the server’s configuration, the catalogs, the access control, the thread pools. The background context is a copy of the global one used for background merges, mutations and replication. The session context is per-client-connection. The query context is per-statement.

They are weak pointers on purpose. A query context holds a weak pointer to its session context and vice versa; whichever dies first breaks the cycle, and neither keeps the other alive. getQueryContext() throws if the query is already over:

cpp
// src/Interpreters/Context.cpp:4335
ContextMutablePtr Context::getQueryContext() const
{
    auto ptr = query_context.lock();
    if (!ptr) throw Exception(ErrorCodes::THERE_IS_NO_QUERY, "There is no query or query context has expired");
    return ptr;
}

Making a query context: copy, then reset

The session creates a query context by copying the session context and then resetting the parts that must not be inherited:

cpp
// src/Interpreters/Session.cpp:726
    /// We can create a query context either from a session context or from a global context.
    const bool from_session_context = static_cast<bool>(session_context) && !detached;

    /// Create a new query context.
    ContextMutablePtr query_context = Context::createCopy(from_session_context ? session_context : global_context);
    query_context->makeQueryContext();

Context::createCopy is a plain copy of the whole Context object under a shared lock — the context is a value, not a reference:

cpp
// src/Interpreters/Context.cpp:1651
ContextMutablePtr Context::createCopy(const ContextPtr & other)
{
    SharedLockGuard lock(other->mutex);
    auto new_context = std::shared_ptr<Context>(new Context(*other));
    return new_context;
}

And makeQueryContext() is the reset. This is the interesting part — it is a list of things that a query must not inherit:

cpp
// src/Interpreters/Context.cpp:4376
void Context::makeQueryContext()
{
    query_context = shared_from_this();
    distributed_plan_local_object = std::make_shared<DistributedPlanLocalObject>();

    /// Throttling should not be inherited, otherwise if you will set
    /// throttling for default profile you will not able to overwrite it
    /// per-user/query.
    ///
    /// Note, that if you need to set it server-wide, you should use
    /// per-server settings, i.e.:
    /// - max_backup_bandwidth_for_server
    /// - max_remote_read_network_bandwidth_for_server
    /// - max_remote_write_network_bandwidth_for_server
    /// - max_local_read_bandwidth_for_server
    /// - max_local_write_bandwidth_for_server
    remote_read_query_throttler.reset();
    remote_write_query_throttler.reset();
    local_read_query_throttler.reset();
    local_write_query_throttler.reset();
    backups_query_throttler.reset();

The throttlers are reset because a user profile may set a bandwidth limit that is stricter than the server’s, and the next query may have a different profile. Inheriting the previous query’s throttler would make the limit sticky in the wrong direction.

The privilege bookkeeping is reset too, and the comment explains a real bug:

cpp
// src/Interpreters/Context.cpp:4397
    /// A new query starts with an empty set of used/missing privileges.
    /// We must not copy the contents of the parent's `QueryPrivilegesInfo`: the parent is the session
    /// (or global) context, whose `query_privileges_info` object is shared between all sessions and queries
    /// (session contexts are created via `createCopy(global_context)` and never call `makeQueryContext`).
    /// Copying its contents — and racing with concurrent writers during the copy — leaked privilege strings
    /// from unrelated earlier queries into `system.query_log.used_privileges`. See issue #105983.
    query_privileges_info = std::make_shared<QueryPrivilegesInfo>();

The subtlety: ContextData’s copy constructor copies the pointer, so the new context would share the parent’s QueryPrivilegesInfo object. The session context’s object is shared between all sessions. Copying it under a lock is fine; but the fix is to allocate a fresh object, which is both correct and lock-free.

SET writes to the session, SETTINGS writes to the query

This is the split that users actually feel. SET max_threads = 8 is a session operation: it persists for the next statement on the same connection. SELECT ... SETTINGS max_threads = 8 is a query operation: it applies to that statement only.

The SET path is a two-liner in InterpreterSetQuery:

cpp
// src/Interpreters/InterpreterSetQuery.cpp:89
    /// Pass as const on purpose: the non-const checkSettingsConstraints overload rewrites the
    /// changes (dropping no-op changes), which would lose the "changed" flag for a setting
    /// explicitly set to its current value. The original code applies const `ast.changes`.
    getContext()->checkSettingsConstraints(std::as_const(changes), SettingSource::QUERY);
    /// Checked before anything is applied, so that a violation leaves the whole statement without effect.
    getContext()->checkSettingsConstraintsForSettingsReset(ast.default_settings, changes, SettingSource::QUERY);
    auto session_context = getContext()->getSessionContext();
    session_context->applySettingsChanges(changes);

Three things to notice.

  1. The changes are checked before anything is applied. SET a = 1, b = 2 where b violates a constraint applies neither. This is an all-or-nothing statement.
  2. The changes are applied to the session context, obtained by walking up from the query context. The query context itself is not modified, so the currently-running query does not see them.
  3. The check is const on purpose — the non-const overload of checkSettingsConstraints rewrites the change list (dropping no-op changes), which would lose the “this was explicitly set” flag for a setting set to the value it already had.

The per-query path is the same, except it applies to the current context:

cpp
// src/Interpreters/InterpreterSetQuery.cpp:110
    /// const on purpose - see the note in execute().
    if (!ignore_setting_constraints)
    {
        getContext()->checkSettingsConstraints(std::as_const(changes), SettingSource::QUERY);
        getContext()->checkSettingsConstraintsForSettingsReset(ast.default_settings, changes, SettingSource::QUERY);
        rejectHTTPOnlyConstructionSettings(ast);
    }
    getContext()->applySettingsChanges(changes);
    getContext()->resetSettingsToDefaultValue(ast.default_settings);

What a setting actually is

A setting is not a map entry. It is a typed field in a struct, generated by a macro from a single X-macro list. The list lives in Settings.cpp:

cpp
// src/Core/Settings.cpp:132
#define COMMON_SETTINGS(DECLARE, DECLARE_WITH_ALIAS) \
    DECLARE(Dialect, dialect, Dialect::clickhouse, R"(
Which dialect will be used to parse query.

and the types are listed separately, so the traits generator can build one contiguous array per type:

cpp
// src/Core/Settings.h:50
#define COMMON_SETTINGS_SUPPORTED_TYPES(CLASS_NAME, M) \
    M(CLASS_NAME, AggregateFunctionInputFormat) \
    M(CLASS_NAME, ArrowCompression) \
    M(CLASS_NAME, ArrowUnsupportedTypes) \
    M(CLASS_NAME, ArrowFlightDescriptorType) \
    M(CLASS_NAME, Bool) \
    M(CLASS_NAME, BoolAuto) \

The traits generator turns the list into a type-grouped layout. This is the key trick — instead of a struct with one member per setting (which would pad to the largest alignment), all Bool settings live in one Bool_[] array, all UInt64 settings in one UInt64_[] array, and the position of each setting in each array is computed at compile time:

cpp
// src/Core/BaseSettings.h:1358
    namespace SETTINGS_TRAITS_NAME##Detail \
    { \
        enum class SettingTypeTag : uint8_t \
        { \
            SUPPORTED_TYPES_MACRO(SETTINGS_TRAITS_NAME, SETTING_DECLARE_TYPE_TAG_) \
            NUM_TYPES \
        }; \
        enum class SettingID : size_t \
        { \
            LIST_OF_SETTINGS_WITHOUT_PATH_MACRO(SETTING_DECLARE_ID_, SETTING_DECLARE_ID_) \
            LIST_OF_SETTINGS_WITH_PATH_MACRO(SETTING_DECLARE_ID_, SETTING_DECLARE_ID_) \
            NUM_SETTINGS \
        }; \
        struct SettingsLayout \
        { \
            size_t type_counts[static_cast<size_t>(SettingTypeTag::NUM_TYPES)] = {}; \
            size_t local_index[static_cast<size_t>(SettingID::NUM_SETTINGS)] = {}; \
        }; \

The comment above the macro explains the payoff:

cpp
// src/Core/BaseSettings.h:1346
    /* Constexpr typed-array layout: groups settings by SettingField type into contiguous */ \
    /* arrays (e.g. Bool_[], UInt64_[]) so that all fields of the same type are adjacent. */ \
    /* This avoids padding between differently-sized types and enables bulk memcpy for */ \
    /* trivially-copyable types in the copy constructor. The layout (array sizes and */ \
    /* per-setting indices) is computed at compile time from the settings list, replacing */ \
    /* what would otherwise require an external code-generation step. */ \

A Settings object is therefore a small object: a handful of typed arrays plus a std::unique_ptr<SettingsImpl>. Copying it is a memcpy per array, not a per-setting loop — which matters, because a query context is a copy of the session context, and the settings are the biggest part of it.

The name → index lookup is a hash map built once in the traits’ singleton Accessor:

cpp
// src/Core/BaseSettings.h:1539
            /** Metadata for one setting */ \
            struct FieldInfo \
            { \
                String name; \
                const std::string_view path; \
                const std::string_view type; \
                const std::string_view description; \
                const UInt64 flags; \
                const SettingFieldOps * ops;                    /* Type-erased ops, shared per type */ \
                size_t data_offset;                             /* Byte offset within Data struct */ \
            }; \

The flags carry the tier of the setting (EXPERIMENTAL, PRIVATE_PREVIEW, BETA, PRODUCTION) and the IMPORTANT bit. The tier matters for permissions — see below.

Setting a value by name goes through the type-erased ops, and the error message is enriched with the setting’s name and the offending value:

cpp
// src/Core/BaseSettings.h:449
template <typename TTraits>
void BaseSettings<TTraits>::set(std::string_view name, const Field & value)
{
    name = TTraits::resolveName(name);
    const auto & accessor = Traits::Accessor::instance();
    /// An unknown name is resolved outside the block that adds the context: its message already names
    /// the setting (and suggests a correction), so nothing has to be added to it.
    const size_t index = accessor.find(name);
    if (index == static_cast<size_t>(-1))
    {
        getCustomSetting(name) = value;
        return;
    }

Note the fallback: an unknown name becomes a “custom setting” — a string-valued field. That is how query parameters ({name:Type}) are carried in the same object as real settings, without colliding.

changes(): the diff between a session and a query

The async insert queue needs to know which settings the user actually changed, because that is exactly the set that must not be shared between buffers. changes() is the diff:

cpp
// src/Core/BaseSettings.h:527
template <typename TTraits>
SettingsChanges BaseSettings<TTraits>::changes() const
{
    SettingsChanges res;
    for (const auto & field : *this)
        res.emplace_back(field.getName(), field.getValue());
    return res;
}

The Settings object tracks a “changed” bit per field, set by set() and cleared by setDefaultValue(). The copy constructor preserves it — that is why a query context copied from the session context still knows which settings the user changed with SET.

The constraint checker

A setting is not just a value; it has constraints: a min, a max, a set of disallowed values, and a writability class. The constraints come from the user’s settings profiles, which are themselves access entities.

The checker is one function, and it is where readonly lives:

cpp
// src/Access/SettingsConstraints.cpp:871
SettingsConstraints::Checker SettingsConstraints::getChecker(const Settings & current_settings, std::string_view setting_name) const
{

The readonly semantics are spelled out in the comment:

cpp
// src/Access/SettingsConstraints.cpp:887
    /** The `readonly` value is understood as follows:
      * 0 - no read-only restrictions.
      * 1 - only read requests, as well as changing settings with `changeable_in_readonly` flag.
      * 2 - only read requests, as well as changing settings, except for the `readonly` setting.
      */

readonly = 2 is the “one level deeper” mode: it lets you change settings, but never the readonly setting itself, so a session cannot escape readonly mode by setting readonly = 0:

cpp
// src/Access/SettingsConstraints.cpp:893
    if (current_settings[Setting::readonly] > 1 && resolved_name == "readonly")
        return Checker(PreformattedMessage::create("Cannot modify 'readonly' setting in readonly mode"), ErrorCodes::READONLY);

readonly = 1 is stricter: no setting may be changed unless it is explicitly marked changeable_in_readonly:

cpp
// src/Access/SettingsConstraints.cpp:911
    if (current_settings[Setting::readonly] == 1)
    {
        const bool changeable_in_readonly = (it != constraints.end()
                && it->second.writability == SettingConstraintWritability::CHANGEABLE_IN_READONLY)
            || isAlwaysChangeableInReadonly(resolved_name);
        if (!changeable_in_readonly)
            return Checker(PreformattedMessage::create("Cannot modify '{}' setting in readonly mode", setting_name),
                           ErrorCodes::READONLY);
    }

And there is a hard-coded allow-list of settings that are always changeable in readonly mode, regardless of what users.xml says:

cpp
// src/Access/SettingsConstraints.cpp:82
/// Settings that are always allowed to change in readonly mode, regardless of the user profile's
/// `<constraints>` block. These are per-request HTTP routing, query-construction, and output
/// shaping settings (formerly special URL parameters like `?database=` and `?default_format=`)
/// that any client must be able to set on a GET request, even when `users.xml` does not declare
/// them as `<changeable_in_readonly/>`. Hard-coding the carve-out here (rather than shipping a
/// new `<constraints>` block in the default `users.xml`) keeps the new server compatible with
/// older `users.xml` files - and, importantly, lets older server versions continue to start up
/// against the new repo `programs/server/users.xml` (the integration-test framework mounts it
/// into backwards-compat containers, where unknown setting names in `<constraints>` would
/// otherwise be rejected with `UNKNOWN_SETTING`).
bool isAlwaysChangeableInReadonly(std::string_view name)
{
    /// HTTP routing / session.
    if (name == "database" || name == "default_format")
        return true;
    /// Selects which of `output_format` / `default_format` the `X-ClickHouse-Format` header aliases;
    /// both targets are changeable here, so the switch between them must be too.
    if (name == "http_x_clickhouse_format_overrides_output_format")
        return true;
    /// Output format selection and response compression.
    if (name == "format" || name == "input_format" || name == "output_format" || name == "compression")
        return true;

The list is short and deliberate: format, compression, database, and the query-construction settings that the HTTP “table as file” feature needs. A read-only user must be able to ask for CSV instead of JSON.

readonly on the write side

readonly is not only about settings. It also blocks the operations. The set of “writing” access types is precomputed as a bitmask:

cpp
// src/Access/ContextAccess.cpp:846
        const AccessFlags not_readonly_flags = write_table_access | table_and_dictionary_and_function_ddl | workload_ddl | resource_ddl | handler_ddl | write_dcl_access | AccessType::SYSTEM | AccessType::KILL_QUERY;
        const AccessFlags not_readonly_1_flags = AccessType::CREATE_TEMPORARY_TABLE;

and the check is a single mask test:

cpp
// src/Access/ContextAccess.cpp:871
    if (params.readonly)
    {
        if constexpr (grant_option)
            return access_denied(ErrorCodes::READONLY, "{}: Cannot change grants in readonly mode.");
        if ((flags & precalc.not_readonly_flags) ||
            ((params.readonly == 1) && (flags & precalc.not_readonly_1_flags)))
        {
            if (params.interface == ClientInfo::Interface::HTTP && params.http_method == ClientInfo::HTTPMethod::GET)
            {
                return access_denied(ErrorCodes::READONLY,
                    "{}: Cannot execute query in readonly mode. "
                    "For queries over HTTP, method GET implies readonly. "
                    "You should use method POST for modifying queries");
            }
            return access_denied(ErrorCodes::READONLY, "{}: Cannot execute query in readonly mode");
        }
    }

Note the difference between the two levels: CREATE TEMPORARY TABLE is blocked under readonly = 1 but allowed under readonly = 2. A temporary table is a session object, not a server object, so under readonly = 2 it is considered harmless. This is a genuinely debatable design decision, and the code documents it as such elsewhere.

The access cache: a 1024-entry LRU keyed by everything

Computing the access rights means walking the user’s granted roles, the default roles, the profiles, and the constraints. That is far too expensive to do per query. So AccessControl keeps a cache:

cpp
// src/Access/AccessControl.cpp:81
class AccessControl::ContextAccessCache
{
public:
    explicit ContextAccessCache(const AccessControl & access_control_) : access_control(access_control_) {}

    std::shared_ptr<const ContextAccess> getContextAccess(const ContextAccessParams & params)
    {
        if (auto cached_access = cache.get(params))
        {
            /// Check that the user hasn't been dropped while it was in the cache.
            if (!(*cached_access)->getUserID() || (*cached_access)->tryGetUser())
                return *cached_access;
        }

        auto res = std::make_shared<ContextAccess>(access_control, params);
        res->initialize();
        cache.add(params, res);
        return res;
    }

private:
    const AccessControl & access_control;
    Poco::AccessExpireCache<ContextAccess::Params, std::shared_ptr<const ContextAccess>> cache;
};

Poco::AccessExpireCache is an LRU with a TTL; the default capacity is 1024 entries. The key is ContextAccessParams, which contains everything that can change the answer — the user, the roles, the credential grant limit, the readonly flag, the current database, the client address, the quota key, the initial user:

cpp
// src/Access/ContextAccessParams.cpp:176
    #define CONTEXT_ACCESS_PARAMS_LESS(name) \
        if (auto cmp = check_less(left.name, right.name); cmp != 0) \
            return cmp < 0;

    CONTEXT_ACCESS_PARAMS_LESS(user_id)
    CONTEXT_ACCESS_PARAMS_LESS(full_access)
    CONTEXT_ACCESS_PARAMS_LESS(use_default_roles)
    CONTEXT_ACCESS_PARAMS_LESS(current_roles)
    CONTEXT_ACCESS_PARAMS_LESS(external_roles)
    CONTEXT_ACCESS_PARAMS_LESS(authentication_grants)
    CONTEXT_ACCESS_PARAMS_LESS(readonly)
    CONTEXT_ACCESS_PARAMS_LESS(allow_ddl)
    CONTEXT_ACCESS_PARAMS_LESS(allow_introspection)
    CONTEXT_ACCESS_PARAMS_LESS(current_database)
    CONTEXT_ACCESS_PARAMS_LESS(interface)
    CONTEXT_ACCESS_PARAMS_LESS(http_method)
    CONTEXT_ACCESS_PARAMS_LESS(address)
    CONTEXT_ACCESS_PARAMS_LESS(forwarded_address)
    CONTEXT_ACCESS_PARAMS_LESS(quota_key)
    CONTEXT_ACCESS_PARAMS_LESS(initial_user_id)

This is the “the key is everything” pattern again, and it is the same pattern the async insert queue uses. Two users with the same name but different addresses get different cache entries; a user who changes readonly gets a different entry.

The cache has a correctness subtlety: a cached entry whose user has been dropped is not returned:

cpp
// src/Access/AccessControl.cpp:90
            /// Check that the user hasn't been dropped while it was in the cache.
            if (!(*cached_access)->getUserID() || (*cached_access)->tryGetUser())
                return *cached_access;

Note the shape: tryGetUser() returns null if the user was dropped, and the condition !getUserID() || tryGetUser() means “return the cached entry only if it has no user id (the global context) or the user still exists”.

The context has its own small memo on top of the shared cache:

cpp
// src/Interpreters/Context.cpp:2652
    {
        SharedLockGuard lock(mutex);
        if (access && !need_recalculate_access)
            return std::make_shared<const ContextAccessWrapper>(access, shared_from_this()); /// No need to recalculate access rights.
    }

need_recalculate_access is set whenever a setting that the access computation depends on changes:

cpp
// src/Interpreters/Context.cpp:3819
    settings->set(name, value);
    if (name == COMPATIBILITY_SETTING_NAME)
        restrictSettingsChangedByCompatibilityWithLock(lock);
    if (ContextAccessParams::dependsOnSettingName(name))
        need_recalculate_access = true;

ContextAccessParams::dependsOnSettingName is the list of settings that change the access answer — readonly, allow_ddl, allow_introspection_functions, and a few others. Change one of them and the cached ContextAccess is stale.

The role cache: recompute on change, not on read

Roles are cached separately, keyed by the set of enabled roles rather than by the user:

cpp
// src/Access/RoleCache.cpp:42
std::shared_ptr<const EnabledRoles>
RoleCache::getEnabledRoles(boost::container::flat_set<UUID> roles, boost::container::flat_set<UUID> roles_with_admin_option)
{
    std::lock_guard lock{mutex};
    ensureSubscribed();

    EnabledRoles::Params params;
    params.current_roles = std::move(roles);
    params.current_roles_with_admin_option = std::move(roles_with_admin_option);
    auto it = enabled_roles_by_params.find(params);
    if (it != enabled_roles_by_params.end())
    {
        if (auto enabled_roles = it->second.lock())
            return enabled_roles;
        enabled_roles_by_params.erase(it);
    }

    auto res = std::shared_ptr<EnabledRoles>(new EnabledRoles(params));
    collectEnabledRoles(*res, nullptr);
    enabled_roles_by_params.emplace(std::move(params), res);
    return res;
}

The cache holds weak pointers, so an entry that nobody uses is dropped automatically — no eviction policy needed. And a change to a role only triggers a recomputation if that role is actually referenced by some enabled set:

cpp
// src/Access/RoleCache.cpp:187
void RoleCache::roleChanged(const UUID & role_id, const RolePtr & changed_role)
{
    /// `mutex` is already locked.

    auto role_from_cache = cache.get(role_id);
    if (role_from_cache)
    {
        /// We update the role stored in a cache entry only if that entry has not expired yet.
        *role_from_cache = changed_role;
        cache.update(role_id, role_from_cache);
    }

    /// Recalculate the access rights only if this role takes part in some enabled set.
    if (referenced_roles.contains(role_id))
        need_collect_enabled_roles = true;
}

That referenced_roles set is the trick that keeps GRANT/REVOKE cheap: changing a role nobody uses costs one contains() check, not a rebuild of every enabled set.

The scope of the whole thing

Here is the whole picture as a diagram:

text
   global context                (singleton: catalogs, access control, pools)
        |
        |  createCopy
        v
   background context            (merges, mutations, replication)
        |
        |  createCopy
        v
   session context               (SET state, current db, roles, temp tables)
        |
        |  createCopy + makeQueryContext()   <- resets throttlers, privileges, classifier
        v
   query context                 (query id, process list, cancel flag, query settings)
        |
        +-- copy of itself for subqueries, views, remote shards

And the settings flow:

text
   server default profile
        |
        v
   user profile  +  role settings      (resolved at login -> setUser)
        |
        v
   session context settings     <--- SET writes here
        |
        |  createCopy
        v
   query context settings       <--- SETTINGS clause writes here
        |
        v
   effective settings for this statement

Trade-offs

What the split buys.

  • Concurrency. Two queries on the same connection cannot interfere: each has its own copy of the settings and its own process-list entry. Cancelling one does not cancel the other.
  • Cheap query contexts. A context copy is a memcpy of a few typed arrays. It is cheap enough that the server makes one per statement without thinking about it.
  • One place to enforce policy. Because every setting write goes through checkSettingsConstraints, a profile constraint is enforced uniformly whether the change came from SET, from a SETTINGS clause, from a profile, or from a role.

What it costs.

  • Copy-on-write friction. Context::createCopy copies everything, then makeQueryContext throws some of it away. For a query context created from a session context, the copy includes the settings, the client info, the user info, the current database — all of which are usually wanted. The waste is in the reset list.
  • Weak-pointer bookkeeping. Four weak pointers, four getXContext() accessors, and the possibility of a THERE_IS_NO_QUERY exception from a place that did not expect it.
  • The cache key is the whole context. ContextAccessParams has sixteen fields, and every one of them multiplies the number of cache entries. A server with many distinct client addresses (a load balancer with source IP hashing, say) can churn the 1024-entry LRU. The forwarded_address field is there precisely because the address alone was not enough.
  • Two different readonly behaviours. readonly = 1 blocks CREATE TEMPORARY TABLE; readonly = 2 allows it. The distinction between “session object” and “server object” is a judgement call, and the code makes it in two different places (the constraint checker and the access checker), which is a maintenance hazard.

The bottom line

The context split is what lets ClickHouse have a stateful protocol (a session with settings, roles, temporary tables) on top of a stateless execution model (a query that is a self-contained pipeline). The trick is not clever: it is “copy the parent, then reset the per-query fields” — but the copy is cheap because the settings were laid out as type-grouped arrays specifically so that the copy could be a memcpy.

The part that is genuinely subtle is the ordering of checks and mutations: constraints are checked before anything is applied, readonly is enforced in two independent places (settings and operations) because they are different kernels of the same policy, and the access cache is keyed by everything that could change the answer, so it can never return a stale decision.

The thing people get wrong

SET is not the same as a SETTINGS clause, and the difference is surprising:

sql
SET max_threads = 1;
SELECT count() FROM big_table;        -- uses 1 thread, and keeps using 1 thread
SELECT count() FROM big_table SETTINGS max_threads = 16;  -- 16 threads, this statement only

And SET inside a session that has readonly = 1 fails for almost everything — including SET max_threads — while readonly = 2 lets you change everything except readonly itself. If you are debugging “why is my setting not taking effect”, check readonly first: it is the setting that disables the other settings.

ClickhouseInternalsContextSettingsSecurity