fraiseql-core 2.19.1

Core execution engine for FraiseQL v2 - Compiled GraphQL over SQL
Documentation
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
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
//! `pg_tviews`' change feed, as the result cache reads it (#1589).
//!
//! With `pg_tviews.publish_changes = 'rows'`, every committed transaction that changed TVIEW
//! rows says which on the PostgreSQL channel [`CHANNEL`], whoever ran the write: FraiseQL, a
//! script, another application. This module reads one payload of version 2 into a
//! [`TviewsMessage`].
//!
//! ```json
//! {"v":2,"u":{"Comment":["c1"]},"d":{"Tag":["t1"]},"c":{"Comment.fk_post":["p1"]},"t":["Audit"]}
//! ```
//!
//! `u` and `d` are the rows inserted or updated and deleted, by GraphQL type and public id;
//! `c` the collections whose members changed, by `<child type>.<reference column>` and parent
//! id; `t` the types whose rows or collections are not listed. Anything this reader cannot
//! read evicts more, never less: an id it cannot read evicts its type, a message of another
//! version or of another shape evicts everything.

use std::{
    collections::{BTreeMap, BTreeSet, HashMap},
    sync::Arc,
};

use fraiseql_db::ViewName;
use serde::Deserialize;
use serde_json::Value;

use super::{
    QueryResultCache,
    collections::{CollectionMap, CollectionTagging},
};
use crate::{db::DatabaseAdapter, error::Result, schema::CompiledSchema};

/// The `NOTIFY` channel `pg_tviews` publishes on.
pub const CHANNEL: &str = "tviews_changes";

/// The payload version this reader knows.
const VERSION: u64 = 2;

/// A collection of child rows: those of `child_type` whose `column` holds a parent's key.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Collection {
    /// The children's GraphQL type (`Comment`).
    pub child_type: String,
    /// The children's reference column (`fk_post`).
    pub column:     String,
}

impl Collection {
    /// The collection of `child_type` rows by `column`.
    #[must_use]
    pub fn new(child_type: impl Into<String>, column: impl Into<String>) -> Self {
        Self {
            child_type: child_type.into(),
            column:     column.into(),
        }
    }
}

/// What one message says changed.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct TviewsChange {
    /// Rows inserted, updated or deleted: public ids by type.
    pub rows:        BTreeMap<String, BTreeSet<String>>,
    /// Collections whose members changed: parent ids by collection.
    pub collections: BTreeMap<Collection, BTreeSet<String>>,
    /// Types whose rows and collections are not listed: all of them changed.
    pub types:       BTreeSet<String>,
}

/// One payload received on [`CHANNEL`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TviewsMessage {
    /// A message this reader understands.
    Change(TviewsChange),
    /// Anything else: every entry may be stale.
    Everything,
}

/// A message on the wire. Every key but `v` may be absent.
#[derive(Deserialize)]
struct Wire {
    v: Option<u64>,
    #[serde(default)]
    u: BTreeMap<String, Vec<Value>>,
    #[serde(default)]
    d: BTreeMap<String, Vec<Value>>,
    #[serde(default)]
    c: BTreeMap<String, Vec<Value>>,
    #[serde(default)]
    t: Vec<String>,
}

/// Read a payload received on [`CHANNEL`].
#[must_use]
pub fn decode(payload: &str) -> TviewsMessage {
    let Ok(wire) = serde_json::from_str::<Wire>(payload) else {
        return TviewsMessage::Everything;
    };
    if wire.v != Some(VERSION) {
        return TviewsMessage::Everything;
    }
    let mut change = TviewsChange {
        types: wire.t.into_iter().collect(),
        ..TviewsChange::default()
    };
    for (type_name, ids) in wire.u.into_iter().chain(wire.d) {
        match ids_of(&ids) {
            Some(ids) => change.rows.entry(type_name).or_default().extend(ids),
            None => {
                change.types.insert(type_name);
            },
        }
    }
    for (key, parents) in wire.c {
        let Some(collection) = collection_of(&key) else {
            return TviewsMessage::Everything;
        };
        match ids_of(&parents) {
            Some(ids) => change.collections.entry(collection).or_default().extend(ids),
            None => {
                change.types.insert(collection.child_type);
            },
        }
    }
    TviewsMessage::Change(change)
}

/// The ids `values` hold, each as text, or `None` when one is neither text nor a number.
fn ids_of(values: &[Value]) -> Option<Vec<String>> {
    values.iter().map(id_text).collect()
}

/// A public id as the feed and the cache's tags spell it: a uuid's (or any string's) text, an
/// integer's decimal digits; `None` for anything else.
pub(super) fn id_text(value: &Value) -> Option<String> {
    match value {
        Value::String(id) => Some(id.clone()),
        Value::Number(id) => Some(id.to_string()),
        _ => None,
    }
}

/// `Comment.fk_post` as its child type and column: the type is a GraphQL name, which holds
/// no dot, so the first dot splits them.
fn collection_of(key: &str) -> Option<Collection> {
    let (child_type, column) = key.split_once('.')?;
    (!child_type.is_empty() && !column.is_empty()).then(|| Collection::new(child_type, column))
}

