Skip to main content

aws_smithy_schema/schema/
extension.rs

1/*
2 * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3 * SPDX-License-Identifier: Apache-2.0
4 */
5
6//! Schema extensions: derived data that runtime components compute once per
7//! schema and cache on the schema itself.
8//!
9//! A codec, protocol, or any other runtime component declares a
10//! [`SchemaExtensionKey`] as a `static`, pairing the type of the data with the
11//! function that derives it from a schema. [`Schema::extension`] runs that
12//! function the first time a given schema is asked for that key and returns the
13//! cached value on every later call.
14//!
15//! ```
16//! use aws_smithy_schema::extension::SchemaExtensionKey;
17//! use aws_smithy_schema::{shape_id, Schema, ShapeType};
18//!
19//! /// A pre-encoded `"name":` JSON object key.
20//! struct FieldKey(Box<str>);
21//!
22//! static FIELD_KEY: SchemaExtensionKey<FieldKey> = SchemaExtensionKey::new(|schema| {
23//!     FieldKey(format!("\"{}\":", schema.member_name().unwrap_or_default()).into())
24//! });
25//!
26//! static MEMBER: Schema<'static> =
27//!     Schema::new_member(shape_id!("ns", "S", "name"), ShapeType::String, "name", 0);
28//!
29//! assert_eq!(&*MEMBER.extension(&FIELD_KEY).0, "\"name\":");
30//! // The second lookup returns the same cached value.
31//! assert!(std::ptr::eq(MEMBER.extension(&FIELD_KEY), MEMBER.extension(&FIELD_KEY)));
32//! ```
33//!
34//! # Every schema has storage
35//!
36//! The storage is a field of [`Schema`], so a schema emitted by any version of the
37//! code generator, a hand-written schema, and one materialized from a model at
38//! runtime all cache identically. No component needs a fallback path for schemas
39//! that lack storage.
40//!
41//! A schema declared as a `const` rather than a `static` is copied at each use, so
42//! values cached on it are discarded with each copy. Declare schemas as `static`s.
43//!
44//! Nothing is allocated until a schema is first asked for an extension. That
45//! allocates room for eight values, and each cached value is boxed. The cache is
46//! freed when the schema is dropped, so a schema built from a model at runtime
47//! does not leak its cached values.
48//!
49//! # Providers
50//!
51//! A provider must derive its value from the schema it is given and the schemas
52//! reachable from it, through ordinary accessors. It must not request extensions:
53//! a recursive shape could lead back to the schema being initialized, which would
54//! otherwise wait on itself forever. A provider that requests an extension that is
55//! not yet cached panics instead, for any key and any schema. Ask for a member's
56//! extension at the point it is needed, outside the provider.
57//!
58//! Values must not depend on per-instance codec settings, because a schema is shared
59//! by every codec instance in the process. Cache every variant a setting can select
60//! (for example, both the `@jsonName` and the member name) and choose at the use
61//! site.
62//!
63//! If a provider panics, the panic propagates to the caller and nothing is cached;
64//! the next request for that key runs the provider again.
65//!
66//! Values are `'static`, so they cannot borrow from the schema. Store owned data,
67//! or member indices rather than member schema references.
68
69use crate::Schema;
70use std::any::Any;
71use std::cell::Cell;
72use std::fmt;
73use std::marker::PhantomData;
74use std::sync::atomic::{AtomicUsize, Ordering};
75use std::sync::OnceLock;
76
77/// Identifies one kind of derived data cached on schemas, and computes it.
78///
79/// Declare keys as `static`s. Each key is assigned a process-unique slot the first
80/// time it is used, and slots are never reused.
81///
82/// [`Schema::extension`] takes a `&'static` key, so a `const` key, which would be a
83/// new key with a new slot at every use, is rejected at compile time:
84///
85/// ```compile_fail
86/// use aws_smithy_schema::extension::SchemaExtensionKey;
87/// use aws_smithy_schema::prelude;
88///
89/// const KEY: SchemaExtensionKey<u8> = SchemaExtensionKey::new(|_| 0);
90/// let _ = prelude::STRING.extension(&KEY);
91/// ```
92pub struct SchemaExtensionKey<T> {
93    /// `0` until assigned; afterwards the slot index plus one.
94    slot: AtomicUsize,
95    provider: fn(&Schema<'_>) -> T,
96    _value: PhantomData<fn() -> T>,
97}
98
99impl<T> SchemaExtensionKey<T> {
100    /// Creates a key whose values are derived by `provider`.
101    pub const fn new(provider: fn(&Schema<'_>) -> T) -> Self {
102        Self {
103            slot: AtomicUsize::new(0),
104            provider,
105            _value: PhantomData,
106        }
107    }
108
109    #[inline]
110    fn slot(&self) -> usize {
111        match self.slot.load(Ordering::Relaxed) {
112            0 => self.assign_slot(),
113            encoded => encoded - 1,
114        }
115    }
116
117    #[cold]
118    fn assign_slot(&self) -> usize {
119        // The slot number is the only shared state, and the value cells it indexes
120        // synchronize themselves, so relaxed ordering is enough. A thread that loses
121        // the race discards its candidate; slots are plentiful, so the gap is harmless.
122        static NEXT_SLOT: AtomicUsize = AtomicUsize::new(0);
123        let candidate = NEXT_SLOT.fetch_add(1, Ordering::Relaxed) + 1;
124        match self
125            .slot
126            .compare_exchange(0, candidate, Ordering::Relaxed, Ordering::Relaxed)
127        {
128            Ok(_) => candidate - 1,
129            Err(existing) => existing - 1,
130        }
131    }
132}
133
134impl<T> fmt::Debug for SchemaExtensionKey<T> {
135    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
136        f.debug_struct("SchemaExtensionKey")
137            .field("value_type", &std::any::type_name::<T>())
138            .finish_non_exhaustive()
139    }
140}
141
142/// Number of value cells per chunk.
143///
144/// Slots are assigned per process in order of first use, so a key that lands past the
145/// first chunk costs every schema that reads it a second chunk and an extra dependent
146/// load on each lookup. The first chunk should therefore hold every key the runtime
147/// crates declare: today the `@httpHeader` name in this crate, the JSON member keys,
148/// the XML list and map names, and the awsQuery collection names, which a process
149/// using restXml, a JSON protocol and awsQuery clients together uses all at once.
150/// Raise this when adding keys; the cost is paid only by schemas that read an extension.
151const CHUNK_LEN: usize = 8;
152
153type ExtensionCell = OnceLock<Box<dyn Any + Send + Sync>>;
154
155struct Chunk {
156    cells: [ExtensionCell; CHUNK_LEN],
157    next: OnceLock<Box<Chunk>>,
158}
159
160impl Chunk {
161    fn new() -> Box<Self> {
162        Box::new(Self {
163            cells: [const { OnceLock::new() }; CHUNK_LEN],
164            next: OnceLock::new(),
165        })
166    }
167}
168
169/// Per-schema extension storage. Empty until the schema is first asked for an
170/// extension, so untouched schemas cost one word plus the cell's state.
171pub(crate) struct ExtensionStorage(OnceLock<Box<Chunk>>);
172
173impl ExtensionStorage {
174    pub(crate) const fn new() -> Self {
175        Self(OnceLock::new())
176    }
177
178    #[inline]
179    pub(crate) fn get_or_compute<'s, T: Send + Sync + 'static>(
180        &'s self,
181        schema: &Schema<'_>,
182        key: &SchemaExtensionKey<T>,
183    ) -> &'s T {
184        let mut slot = key.slot();
185        let mut chunk: &Chunk = self.0.get_or_init(Chunk::new);
186        while slot >= CHUNK_LEN {
187            chunk = chunk.next.get_or_init(Chunk::new);
188            slot -= CHUNK_LEN;
189        }
190        let cell = &chunk.cells[slot];
191        let value: &(dyn Any + Send + Sync) = match cell.get() {
192            // Deref the box: `Box<dyn Any>` is itself `Any`, so downcasting the box
193            // would test the box's type rather than the value's.
194            Some(value) => &**value,
195            None => initialize(cell, schema, key),
196        };
197        value
198            .downcast_ref::<T>()
199            .expect("an extension slot is only ever filled by the key that owns it")
200    }
201}
202
203thread_local! {
204    /// Whether this thread is running an extension provider.
205    static IN_PROVIDER: Cell<bool> = const { Cell::new(false) };
206}
207
208/// Clears [`IN_PROVIDER`] when the provider returns or panics.
209struct ProviderGuard;
210
211impl Drop for ProviderGuard {
212    fn drop(&mut self) {
213        IN_PROVIDER.with(|in_provider| in_provider.set(false));
214    }
215}
216
217/// Computes and caches the value of an empty cell.
218///
219/// A provider that requests an uncached extension panics here, before it waits on any
220/// cell. Waiting would deadlock if the request led back, through a recursive schema or
221/// another thread's provider, to a cell whose initialization is waiting on this one.
222/// Callers outside a provider still wait for a concurrent initialization, so each value
223/// is computed once.
224#[cold]
225#[inline(never)]
226fn initialize<'c, T: Send + Sync + 'static>(
227    cell: &'c ExtensionCell,
228    schema: &Schema<'_>,
229    key: &SchemaExtensionKey<T>,
230) -> &'c (dyn Any + Send + Sync) {
231    if IN_PROVIDER.with(Cell::get) {
232        panic!(
233            "a schema extension provider requested the uncached extension `{}` for schema `{}`; \
234             providers must derive their value from the schema without requesting extensions",
235            std::any::type_name::<T>(),
236            schema.shape_id().as_str(),
237        );
238    }
239    &**cell.get_or_init(|| {
240        IN_PROVIDER.with(|in_provider| in_provider.set(true));
241        let _guard = ProviderGuard;
242        Box::new((key.provider)(schema))
243    })
244}
245
246impl fmt::Debug for ExtensionStorage {
247    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
248        // Cached values are derived data, so they are left out of `Schema`'s output.
249        f.write_str("..")
250    }
251}
252
253#[cfg(test)]
254mod tests {
255    use super::SchemaExtensionKey;
256    use crate::{shape_id, Schema, ShapeType};
257    use std::sync::atomic::{AtomicUsize, Ordering};
258
259    static CALLS: AtomicUsize = AtomicUsize::new(0);
260
261    static LEN: SchemaExtensionKey<usize> = SchemaExtensionKey::new(|schema| {
262        CALLS.fetch_add(1, Ordering::SeqCst);
263        schema.member_name().map_or(0, str::len)
264    });
265
266    static MEMBER: Schema<'static> =
267        Schema::new_member(shape_id!("ns", "S", "abc"), ShapeType::String, "abc", 0);
268
269    #[test]
270    fn computes_once_per_schema() {
271        let before = CALLS.load(Ordering::SeqCst);
272        assert_eq!(*MEMBER.extension(&LEN), 3);
273        assert_eq!(*MEMBER.extension(&LEN), 3);
274        assert_eq!(CALLS.load(Ordering::SeqCst) - before, 1);
275    }
276
277    #[test]
278    fn works_on_runtime_schemas() {
279        let name = String::from("runtime");
280        let id = crate::ShapeId::from_parts("ns#S$runtime", "ns", "S");
281        let schema = Schema::new_member(id, ShapeType::String, &name, 0);
282        assert_eq!(*schema.extension(&LEN), 7);
283    }
284
285    #[test]
286    fn values_are_dropped_with_their_schema() {
287        static DROPS: AtomicUsize = AtomicUsize::new(0);
288        struct Tracked;
289        impl Drop for Tracked {
290            fn drop(&mut self) {
291                DROPS.fetch_add(1, Ordering::SeqCst);
292            }
293        }
294        static KEY: SchemaExtensionKey<Tracked> = SchemaExtensionKey::new(|_| Tracked);
295
296        let schema = Schema::new(shape_id!("ns", "Dropped"), ShapeType::String);
297        let _ = schema.extension(&KEY);
298        assert_eq!(DROPS.load(Ordering::SeqCst), 0);
299        drop(schema);
300        assert_eq!(DROPS.load(Ordering::SeqCst), 1);
301    }
302
303    #[test]
304    fn a_panicking_provider_caches_nothing() {
305        static ATTEMPTS: AtomicUsize = AtomicUsize::new(0);
306        static KEY: SchemaExtensionKey<usize> = SchemaExtensionKey::new(|_| {
307            if ATTEMPTS.fetch_add(1, Ordering::SeqCst) == 0 {
308                panic!("first attempt fails");
309            }
310            7
311        });
312        static S: Schema<'static> = Schema::new(shape_id!("ns", "Panics"), ShapeType::String);
313
314        let first = std::panic::catch_unwind(|| *S.extension(&KEY));
315        assert!(first.is_err());
316        assert_eq!(*S.extension(&KEY), 7);
317        assert_eq!(*S.extension(&KEY), 7);
318        assert_eq!(ATTEMPTS.load(Ordering::SeqCst), 2);
319    }
320
321    #[test]
322    #[cfg(target_pointer_width = "64")]
323    fn chunk_size() {
324        // Eight 24-byte cells and the 16-byte link: the heap cost of the first extension
325        // a schema reads.
326        assert_eq!(std::mem::size_of::<super::Chunk>(), 208);
327    }
328
329    #[test]
330    fn many_keys_spill_into_later_chunks() {
331        static KEYS: [SchemaExtensionKey<usize>; 9] = [
332            SchemaExtensionKey::new(|_| 0),
333            SchemaExtensionKey::new(|_| 1),
334            SchemaExtensionKey::new(|_| 2),
335            SchemaExtensionKey::new(|_| 3),
336            SchemaExtensionKey::new(|_| 4),
337            SchemaExtensionKey::new(|_| 5),
338            SchemaExtensionKey::new(|_| 6),
339            SchemaExtensionKey::new(|_| 7),
340            SchemaExtensionKey::new(|_| 8),
341        ];
342        static S: Schema<'static> = Schema::new(shape_id!("ns", "T"), ShapeType::String);
343        for (i, key) in KEYS.iter().enumerate() {
344            assert_eq!(*S.extension(key), i);
345        }
346        for (i, key) in KEYS.iter().enumerate().rev() {
347            assert_eq!(*S.extension(key), i);
348        }
349    }
350
351    #[test]
352    fn concurrent_first_access_computes_once() {
353        static N: AtomicUsize = AtomicUsize::new(0);
354        static KEY: SchemaExtensionKey<u64> = SchemaExtensionKey::new(|_| {
355            N.fetch_add(1, Ordering::SeqCst);
356            42
357        });
358        static S: Schema<'static> = Schema::new(shape_id!("ns", "U"), ShapeType::String);
359        std::thread::scope(|scope| {
360            for _ in 0..8 {
361                scope.spawn(|| assert_eq!(*S.extension(&KEY), 42));
362            }
363        });
364        assert_eq!(N.load(Ordering::SeqCst), 1);
365    }
366
367    /// Runs `f` and returns its panic message, failing if it did not panic.
368    fn panic_message(f: impl FnOnce() + std::panic::UnwindSafe) -> String {
369        let payload = std::panic::catch_unwind(f).expect_err("expected a panic");
370        payload
371            .downcast_ref::<String>()
372            .cloned()
373            .or_else(|| payload.downcast_ref::<&str>().map(|s| s.to_string()))
374            .unwrap_or_default()
375    }
376
377    #[test]
378    fn a_provider_requesting_its_own_key_panics() {
379        static KEY: SchemaExtensionKey<u8> =
380            SchemaExtensionKey::new(|schema| *schema.extension(&KEY));
381        static S: Schema<'static> = Schema::new(shape_id!("ns", "SelfRef"), ShapeType::String);
382
383        let message = panic_message(|| {
384            let _ = S.extension(&KEY);
385        });
386        assert!(message.contains("uncached extension"), "{message}");
387        assert!(message.contains("ns#SelfRef"), "{message}");
388        // Nothing is cached and the guard is cleared, so the next attempt fails the same way.
389        let message = panic_message(|| {
390            let _ = S.extension(&KEY);
391        });
392        assert!(message.contains("uncached extension"), "{message}");
393    }
394
395    /// The example from review: DynamoDB `AttributeValue` refers to itself through a list
396    /// and a map, and the provider recurses into members.
397    #[test]
398    fn a_recursive_provider_over_a_recursive_schema_panics() {
399        use crate::prelude;
400
401        static AV_S: Schema<'static> = Schema::new_member(
402            shape_id!("com.amazonaws.dynamodb", "AttributeValue", "S"),
403            ShapeType::String,
404            "S",
405            0,
406        );
407        static AV_L: Schema<'static> = Schema::new_member(
408            shape_id!("com.amazonaws.dynamodb", "AttributeValue", "L"),
409            ShapeType::List,
410            "L",
411            1,
412        )
413        .with_list_member(&ATTRIBUTE_VALUE);
414        static AV_M: Schema<'static> = Schema::new_member(
415            shape_id!("com.amazonaws.dynamodb", "AttributeValue", "M"),
416            ShapeType::Map,
417            "M",
418            2,
419        )
420        .with_map_members(&prelude::STRING, &ATTRIBUTE_VALUE);
421        static ATTRIBUTE_VALUE: Schema<'static> = Schema::new_struct(
422            shape_id!("com.amazonaws.dynamodb", "AttributeValue"),
423            ShapeType::Union,
424            &[&AV_S, &AV_L, &AV_M],
425        );
426        static CONTAINS_SENSITIVE: SchemaExtensionKey<bool> = SchemaExtensionKey::new(|schema| {
427            schema.sensitive().is_some()
428                || schema
429                    .members()
430                    .iter()
431                    .any(|m| *m.extension(&CONTAINS_SENSITIVE))
432                || schema
433                    .member()
434                    .is_some_and(|t| *t.extension(&CONTAINS_SENSITIVE))
435                || schema
436                    .key()
437                    .is_some_and(|t| *t.extension(&CONTAINS_SENSITIVE))
438        });
439
440        let message = panic_message(|| {
441            let _ = ATTRIBUTE_VALUE.extension(&CONTAINS_SENSITIVE);
442        });
443        assert!(message.contains("uncached extension"), "{message}");
444    }
445
446    #[test]
447    fn a_cycle_through_two_keys_panics() {
448        static A: SchemaExtensionKey<u8> = SchemaExtensionKey::new(|schema| *schema.extension(&B));
449        static B: SchemaExtensionKey<u8> = SchemaExtensionKey::new(|schema| *schema.extension(&A));
450        static S: Schema<'static> = Schema::new(shape_id!("ns", "TwoKeys"), ShapeType::String);
451
452        let message = panic_message(|| {
453            let _ = S.extension(&A);
454        });
455        assert!(message.contains("uncached extension"), "{message}");
456    }
457
458    /// Two threads each initialize one cell whose provider requests the other's cell.
459    /// Waiting would deadlock; both providers must panic instead.
460    #[test]
461    fn a_cycle_across_threads_panics_instead_of_deadlocking() {
462        use std::sync::Barrier;
463
464        static BOTH_IN_PROVIDERS: Barrier = Barrier::new(2);
465        static S1: Schema<'static> = Schema::new(shape_id!("ns", "Thread1"), ShapeType::String);
466        static S2: Schema<'static> = Schema::new(shape_id!("ns", "Thread2"), ShapeType::String);
467        static A: SchemaExtensionKey<u8> = SchemaExtensionKey::new(|_| {
468            BOTH_IN_PROVIDERS.wait();
469            *S2.extension(&B)
470        });
471        static B: SchemaExtensionKey<u8> = SchemaExtensionKey::new(|_| {
472            BOTH_IN_PROVIDERS.wait();
473            *S1.extension(&A)
474        });
475
476        std::thread::scope(|scope| {
477            let first = scope.spawn(|| *S1.extension(&A));
478            let second = scope.spawn(|| *S2.extension(&B));
479            assert!(first.join().is_err());
480            assert!(second.join().is_err());
481        });
482    }
483
484    /// The guard is checked only when a cell is initialized, keeping it off the cached
485    /// lookup path. This is not a supported provider pattern: whether the value is cached
486    /// depends on access order.
487    #[test]
488    fn a_provider_may_read_an_already_cached_extension() {
489        static BASE: SchemaExtensionKey<u8> = SchemaExtensionKey::new(|_| 2);
490        static DERIVED: SchemaExtensionKey<u8> =
491            SchemaExtensionKey::new(|schema| *schema.extension(&BASE) * 3);
492        static S: Schema<'static> = Schema::new(shape_id!("ns", "Cached"), ShapeType::String);
493
494        assert_eq!(*S.extension(&BASE), 2);
495        assert_eq!(*S.extension(&DERIVED), 6);
496    }
497
498    #[test]
499    fn a_panicking_provider_clears_the_guard() {
500        static FAILS: SchemaExtensionKey<u8> =
501            SchemaExtensionKey::new(|_| panic!("provider failed"));
502        static OK: SchemaExtensionKey<u8> = SchemaExtensionKey::new(|_| 5);
503        static S: Schema<'static> = Schema::new(shape_id!("ns", "Cleared"), ShapeType::String);
504
505        assert_eq!(
506            panic_message(|| {
507                let _ = S.extension(&FAILS);
508            }),
509            "provider failed"
510        );
511        // Not treated as nested: this thread is no longer inside a provider.
512        assert_eq!(*S.extension(&OK), 5);
513    }
514}