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.
SETwrites to the session context; aSETTINGSclause writes to the query context. Every write is checked against a constraint set before it is applied, andreadonly = 1blocks 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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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.
- The changes are checked before anything is applied.
SET a = 1, b = 2wherebviolates a constraint applies neither. This is an all-or-nothing statement. - 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.
- The check is
conston purpose — the non-const overload ofcheckSettingsConstraintsrewrites 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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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:
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 shardsAnd the settings flow:
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 statementTrade-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
memcpyof 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 fromSET, from aSETTINGSclause, from a profile, or from a role.
What it costs.
- Copy-on-write friction.
Context::createCopycopies everything, thenmakeQueryContextthrows 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 aTHERE_IS_NO_QUERYexception from a place that did not expect it. - The cache key is the whole context.
ContextAccessParamshas 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. Theforwarded_addressfield is there precisely because the address alone was not enough. - Two different
readonlybehaviours.readonly = 1blocksCREATE TEMPORARY TABLE;readonly = 2allows 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:
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 onlyAnd 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.