/// What the change feed is read against, from the database: the views reading each type,
/// and the collections composed levels are.
#[derive(Debug, Clone, Default)]
pub struct FeedCatalog {
    /// For each GraphQL type, the views (as FraiseQL names them) that read its TVIEW, on
    /// their own or through views.
    pub type_views:  HashMap<String, Vec<ViewName>>,
    /// The collections composed levels are.
    pub collections: CollectionMap,
}

impl FeedCatalog {
    /// A catalog from `(type, view)` rows and `(child view, parent view, child type,
    /// reference column, parent identity column)` rows: one collection per reference, which
    /// a level joining on exactly those columns forms.
    #[must_use]
    pub fn from_rows<'a>(
        type_rows: impl IntoIterator<Item = (&'a str, &'a str)>,
        collection_rows: impl IntoIterator<Item = (&'a str, &'a str, &'a str, &'a str, &'a str)>,
    ) -> Self {
        let mut type_views: HashMap<String, Vec<ViewName>> = HashMap::new();
        for (type_name, view) in type_rows {
            let views = type_views.entry(type_name.to_string()).or_default();
            let view = ViewName::from(view);
            if !views.contains(&view) {
                views.push(view);
            }
        }
        let collections = CollectionMap::from_levels(collection_rows.into_iter().map(
            |(child, parent, child_type, column, key)| {
                ((child, parent, column, key), Collection::new(child_type, column))
            },
        ));
        Self {
            type_views,
            collections,
        }
    }
}

/// Whether every session of the database publishes rows on [`CHANNEL`], and its registry
/// names types (`typename`, which names collections).
///
/// What this session sees is not enough: a role-level setting could turn it on for
/// FraiseQL's role only, or off for another writer, whose writes would then never be heard.
/// So the value must come from the server or the database (not a role, a user or this
/// session), and no role may override it in this database.
const PUBLISHING_PROBE: &str = "SELECT EXISTS (SELECT 1 FROM pg_catalog.pg_settings \
     WHERE name = 'pg_tviews.publish_changes' AND setting = 'rows' \
     AND source IN ('configuration file', 'database', 'global')) \
     AND NOT EXISTS (SELECT 1 FROM pg_catalog.pg_db_role_setting s, \
     LATERAL unnest(s.setconfig) c WHERE s.setrole <> 0 \
     AND s.setdatabase IN (0, (SELECT oid FROM pg_catalog.pg_database \
     WHERE datname = current_database())) \
     AND c LIKE 'pg_tviews.publish\\_changes=%' AND c <> 'pg_tviews.publish_changes=rows') \
     AND EXISTS (SELECT 1 FROM pg_attribute \
     WHERE attrelid = to_regclass('tviews.registry') AND attname = 'typename' \
     AND NOT attisdropped) AS publishing";

/// The registry's TVIEWs, as a CTE following [`reads_cte`](super::embedding_graph); a TVIEW
/// in a schema this role cannot use has no `rel`.
fn registry() -> String {
    format!(
        "tv AS (SELECT {}::oid AS rel, g.entity, g.typename, g.\"references\" AS refs \
         FROM tviews.registry g)",
        fraiseql_db::usable_relation("g.schema", "g.name")
    )
}

/// Read the [`FeedCatalog`] of `schema`'s views from the database `adapter` serves, or
/// `None` when it does not publish rows on [`CHANNEL`].
///
/// # Errors
///
/// `FraiseQLError::Database` when a catalog query fails.
pub async fn read_feed_catalog<A: DatabaseAdapter + ?Sized>(
    adapter: &A,
    schema: &CompiledSchema,
) -> Result<Option<FeedCatalog>> {
    let publishing = adapter
        .execute_raw_query(PUBLISHING_PROBE)
        .await?
        .first()
        .and_then(|row| row.get("publishing"))
        .and_then(Value::as_bool)
        .unwrap_or(false);
    if !publishing {
        return Ok(None);
    }
    let names = super::swept_view_names(schema);
    if names.is_empty() {
        return Ok(Some(FeedCatalog::default()));
    }
    let reads = super::embedding_graph::reads_cte(&names);
    let registry = registry();
    let types = adapter
        .execute_raw_query(&format!(
            "{reads}, {registry} \
             SELECT DISTINCT tv.typename AS type_name, s.name AS view \
             FROM reads x JOIN roots s ON s.rel = x.root JOIN tv ON tv.rel = x.rel \
             ORDER BY 1, 2"
        ))
        .await?;
    let collections = adapter
        .execute_raw_query(&format!(
            "{reads}, {registry} \
             SELECT c.name AS child, p.name AS parent, ct.typename AS child_type, \
                    ref.key AS column_name, ref.value ->> 'column' AS key_name \
             FROM roots c JOIN tv ct ON ct.rel = c.rel \
             CROSS JOIN LATERAL jsonb_each(ct.refs) ref \
             JOIN tv pt ON pt.entity = ref.value ->> 'entity' \
             JOIN roots p ON p.rel = pt.rel"
        ))
        .await?;
    let text = |row: &HashMap<String, Value>, key: &str| {
        row.get(key).and_then(Value::as_str).map(str::to_owned)
    };
    let types: Vec<(String, String)> = types
        .iter()
        .filter_map(|row| Some((text(row, "type_name")?, text(row, "view")?)))
        .collect();
    let collections: Vec<[String; 5]> = collections
        .iter()
        .filter_map(|row| {
            Some([
                text(row, "child")?,
                text(row, "parent")?,
                text(row, "child_type")?,
                text(row, "column_name")?,
                text(row, "key_name")?,
            ])
        })
        .collect();
    Ok(Some(FeedCatalog::from_rows(
        types.iter().map(|(t, v)| (t.as_str(), v.as_str())),
        collections
            .iter()
            .map(|[c, p, t, k, r]| (c.as_str(), p.as_str(), t.as_str(), k.as_str(), r.as_str())),
    )))
}

