Commit 4cf789d
authored
feat: per-principal cache namespacing for SQL/search/caching-accelerator (spiceai#10680) (spiceai#10702)
* feat(runtime-request-context): add CacheNamespace + AuthPrincipal::stable_id (milestone 1 of spiceai#10680)
Introduce the CacheNamespace enum (Public / Principal(id) / System) on
RequestContext as the foundation for per-user scoped caching under U2M
auth.
- New CacheNamespace type with kind() (telemetry, low-cardinality) and
as_header_value() (HTTP-vocabulary 'shared'/'user'/'system').
- AuthPrincipal gains stable_id() -> Option<Cow<str>> with default None.
Real principals override; the default returns None which maps to the
Public namespace (correct behavior for the existing Anonymous principal).
- ApiKey::stable_id() returns 'apikey:<sha256(key)[..16]>'. The literal
key value is never exposed; rotated keys produce a new id, which
correctly treats them as a different principal.
- RequestContext::cache_namespace() computes the namespace lazily from
protocol + auth_principal, with an optional builder override
(with_cache_namespace) for SWR background revalidation and similar
flows that must inherit the originating user's namespace.
- to_dimensions() now emits cache_namespace_kind (low-cardinality only;
never includes the principal id).
No call sites consume the namespace yet — this is purely additive
infrastructure. Subsequent milestones wire it into the SQL results
cache, search cache, response headers, caching accelerator, and
cluster RPC propagation.
Refs: spiceai#10680
* feat: scope SQL results + search caches by CacheNamespace; add Results-Cache-Scope header (spiceai#10680)
Mix the request's CacheNamespace into every cache key for the SQL
results cache, the plan cache (via the SQL-keyed plans cache key),
and the search cache. Two requests in different namespaces now hash
to distinct cache entries for otherwise-identical inputs, so cached
results cannot leak between authenticated principals.
cache::key::CacheKey
- New as_raw_key_in_namespace(hasher, tag, id_bytes) folds a
[tag][id_len LE u64][id_bytes] prefix into the hasher before the
existing payload bytes, so (tag=1, id='abc') + payload starting
with 'def' cannot collide with (tag=1, id='abcdef') + 'def'.
- Existing as_raw_key() is unchanged and is used by the embeddings
cache, which is intentionally globally shared because (model,
input) → embedding is permission-independent.
- hash_payload() is extracted so both methods walk identical bytes
for the same payload.
runtime-request-context::CacheNamespace
- hash_inputs() returns (u8 tag, &[u8] id) for use with
as_raw_key_in_namespace. Tags are stable across releases
(0=Public, 1=Principal, 2=System) so cached entries survive
upgrades.
runtime/src/datafusion/query/cache.rs
- get_plan_or_cached and try_get_cached_result derive the namespace
from the request context and pass its hash inputs through to
every key materialization (SQL key, client-supplied key, plan
key). The plans cache (`get_or_create_logical_plan`) is fed
namespace-mixed keys via sql_raw_cache_key, so plan caching is
also per-namespace.
runtime/src/search/search_engine.rs
- search_with_cache mixes the namespace into the search cache key.
HTTP response headers (runtime/src/http/v1/{mod,search}.rs)
- New Results-Cache-Scope and Search-Results-Cache-Scope sibling
headers next to the existing *-Cache-Status, with values
'shared' / 'user' / 'system'. No principal id ever appears in
the header, so it is safe to log and dashboard.
- When scope == 'user', append 'Authorization' to Vary so any
HTTP cache between Spice and the client never collapses entries
belonging to different principals.
- Added a small append_vary helper that combines fields rather
than overwriting (RFC 7231 §7.1.4), and switched the existing
Spice-Cache-Key Vary to use it.
Tests
- runtime-request-context: hash_inputs_distinct_per_variant
asserts tag mapping and that empty-id Public/System never collide.
- runtime: test_results_cache_isolated_per_principal drives two
distinct Principal namespaces through the full Query pipeline and
asserts MISS on the second principal's first request, HIT on
each principal's repeat, and MISS for an unauthenticated Public
caller. This is the explicit cross-principal isolation guarantee.
Refs: spiceai#10680
* feat(runtime): SWR background revalidation inherits originating cache namespace (spiceai#10680)
trigger_background_query_revalidation now plumbs the originating
request's CacheNamespace into create_background_context, which sets
it as an explicit override on the new internal RequestContext. The
background query therefore re-executes under the user's namespace,
not under System.
Why it matters: any cache lookup the background query performs while
re-running (planner cache, search cache, the upcoming caching
accelerator) must use the same namespace as the originating request.
Without this propagation, sub-cache reads/writes during SWR refresh
would land in a different scope and either leak cross-user (caching
accelerator) or never serve the user who triggered the refresh.
The result write itself is unaffected because cache_revalidation_result
already writes under the original (already-namespace-mixed) cache key.
New test test_swr_revalidation_inherits_originating_namespace
exercises the full SWR lifecycle for two distinct principals: Alice
populates, waits past TTL, sees STALE, the background refresh runs,
Alice then sees a fresh HIT, and Bob \u2014 issuing the same SQL \u2014 still
sees a MISS, proving the refresh did not bleed into another scope.
Refs: spiceai#10680
* feat(runtime): reserve __spice_cache_namespace column for caching accelerator (m4 foundation, spiceai#10680)
Introduces the foundation for per-namespace isolation inside the
caching accelerator (refresh_mode: caching). No behavior change:
nothing yet writes, reads, or stamps the column \u2014 this commit only
defines the names, validation, and helpers later commits will use.
- CACHE_NAMESPACE_COLUMN = "__spice_cache_namespace". The leading
double-underscore matches the existing internal-name convention
used elsewhere in Spice and makes accidental collision with real
user columns very unlikely.
- is_reserved_caching_column() for use by dataset config validation
(case-insensitive so SQL-style identifiers also collide).
- extend_schema_with_cache_namespace() returns the storage schema
used by the caching accelerator: the source schema with one
appended Utf8 NOT NULL column. NOT NULL is deliberate \u2014 a NULL on
read indicates a corrupt or pre-isolation table and we want that
to surface as an error rather than silently behaving as 'public'.
Returns DataFusionError::Plan with both the dataset name and the
reserved column name on collision, so users hitting the breaking
change get a self-explanatory message instead of a schema mismatch
deep in the accelerator.
Tests cover case-insensitive reservation, append-and-shape of the
extended schema, and the collision error message contents.
Refs: spiceai#10680
* feat(runtime): extend caching accelerator storage schema with namespace column; hide from user-facing schema (m4b, spiceai#10680)
Builds on the m4 foundation. In caching mode (refresh_mode: caching),
the underlying accelerator table is now created with one extra column
appended to the source schema: __spice_cache_namespace (Utf8 NOT NULL).
The user-facing AcceleratedTable schema continues to expose only the
original columns, so query planning, federation, and user projections
are unaffected.
This is the wiring step \u2014 nothing yet stamps or filters by the column;
later commits add per-namespace read filtering and write stamping.
datafusion/mod.rs::make_accelerated_table:
- When refresh_mode == Caching, derive storage_schema from
refresh_schema via extend_schema_with_cache_namespace and pass it
to create_accelerator_table. The schema-extension helper returns a
self-explanatory error if the source schema already declares the
reserved column name.
- Tell AcceleratedTableBuilder to expose refresh_schema (the original)
as the user-facing schema via the new user_facing_schema setter.
accelerated_table/mod.rs:
- AcceleratedTable gains user_facing_schema: Option<SchemaRef>.
schema() returns it when set, otherwise the storage schema (no
behavior change for non-caching modes).
- Builder::user_facing_schema(schema) setter; defaulted to None.
Schema flow correctness:
- Storage = user_facing + 1 appended column. All user-facing column
indices remain valid in storage, so projections forward unchanged.
- extend_projection_for_caching looks up CACHE_REFRESHED_AT_COLUMN by
name, so its returned indices are valid in either schema.
- SchemaCastScanExec at the top of the scan plan uses the user-facing
target schema and projects by column name via try_cast_to, so the
namespace column is naturally stripped from results.
Breaking change: pre-existing caching accelerator storage from earlier
Spice versions does not have __spice_cache_namespace and will fail to
open with a schema-mismatch error from the underlying engine. Users
must drop the on-disk store (e.g. delete duckdb_file path or DROP TABLE
on SQLite/Postgres/Cayenne) and restart so Spice recreates the table.
All 183 accelerated_table unit tests pass; clippy clean.
Refs: spiceai#10680
* feat(runtime): namespace-scope caching accelerator + drop redundant read-only cache bypass (m4cd, spiceai#10680)
This commit lands two changes that turn out to be inseparable in
practice. M4cd makes the caching accelerator per-principal isolated;
once that holds, the read-only short-circuit in the SQL results
cache becomes both unnecessary and actively harmful (it forced
read-only API keys to skip the cache they could safely reuse).
------------------------------------------------------------
Part 1 \u2014 caching-accelerator namespace scoping (m4cd)
------------------------------------------------------------
Builds on m4b (storage column) by actually using the column for
isolation. Combined with m4b, this is the safety-meaningful change:
two principals running the same caching-accelerated query against
the same dataset are now backed by disjoint cached rows, both at
read time and write time, including SWR background refresh.
CacheNamespace::storage_id (runtime-request-context):
- New stable-string accessor that maps each variant to its on-disk
tag: "public", "system", or the principal's opaque id verbatim.
These values are persisted in __spice_cache_namespace and must
survive upgrades.
caching.rs (runtime/accelerated_table):
- stamp_namespace_column(batch, ns_id): appends a constant-string
Utf8 NOT NULL column populated with ns_id for every row.
Idempotent if the column is already present.
- namespace_filter_expr(ns_id): builds the equality predicate
pushed onto every accelerator scan in caching mode.
- CacheWriteRequest gains namespace_id: Arc<str>. Send-sites
(handle_cache_miss, handle_cache_hit's SWR refresh, refresh_entry)
record the originating namespace; the flush task is the one
that stamps the column and augments upsert filters.
- flush_cache_writes inspects the accelerator's actual schema. If
__spice_cache_namespace is present (real deployments per m4b),
every batch is stamped and every upsert filter set gets a
namespace equality predicate appended. If not (test mocks),
behavior is unchanged so caching unit tests can keep their
minimal schemas.
- handle_cache_miss / handle_cache_hit / refresh_entry now take a
CacheNamespace parameter, plumbed from a single capture at the
top of CachingAccelerationScanExec::execute.
AcceleratedTable::scan (caching mode):
- Augments the filters passed to accelerator.scan with
__spice_cache_namespace = $current_ns. The federated source
still receives only the user's original filters (it does not
have the column).
- Strict re-application via FilterExec on top of the accelerator
scan output. Filters passed to TableProvider::scan are an
*optimization hint* in DataFusion; an accelerator that returns
Inexact / Unsupported (or even one that returns Exact and
silently does not push down) would let rows from other
namespaces leak through. End-to-end testing with DuckDB
confirmed this happens in practice without strict re-application.
- extend_projection_for_caching now extends the storage projection
to include __spice_cache_namespace whenever the accelerator
schema has the column, so the FilterExec on top can resolve it
even when the user's SELECT does not name it. SchemaCastScanExec
strips both fetched_at and __spice_cache_namespace from the
user-facing output by name.
Request-context propagation (the subtle bit):
- DataFusion does not propagate Tokio task-locals across the
TableProvider::scan await point or into ExecutionPlan::execute
on worker threads. Reading the cache namespace via
RequestContext::current() in those contexts silently falls back
to the global INTERNAL_REQUEST_CONTEXT (Protocol::Internal, no
principal), collapsing every caller to CacheNamespace::System
and defeating isolation. End-to-end testing caught this.
- Both AcceleratedTable::scan and
CachingAccelerationScanExec::execute now read the request
context from the SessionConfig / TaskContext extension where
Query::run_internal attaches it, matching the pattern used by
BytesProcessedExec.
Verified end-to-end with two API keys against an HTTP source dataset
(refresh_mode: caching, engine: duckdb file): each principal triggers
exactly one upstream fetch for a cold scan, repeats serve from
their own cached row, and the other principal's repeat does not
inherit. Inspecting the underlying DuckDB shows two rows stamped
with two distinct apikey:<sha> namespaces.
------------------------------------------------------------
Part 2 \u2014 drop the read-only short-circuit in SQL results cache
------------------------------------------------------------
Previously get_plan_or_cached treated read_only=true as a hard
"skip cache lookup AND skip cache populate" signal. That was a
workaround for two hazards both now handled at the right layer:
1. Cross-principal leakage. A write-capable caller's cached output
could in theory be served back to a read-only caller on a later
identical query. With cache keys namespaced by CacheNamespace
(this PR), cross-principal hits are structurally impossible \u2014
a read-only caller can only ever see entries it (or another
caller in the same namespace) populated.
2. Cache-served write-capable plans bypassing read-only validation.
QueryResultsCacheProvider::cache_is_enabled_for_plan refuses to
cache DDL/DML/Copy/Statement and every LogicalPlan::Extension
whose name appears in WRITE_CAPABLE_EXTENSION_NAMES (currently
DdlExtension and DmlExtension). The cache itself therefore never
stores a write-capable plan; nothing for read-only validation to
bypass on the read side. The old doc comment cited
DistributedCayenneInsert as a gap, but that name is a *physical*
exec node \u2014 its logical form is DmlExtension, already covered.
Removing the short-circuit means read-only API keys (the default
when no :rw suffix is given) now get cache hits for repeated
queries, the same as RW keys, while remaining isolated from other
principals. Verified manually with two bare API keys against
/v1/sql:
alice repeat -> HIT
bob (same SQL) -> MISS (not alice's entry)
bob repeat -> HIT
alice again -> HIT (still her entry, untouched by bob)
Changes:
- get_plan_or_cached drops the read_only parameter and the three
if read_only branches (two lookup skips + one populate skip).
- Doc comment rewritten to state the two invariants the cache now
relies on.
- Two callers in datafusion/query.rs updated; the read_only flag is
still threaded into QueryBuilder for validate_sql_query_read_only,
which is unaffected.
Refs: spiceai#10680
* test(runtime): integration coverage for per-principal caching-accelerator isolation (spiceai#10680)
End-to-end regression gate for the namespace-scoping work in m4cd.
Two tokio integration tests sit in tests/acceleration/, gated on the
`duckdb` feature, exercising the full pipeline:
HTTP source (axum mock with hit counter)
-> caching-mode acceleration (refresh_mode: caching)
-> DuckDB file accelerator (tempdir)
-> Query::run_internal (via query_builder)
-> AcceleratedTable::scan
-> CachingAccelerationScanExec::execute (worker thread)
The caching accelerator unit tests use minimal mock storage and never
go through the planner / TaskContext, so they cannot reach the two
silent-failure modes that were caught only by manual end-to-end
testing during m4cd:
1. DataFusion treats filters passed to TableProvider::scan as
optimization hints; an accelerator that does not push down would
leak rows from another principal's namespace.
2. DataFusion does not propagate Tokio task-locals through
TableProvider::scan or ExecutionPlan::execute, so reading the
namespace via RequestContext::current() collapses every caller to
CacheNamespace::System and silently breaks isolation.
Either regression would silently turn the cross-principal scenario
into a data leak rather than a test failure. Verified by injecting
each regression in turn before landing this test:
- Removing the FilterExec re-application: passes (DuckDB pushes the
predicate down hard), but documents the behavior; the protection
remains for accelerators with weaker pushdown.
- Restoring RequestContext::current() in
CachingAccelerationScanExec::execute: FAILS as expected
("alice repeat must NOT fetch upstream (saw 2 fetches; expected 1)"),
proving the test gates the more dangerous of the two regressions.
Tests added:
- caching_accelerator_isolates_per_principal_e2e
Walks alice -> alice (cache hit) -> bob (cross-principal cold
fetch, NOT alice's row) -> bob (cache hit) -> alice (still her
own row, untouched by bob). Asserts upstream fetch counts at
every step plus body inequality across principals.
- caching_accelerator_hides_namespace_column_from_user_schema
Confirms the user-facing schema does not expose
__spice_cache_namespace, and that referencing it in SQL fails
with a normal "no field" error rather than silently resolving
against the hidden storage column.
Implementation notes:
- Uses ApiKey::ReadOnly principals plumbed through
RequestContext::set_auth_principal + ctx.scope() so the same
query string under different principals produces different
CacheNamespace values (apikey:<sha256[..16]>).
- Goes through query_builder().build().run() rather than the raw
DataFrame API so Query::run_internal attaches the per-call
RequestContext to the SessionConfig. The DataFrame path bypasses
this and the scan would silently fall back to System ns,
collapsing both principals into one cache scope.
- Sleeps 1s between scans (>= 2x CACHE_WRITE_FLUSH_INTERVAL_MS) so
the async batched cache flush is guaranteed to land before the
next scan looks for the row.
Refs: spiceai#106801 parent b6c0662 commit 4cf789d
17 files changed
Lines changed: 1750 additions & 136 deletions
File tree
- crates
- cache/src
- runtime-auth
- src
- runtime-request-context/src
- runtime
- src
- accelerated_table
- datafusion
- query
- http/v1
- search
- tests/acceleration
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
86 | 86 | | |
87 | 87 | | |
88 | 88 | | |
89 | | - | |
90 | | - | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
91 | 93 | | |
92 | | - | |
93 | | - | |
94 | | - | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
95 | 97 | | |
96 | | - | |
97 | | - | |
| 98 | + | |
| 99 | + | |
98 | 100 | | |
99 | 101 | | |
100 | | - | |
| 102 | + | |
101 | 103 | | |
102 | 104 | | |
103 | 105 | | |
104 | 106 | | |
105 | | - | |
| 107 | + | |
106 | 108 | | |
107 | 109 | | |
108 | 110 | | |
| |||
111 | 113 | | |
112 | 114 | | |
113 | 115 | | |
114 | | - | |
115 | | - | |
| 116 | + | |
| 117 | + | |
116 | 118 | | |
117 | 119 | | |
118 | 120 | | |
119 | 121 | | |
120 | 122 | | |
121 | | - | |
| 123 | + | |
122 | 124 | | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
123 | 162 | | |
124 | 163 | | |
125 | 164 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
17 | 17 | | |
18 | 18 | | |
19 | 19 | | |
20 | | - | |
| 20 | + | |
| 21 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
14 | 14 | | |
15 | 15 | | |
16 | 16 | | |
| 17 | + | |
17 | 18 | | |
18 | 19 | | |
19 | 20 | | |
20 | 21 | | |
21 | 22 | | |
| 23 | + | |
22 | 24 | | |
23 | 25 | | |
24 | 26 | | |
25 | 27 | | |
26 | 28 | | |
27 | 29 | | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
28 | 47 | | |
29 | 48 | | |
30 | 49 | | |
| |||
59 | 78 | | |
60 | 79 | | |
61 | 80 | | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
62 | 101 | | |
63 | 102 | | |
| 103 | + | |
| 104 | + | |
64 | 105 | | |
65 | 106 | | |
66 | 107 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
0 commit comments