OXYGENHUB
# f677d6270298 oxygenhub / crates / hub / src / diffusion.rs
6.8 KiB · Rust · last changed Diffusion: the reverse pin index, Used by, and how far behind 24 days ago source blame history raw
1
//! Diffusion (#148, docs/diffusion.md): an upstream's changes reach the
2
//! repositories that pin it as a subrepo.
3
//!
4
//! D1 starts with the reverse pin index (§4.1): when a push moves a
5
//! repository's default bookmark, or the bookmark its work lands on, each
6
//! Oxygen subrepo pin in the new state is recorded. That answers "who
7
//! consumes this repository, at which state, and how far behind", which
8
//! an upstream's page shows as Used by.
9
10
use crate::auth::roles;
11
use crate::db::{self, accounts::Account, repos::RepoRow, Pool};
12
use crate::repos::RepoName;
13
use crate::Shared;
14
use oxy_core::hash::ChangeHash;
15
use serde::Serialize;
16
17
/// How far back landed proposals teach where a repository's work lands.
18
pub const LEARN_DAYS: i64 = 90;
19
20
/// The bookmark a repository's work lands on (§4.3): the most common
21
/// target of its proposals landed in the last `LEARN_DAYS`, else its
22
/// default bookmark.
23
pub async fn target(pool: &Pool, row: &RepoRow) -> String {
24
    let since = db::now_ms() - LEARN_DAYS * 24 * 3600 * 1000;
25
    let learned: Option<String> = sqlx::query_scalar(
26
        "SELECT target FROM proposals WHERE repo_id = ? AND state = 'landed' AND landed_ms >= ?
27
         GROUP BY target ORDER BY COUNT(*) DESC, MAX(landed_ms) DESC LIMIT 1",
28
    )
29
    .bind(row.id)
30
    .bind(since)
31
    .fetch_optional(pool)
32
    .await
33
    .ok()
34
    .flatten();
35
    learned.filter(|t| !t.is_empty()).unwrap_or_else(|| row.default_bookmark.clone())
36
}
37
38
/// The bookmarks whose pins are indexed: the default and the learned
39
/// target (one when they are the same).
40
pub async fn followed_bookmarks(pool: &Pool, row: &RepoRow) -> Vec<String> {
41
    let mut out = vec![row.default_bookmark.clone()];
42
    let t = target(pool, row).await;
43
    if !out.contains(&t) {
44
        out.push(t);
45
    }
46
    out
47
}
48
49
/// Record `row`'s pins on `bookmark` at `heads`, replacing what was there.
50
pub async fn record(app: &Shared, row: &RepoRow, bookmark: &str, heads: &[ChangeHash]) {
51
    let Some(name) = RepoName::new(&row.owner, &row.name) else { return };
52
    let pins = if heads.is_empty() { Vec::new() } else { crate::flow::pins_in(app, name, heads.to_vec()).await };
53
    let now = db::now_ms();
54
    let Ok(mut tx) = app.db.begin().await else { return };
55
    let mut ok = sqlx::query("DELETE FROM diffusion_pins WHERE consumer_id = ? AND bookmark = ?")
56
        .bind(row.id)
57
        .bind(bookmark)
58
        .execute(&mut *tx)
59
        .await
60
        .is_ok();
61
    for (path, id, pinned) in &pins {
62
        let heads: Vec<String> = pinned.iter().map(|h| h.to_hex()).collect();
63
        ok &=
64
            sqlx::query("INSERT INTO diffusion_pins (consumer_id, bookmark, path, upstream, heads, updated_ms) VALUES (?, ?, ?, ?, ?, ?)")
65
                .bind(row.id)
66
                .bind(bookmark)
67
                .bind(path)
68
                .bind(hex::encode(id))
69
                .bind(heads.join("+"))
70
                .bind(now)
71
                .execute(&mut *tx)
72
                .await
73
                .is_ok();
74
    }
75
    if ok {
76
        tx.commit().await.ok();
77
    }
78
}
79
80
/// After a push: index the pins of each moved bookmark that is followed.
81
pub async fn after_push(app: &Shared, row: &RepoRow, moved: &[(String, String, Vec<ChangeHash>)]) {
82
    let followed = followed_bookmarks(&app.db, row).await;
83
    for (bookmark, _, heads) in moved {
84
        if followed.contains(bookmark) {
85
            record(app, row, bookmark, heads).await;
86
        }
87
    }
88
}
89
90
/// Index every repository's followed bookmarks, for a hub that had no
91
/// index before (run once at start, in the background).
92
pub async fn backfill(app: &Shared) {
93
    let Ok(rows) = db::repos::list(&app.db).await else { return };
94
    for row in rows {
95
        let Some(name) = RepoName::new(&row.owner, &row.name) else { continue };
96
        for bookmark in followed_bookmarks(&app.db, &row).await {
97
            let b = bookmark.clone();
98
            let heads = app.repos.read(&name, move |r, _| heads_of(r, &b)).await;
99
            if let Ok(heads) = heads {
100
                record(app, &row, &bookmark, &heads).await;
101
            }
102
        }
103
    }
104
}
105
106
/// A bookmark's heads as the repository has them now (not a cached
107
/// list, which a push refreshes a moment later); empty when it has none.
108
fn heads_of(r: &oxy_core::repo::Repo, bookmark: &str) -> Result<Vec<ChangeHash>, crate::repos::RepoError> {
109
    Ok(r.bookmarks()?.into_iter().find(|b| b.name == bookmark).map(|b| b.heads).unwrap_or_default())
110
}
111
112
/// One consumer of an upstream, as an upstream's page and API show it.
113
#[derive(Clone, Debug, Serialize)]
114
pub struct Consumer {
115
    pub owner: String,
116
    pub name: String,
117
    pub bookmark: String,
118
    pub path: String,
119
    /// The upstream heads it pins, hex.
120
    pub pinned: Vec<String>,
121
    /// Upstream changes on its default bookmark the pin lacks; `None` when
122
    /// the hub cannot tell (the pinned changes are not in the upstream).
123
    pub behind: Option<usize>,
124
}
125
126
/// The consumers of `row` that `account` may read, with how far behind
127
/// the upstream's default bookmark each pin is.
128
pub async fn consumers(app: &Shared, row: &RepoRow, account: Option<&Account>) -> Result<Vec<Consumer>, crate::repos::RepoError> {
129
    let Some(name) = RepoName::new(&row.owner, &row.name) else { return Ok(Vec::new()) };
130
    let id = app.repos.read(&name, |r, _| Ok(r.id()?)).await?;
131
    let found: Vec<(i64, String, String, String)> = sqlx::query_as(
132
        "SELECT consumer_id, bookmark, path, heads FROM diffusion_pins WHERE upstream = ? ORDER BY consumer_id, bookmark, path",
133
    )
134
    .bind(hex::encode(id))
135
    .fetch_all(&app.db)
136
    .await
137
    .map_err(|e| crate::repos::RepoError::Io(std::io::Error::other(e)))?;
138
    let mut out = Vec::new();
139
    let mut pinned_sets = Vec::new();
140
    for (consumer_id, bookmark, path, heads) in found {
141
        let Ok(Some(c)) = db::repos::get_by_id(&app.db, consumer_id).await else { continue };
142
        if !roles::allows(roles::role_for(&app.db, account, &c).await.ok().flatten(), crate::auth::Access::Read) {
143
            continue;
144
        }
145
        let pinned: Vec<String> = heads.split('+').filter(|h| !h.is_empty()).map(String::from).collect();
146
        pinned_sets.push(pinned.iter().filter_map(|h| ChangeHash::from_hex(h)).collect::<Vec<_>>());
147
        out.push(Consumer { owner: c.owner, name: c.name, bookmark, path, pinned, behind: None });
148
    }
149
    if out.is_empty() {
150
        return Ok(out);
151
    }
152
    let default = row.default_bookmark.clone();
153
    let behind = app
154
        .repos
155
        .read(&name, move |r, cache| {
156
            let tip = heads_of(r, &default)?;
157
            let tip = cache.summary(r, &tip)?;
158
            Ok(pinned_sets
159
                .iter()
160
                .map(|p| cache.summary(r, p).ok().map(|s| tip.changes.iter().filter(|c| !s.changes.contains(*c)).count()))
161
                .collect::<Vec<_>>())
162
        })
163
        .await?;
164
    for (c, b) in out.iter_mut().zip(behind) {
165
        c.behind = b;
166
    }
167
    Ok(out)
168
}