/// Applies `pg_tviews`' change feed to this server's cache.
pub struct TviewsEvictor {
    cache:      Arc<QueryResultCache>,
    type_views: HashMap<String, Vec<ViewName>>,
}

impl TviewsEvictor {
    /// An evictor for `cache`, sweeping for each type the views `type_views` say read it.
    #[must_use]
    pub const fn new(
        cache: Arc<QueryResultCache>,
        type_views: HashMap<String, Vec<ViewName>>,
    ) -> Self {
        Self { cache, type_views }
    }

    /// Evict what `message` says changed.
    ///
    /// # Errors
    ///
    /// Infallible today; the cache's eviction methods keep a `Result` for API compatibility.
    pub fn apply(&self, message: &TviewsMessage) -> Result<()> {
        let TviewsMessage::Change(change) = message else {
            return self.cache.clear();
        };
        // Rows of a type: every view reading the type, as a write FraiseQL ran would sweep
        // it, but not the levels indexed by collection, nor the reads of one row by id: `c`
        // and the rows name those precisely (#1576).
        let views: BTreeSet<&ViewName> = change
            .rows
            .keys()
            .chain(&change.types)
            .filter_map(|type_name| self.type_views.get(type_name))
            .flatten()
            .collect();
        if !views.is_empty() {
            let views: Vec<ViewName> = views.into_iter().cloned().collect();
            self.cache.invalidate_views_except_collections(&views)?;
        }
        for (type_name, ids) in &change.rows {
            for id in ids {
                self.cache.invalidate_by_id(type_name, id)?;
            }
        }
        for type_name in &change.types {
            self.cache.invalidate_by_id_type(type_name)?;
        }
        for (collection, parents) in &change.collections {
            for parent in parents {
                self.cache.invalidate_collection(collection, parent)?;
            }
        }
        // A type whose rows are not listed may have joined or left any collection of it.
        for type_name in &change.types {
            self.cache.invalidate_collections_of(type_name)?;
        }
        Ok(())
    }
}

/// What a server's listener on [`CHANNEL`] does to its cache (#1589).
pub struct TviewsListener {
    evictor:     TviewsEvictor,
    tagging:     CollectionTagging,
    collections: CollectionMap,
}

impl TviewsListener {
    /// A listener for `cache`, whose composed reads `tagging` indexes, against `catalog`.
    #[must_use]
    pub fn new(
        cache: Arc<QueryResultCache>,
        tagging: CollectionTagging,
        catalog: FeedCatalog,
    ) -> Self {
        Self {
            evictor: TviewsEvictor::new(cache, catalog.type_views),
            tagging,
            collections: catalog.collections,
        }
    }

    /// The session listens on [`CHANNEL`]. Nothing written before is heard: clear the
    /// cache, then index composed reads by collection, in that order (a fill that read the
    /// map is never older than the clear).
    pub fn listening(&self) {
        let _ = self.evictor.cache.clear();
        self.tagging.hear(self.collections.clone());
    }

    /// The session is lost: what is written from now on is not heard. Stop indexing by
    /// collection, then clear the cache, whose fence refuses every fill already in flight.
    pub fn lost(&self) {
        self.tagging.deafen();
        let _ = self.evictor.cache.clear();
    }

    /// `pg_tviews` committed transactions without publishing them (#1604): what they wrote
    /// was never heard, so any entry may be stale. Clear the cache; the feed is still heard,
    /// so composed reads stay indexed by collection.
    pub fn missed(&self) {
        let _ = self.evictor.cache.clear();
    }

    /// Apply `payload`, received on [`CHANNEL`]. An eviction that fails clears the cache:
    /// evicting less than the write requires is a stale answer.
    pub fn receive(&self, payload: &str) {
        if self.evictor.apply(&decode(payload)).is_err() {
            let _ = self.evictor.cache.clear();
        }
    }
}

#[cfg(test)]
#[path = "tviews_feed_tests.rs"]
mod tviews_feed_tests;