|
1 |
+ |
//! Keeping the `git_notes` index in step with `refs/notes/*`.
|
|
2 |
+ |
//!
|
|
3 |
+ |
//! <!-- wiki: mnw-server-git-notes -->
|
|
4 |
+ |
//!
|
|
5 |
+ |
//! The repository is truth and this is a projection of it, so everything here
|
|
6 |
+ |
//! is written to be safely repeatable: a reindex that runs twice does nothing
|
|
7 |
+ |
//! the second time, a reindex that dies halfway leaves rows that the next one
|
|
8 |
+ |
//! recomputes, and a reindex that never runs at all costs correctness nothing
|
|
9 |
+ |
//! because every read path can still walk the repository.
|
|
10 |
+ |
//!
|
|
11 |
+ |
//! **Three call sites, not one.** The plan named the post-receive hook. That
|
|
12 |
+ |
//! covers a push and nothing else: the P3 inbox merge and the P2 browser write
|
|
13 |
+ |
//! both move `refs/notes/*` from inside this process, where no hook fires. A
|
|
14 |
+ |
//! hook-only index would silently hold only the notes that arrived by push,
|
|
15 |
+ |
//! which is the minority of them.
|
|
16 |
+ |
//!
|
|
17 |
+ |
//! None of the three tells this module which tip it is replacing. They do not
|
|
18 |
+ |
//! all know it, and the one that does could be wrong about it (a hook that runs
|
|
19 |
+ |
//! late, a push that landed while a merge was running). The previously indexed
|
|
20 |
+ |
//! tip is read from `git_notes_index_state` instead, so all three are the same
|
|
21 |
+ |
//! call and the diff is always against what the index actually holds.
|
|
22 |
+ |
|
|
23 |
+ |
use axum::{Json, extract::State, http::HeaderMap};
|
|
24 |
+ |
use chrono::{DateTime, Utc};
|
|
25 |
+ |
use gix::bstr::ByteSlice;
|
|
26 |
+ |
use serde::{Deserialize, Serialize};
|
|
27 |
+ |
use sqlx::PgPool;
|
|
28 |
+ |
|
|
29 |
+ |
use crate::{
|
|
30 |
+ |
config::Config,
|
|
31 |
+ |
db::{self, GitRepoId},
|
|
32 |
+ |
error::{AppError, Result},
|
|
33 |
+ |
git::notes::{self, GixEngine, NoteObjects, Oid},
|
|
34 |
+ |
};
|
|
35 |
+ |
|
|
36 |
+ |
use super::repos_root;
|
|
37 |
+ |
|
|
38 |
+ |
/// How many notes are written per transaction.
|
|
39 |
+ |
///
|
|
40 |
+ |
/// A cold index of a heavily annotated repository is one diff and can be large;
|
|
41 |
+ |
/// the batch keeps a single statement's parameter arrays and the memory behind
|
|
42 |
+ |
/// them bounded. The index-state row is written only with the last batch, so a
|
|
43 |
+ |
/// run that dies partway through leaves the namespace claiming the tip it had
|
|
44 |
+ |
/// before and the next run recomputes the whole diff. Both halves of applying a
|
|
45 |
+ |
/// change are idempotent, which is what makes redoing it free.
|
|
46 |
+ |
const BATCH: usize = 500;
|
|
47 |
+ |
|
|
48 |
+ |
/// How much of a commit subject the feed keeps.
|
|
49 |
+ |
const SUMMARY_MAX_CHARS: usize = 200;
|
|
50 |
+ |
|
|
51 |
+ |
/// What one namespace's reindex turned out to need.
|
|
52 |
+ |
enum Outcome {
|
|
53 |
+ |
/// The ref is gone. The index forgets the namespace rather than emptying
|
|
54 |
+ |
/// it, so a namespace that comes back is walked in full.
|
|
55 |
+ |
Gone,
|
|
56 |
+ |
/// The index already holds this tip.
|
|
57 |
+ |
UpToDate,
|
|
58 |
+ |
Apply(Plan),
|
|
59 |
+ |
}
|
|
60 |
+ |
|
|
61 |
+ |
struct Plan {
|
|
62 |
+ |
tip: String,
|
|
63 |
+ |
/// The notes commit's own committer, which is who wrote the annotations in
|
|
64 |
+ |
/// this diff and when. Not the person who triggered the reindex: a rebuild
|
|
65 |
+ |
/// run in 2027 must not restamp a note written in 2026 as written today.
|
|
66 |
+ |
updated_at: DateTime<Utc>,
|
|
67 |
+ |
updated_by: String,
|
|
68 |
+ |
upserts: Vec<db::git_notes::NoteUpsert>,
|
|
69 |
+ |
removals: Vec<String>,
|
|
70 |
+ |
}
|
|
71 |
+ |
|
|
72 |
+ |
/// Bring one namespace's rows up to date with the ref.
|
|
73 |
+ |
///
|
|
74 |
+ |
/// Errors are the caller's to swallow or report. A write path should log and
|
|
75 |
+ |
/// carry on: the note is in the repository either way, and failing somebody's
|
|
76 |
+ |
/// annotation because a projection of it could not be updated would be the
|
|
77 |
+ |
/// index deciding what the repository is allowed to hold.
|
|
78 |
+ |
#[tracing::instrument(skip(db_pool, config), fields(%owner, %repo_name, %namespace))]
|
|
79 |
+ |
pub async fn reindex_namespace(
|
|
80 |
+ |
db_pool: &PgPool,
|
|
81 |
+ |
config: &Config,
|
|
82 |
+ |
repo_id: GitRepoId,
|
|
83 |
+ |
owner: &str,
|
|
84 |
+ |
repo_name: &str,
|
|
85 |
+ |
namespace: &str,
|
|
86 |
+ |
) -> Result<()> {
|
|
87 |
+ |
let previous = db::git_notes::indexed_tip(db_pool, repo_id, namespace).await?;
|
|
88 |
+ |
let root = repos_root(config)?;
|
|
89 |
+ |
|
|
90 |
+ |
let owned = (
|
|
91 |
+ |
owner.to_string(),
|
|
92 |
+ |
repo_name.to_string(),
|
|
93 |
+ |
namespace.to_string(),
|
|
94 |
+ |
);
|
|
95 |
+ |
let outcome = tokio::task::spawn_blocking(move || {
|
|
96 |
+ |
let (owner, repo_name, namespace) = owned;
|
|
97 |
+ |
plan(&root, &owner, &repo_name, &namespace, previous.as_deref())
|
|
98 |
+ |
})
|
|
99 |
+ |
.await
|
|
100 |
+ |
.map_err(|e| AppError::Internal(anyhow::anyhow!("notes reindex task failed: {e}")))??;
|
|
101 |
+ |
|
|
102 |
+ |
match outcome {
|
|
103 |
+ |
Outcome::UpToDate => Ok(()),
|
|
104 |
+ |
Outcome::Gone => db::git_notes::forget_namespace(db_pool, repo_id, namespace).await,
|
|
105 |
+ |
Outcome::Apply(plan) => apply(db_pool, repo_id, namespace, plan).await,
|
|
106 |
+ |
}
|
|
107 |
+ |
}
|
|
108 |
+ |
|
|
109 |
+ |
/// Bring every namespace up to date, and forget the ones the repository no
|
|
110 |
+ |
/// longer has.
|
|
111 |
+ |
///
|
|
112 |
+ |
/// The full-rebuild entry point. Reindexing a namespace the index has never
|
|
113 |
+ |
/// seen walks it whole, so dropping the rows and calling this reconstructs the
|
|
114 |
+ |
/// table from the repositories, which is what the load-bearing rule promises.
|
|
115 |
+ |
#[tracing::instrument(skip(db_pool, config), fields(%owner, %repo_name))]
|
|
116 |
+ |
pub async fn reindex_repo(
|
|
117 |
+ |
db_pool: &PgPool,
|
|
118 |
+ |
config: &Config,
|
|
119 |
+ |
repo_id: GitRepoId,
|
|
120 |
+ |
owner: &str,
|
|
121 |
+ |
repo_name: &str,
|
|
122 |
+ |
) -> Result<()> {
|
|
123 |
+ |
let root = repos_root(config)?;
|
|
124 |
+ |
let owned = (owner.to_string(), repo_name.to_string());
|
|
125 |
+ |
let present = tokio::task::spawn_blocking(move || -> Result<Vec<String>> {
|
|
126 |
+ |
let (owner, repo_name) = owned;
|
|
127 |
+ |
let repo = crate::git::open_repo(&root, &owner, &repo_name)?;
|
|
128 |
+ |
let engine = GixEngine::new(&repo);
|
|
129 |
+ |
Ok(notes::list_namespaces(&engine)
|
|
130 |
+ |
.map_err(to_app)?
|
|
131 |
+ |
.into_iter()
|
|
132 |
+ |
.map(|ns| ns.name)
|
|
133 |
+ |
.collect())
|
|
134 |
+ |
})
|
|
135 |
+ |
.await
|
|
136 |
+ |
.map_err(|e| AppError::Internal(anyhow::anyhow!("notes reindex task failed: {e}")))??;
|
|
137 |
+ |
|
|
138 |
+ |
for namespace in &present {
|
|
139 |
+ |
reindex_namespace(db_pool, config, repo_id, owner, repo_name, namespace).await?;
|
|
140 |
+ |
}
|
|
141 |
+ |
|
|
142 |
+ |
// Namespaces the index knows and the repository does not. A deleted notes
|
|
143 |
+ |
// ref fires a hook for the delete, but a rebuild has to catch the ones that
|
|
144 |
+ |
// went away while nothing was watching.
|
|
145 |
+ |
for namespace in db::git_notes::indexed_namespaces(db_pool, repo_id).await? {
|
|
146 |
+ |
if !present.contains(&namespace) {
|
|
147 |
+ |
db::git_notes::forget_namespace(db_pool, repo_id, &namespace).await?;
|
|
148 |
+ |
}
|
|
149 |
+ |
}
|
|
150 |
+ |
Ok(())
|
|
151 |
+ |
}
|
|
152 |
+ |
|
|
153 |
+ |
/// Read the repository and work out what the index is missing.
|
|
154 |
+ |
///
|
|
155 |
+ |
/// Blocking: this opens a repository and walks trees. Everything it returns is
|
|
156 |
+ |
/// owned, so the async half touches no git object.
|
|
157 |
+ |
fn plan(
|
|
158 |
+ |
root: &std::path::Path,
|
|
159 |
+ |
owner: &str,
|
|
160 |
+ |
repo_name: &str,
|
|
161 |
+ |
namespace: &str,
|
|
162 |
+ |
previous: Option<&str>,
|
|
163 |
+ |
) -> Result<Outcome> {
|
|
164 |
+ |
let repo = crate::git::open_repo(root, owner, repo_name)?;
|
|
165 |
+ |
let engine = GixEngine::new(&repo);
|
|
166 |
+ |
|
|
167 |
+ |
let full_ref = format!("{}{namespace}", notes::NOTES_REF_PREFIX);
|
|
168 |
+ |
let Some(tip) = engine.resolve_ref(&full_ref).map_err(to_app)? else {
|
|
169 |
+ |
return Ok(Outcome::Gone);
|
|
170 |
+ |
};
|
|
171 |
+ |
|
|
172 |
+ |
// A stored tip the repository cannot read is treated as no tip at all,
|
|
173 |
+ |
// which walks the namespace from cold. It happens for real: a notes ref can
|
|
174 |
+ |
// be force-pushed and the commit it used to name collected, and refusing to
|
|
175 |
+ |
// index a repository because of a commit that no longer exists would leave
|
|
176 |
+ |
// it stale forever with nothing to fix it.
|
|
177 |
+ |
let previous = previous
|
|
178 |
+ |
.and_then(|hex| Oid::from_hex(hex.as_bytes()).ok())
|
|
179 |
+ |
.filter(|oid| engine.read_commit(*oid).is_ok());
|
|
180 |
+ |
if previous == Some(tip) {
|
|
181 |
+ |
return Ok(Outcome::UpToDate);
|
|
182 |
+ |
}
|
|
183 |
+ |
|
|
184 |
+ |
let changes = notes::diff_notes(&engine, previous, Some(tip)).map_err(to_app)?;
|
|
185 |
+ |
let meta = engine.read_commit(tip).map_err(to_app)?;
|
|
186 |
+ |
|
|
187 |
+ |
let mut upserts = Vec::new();
|
|
188 |
+ |
let mut removals = Vec::new();
|
|
189 |
+ |
let mut buffer = Vec::new();
|
|
190 |
+ |
for change in changes {
|
|
191 |
+ |
match change {
|
|
192 |
+ |
notes::NoteChange::Removed { target } => removals.push(target.to_hex()),
|
|
193 |
+ |
notes::NoteChange::Set { target, blob } => {
|
|
194 |
+ |
buffer.clear();
|
|
195 |
+ |
engine.read_blob_into(blob, &mut buffer).map_err(to_app)?;
|
|
196 |
+ |
let (target_is_commit, target_summary, target_time) = target_facts(&repo, target);
|
|
197 |
+ |
upserts.push(db::git_notes::NoteUpsert {
|
|
198 |
+ |
target_oid: target.to_hex(),
|
|
199 |
+ |
blob_oid: blob.to_hex(),
|
|
200 |
+ |
content: String::from_utf8_lossy(&buffer).into_owned(),
|
|
201 |
+ |
target_is_commit,
|
|
202 |
+ |
target_summary,
|
|
203 |
+ |
target_time,
|
|
204 |
+ |
});
|
|
205 |
+ |
}
|
|
206 |
+ |
}
|
|
207 |
+ |
}
|
|
208 |
+ |
|
|
209 |
+ |
Ok(Outcome::Apply(Plan {
|
|
210 |
+ |
tip: tip.to_hex(),
|
|
211 |
+ |
updated_at: meta.committer.time,
|
|
212 |
+ |
updated_by: meta.committer.name,
|
|
213 |
+ |
upserts,
|
|
214 |
+ |
removals,
|
|
215 |
+ |
}))
|
|
216 |
+ |
}
|
|
217 |
+ |
|
|
218 |
+ |
/// What the annotated object is, for the feed and the annotated-commits filter.
|
|
219 |
+ |
///
|
|
220 |
+ |
/// A target that is not a commit is the ordinary case for a note on a blob, and
|
|
221 |
+ |
/// one that resolves to nothing at all is ordinary too: a notes tree can outlive
|
|
222 |
+ |
/// the object it annotates.
|
|
223 |
+ |
fn target_facts(repo: &gix::Repository, target: Oid) -> (bool, String, Option<DateTime<Utc>>) {
|
|
224 |
+ |
let commit = gix::ObjectId::from_hex(target.to_hex().as_bytes())
|
|
225 |
+ |
.ok()
|
|
226 |
+ |
.and_then(|oid| repo.find_commit(oid).ok());
|
|
227 |
+ |
let Some(commit) = commit else {
|
|
228 |
+ |
return (false, String::new(), None);
|
|
229 |
+ |
};
|
|
230 |
+ |
|
|
231 |
+ |
let summary: String = commit
|
|
232 |
+ |
.message_raw()
|
|
233 |
+ |
.map(|m| m.to_str_lossy().into_owned())
|
|
234 |
+ |
.unwrap_or_default()
|
|
235 |
+ |
.lines()
|
|
236 |
+ |
.next()
|
|
237 |
+ |
.unwrap_or_default()
|
|
238 |
+ |
.chars()
|
|
239 |
+ |
.take(SUMMARY_MAX_CHARS)
|
|
240 |
+ |
.collect();
|
|
241 |
+ |
let when = commit
|
|
242 |
+ |
.committer()
|
|
243 |
+ |
.ok()
|
|
244 |
+ |
.and_then(|c| c.time().ok())
|
|
245 |
+ |
.and_then(|t| DateTime::from_timestamp(t.seconds, 0));
|
|
246 |
+ |
(true, summary, when)
|
|
247 |
+ |
}
|
|
248 |
+ |
|
|
249 |
+ |
/// Write a plan out, in batches, claiming the tip only once all of it landed.
|
|
250 |
+ |
async fn apply(db_pool: &PgPool, repo_id: GitRepoId, namespace: &str, plan: Plan) -> Result<()> {
|
|
251 |
+ |
let mut removals = plan.removals.as_slice();
|
|
252 |
+ |
let mut chunks = plan.upserts.chunks(BATCH).peekable();
|
|
253 |
+ |
|
|
254 |
+ |
// An empty diff still has to record the tip: the namespace's history moved
|
|
255 |
+ |
// without changing any note, and leaving the old tip stored would make every
|
|
256 |
+ |
// later reindex rewalk from it.
|
|
257 |
+ |
if chunks.peek().is_none() {
|
|
258 |
+ |
return db::git_notes::apply_changes(
|
|
259 |
+ |
db_pool,
|
|
260 |
+ |
repo_id,
|
|
261 |
+ |
&db::git_notes::NamespaceUpdate {
|
|
262 |
+ |
namespace,
|
|
263 |
+ |
upserts: &[],
|
|
264 |
+ |
removals,
|
|
265 |
+ |
tip: Some(&plan.tip),
|
|
266 |
+ |
updated_at: plan.updated_at,
|
|
267 |
+ |
updated_by: &plan.updated_by,
|
|
268 |
+ |
},
|
|
269 |
+ |
)
|
|
270 |
+ |
.await;
|
|
271 |
+ |
}
|
|
272 |
+ |
|
|
273 |
+ |
while let Some(batch) = chunks.next() {
|
|
274 |
+ |
let last = chunks.peek().is_none();
|
|
275 |
+ |
db::git_notes::apply_changes(
|
|
276 |
+ |
db_pool,
|
|
277 |
+ |
repo_id,
|
|
278 |
+ |
&db::git_notes::NamespaceUpdate {
|
|
279 |
+ |
namespace,
|
|
280 |
+ |
upserts: batch,
|
|
281 |
+ |
// Removals go with the first batch; they are one statement and
|
|
282 |
+ |
// repeating them per batch would be pure cost.
|
|
283 |
+ |
removals,
|
|
284 |
+ |
tip: last.then_some(plan.tip.as_str()),
|
|
285 |
+ |
updated_at: plan.updated_at,
|
|
286 |
+ |
updated_by: &plan.updated_by,
|
|
287 |
+ |
},
|
|
288 |
+ |
)
|
|
289 |
+ |
.await?;
|
|
290 |
+ |
removals = &[];
|
|
291 |
+ |
}
|
|
292 |
+ |
Ok(())
|
|
293 |
+ |
}
|
|
294 |
+ |
|
|
295 |
+ |
fn to_app(e: notes::NotesError) -> AppError {
|
|
296 |
+ |
crate::git::GitError::from(e).into()
|
|
297 |
+ |
}
|
|
298 |
+ |
|
|
299 |
+ |
// ── The hook endpoint ──
|
|
300 |
+ |
|
|
301 |
+ |
#[derive(Deserialize)]
|
|
302 |
+ |
pub(super) struct ReindexRequest {
|
|
303 |
+ |
repo_owner: String,
|
|
304 |
+ |
repo_name: String,
|
|
305 |
+ |
/// The full notes ref the push landed on.
|
|
306 |
+ |
ref_name: String,
|
|
307 |
+ |
}
|
|
308 |
+ |
|
|
309 |
+ |
#[derive(Serialize)]
|
|
310 |
+ |
pub(super) struct ReindexResponse {
|
|
311 |
+ |
namespace: String,
|
|
312 |
+ |
}
|
|
313 |
+ |
|
|
314 |
+ |
/// `POST /api/internal/notes/reindex`
|
|
315 |
+ |
///
|
|
316 |
+ |
/// Called by the post-receive hook for a push to `refs/notes/*`. Auth is the
|
|
317 |
+ |
/// per-repo HMAC the other hook arms carry.
|
|
318 |
+ |
///
|
|
319 |
+ |
/// The namespace is not validated against the rules the write paths apply. A
|
|
320 |
+ |
/// push can land a namespace the browser would refuse, including `mnw/*`, and
|
|
321 |
+ |
/// when it does the repository holds it and the index has to say so. Deciding
|
|
322 |
+ |
/// what may be written is the write path's job; this one reports what is there.
|
|
323 |
+ |
#[tracing::instrument(skip_all, name = "git::reindex_notes")]
|
|
324 |
+ |
pub(super) async fn reindex(
|
|
325 |
+ |
State(db_pool): State<PgPool>,
|
|
326 |
+ |
State(config): State<Config>,
|
|
327 |
+ |
headers: HeaderMap,
|
|
328 |
+ |
Json(req): Json<ReindexRequest>,
|
|
329 |
+ |
) -> Result<Json<ReindexResponse>> {
|
|
330 |
+ |
super::notes_inbox::verify_repo_hmac(&config, &headers, &req.repo_owner, &req.repo_name)?;
|
|
331 |
+ |
|
|
332 |
+ |
let namespace = req
|
|
333 |
+ |
.ref_name
|
|
334 |
+ |
.strip_prefix(notes::NOTES_REF_PREFIX)
|
|
335 |
+ |
.filter(|name| !name.is_empty())
|
|
336 |
+ |
.ok_or_else(|| AppError::BadRequest(format!("{} is not a notes ref", req.ref_name)))?
|
|
337 |
+ |
.to_string();
|
|
338 |
+ |
|
|
339 |
+ |
let owner_user = db::users::get_user_by_username(
|
|
340 |
+ |
&db_pool,
|
|
341 |
+ |
&db::Username::from_trusted(req.repo_owner.clone()),
|
|
342 |
+ |
)
|
|
343 |
+ |
.await?
|
|
344 |
+ |
.ok_or(AppError::NotFound)?;
|
|
345 |
+ |
let repo = db::git_repos::get_repo_by_user_and_name(&db_pool, owner_user.id, &req.repo_name)
|
|
346 |
+ |
.await?
|
|
347 |
+ |
.ok_or(AppError::NotFound)?;
|
|
348 |
+ |
|
|
349 |
+ |
reindex_namespace(
|
|
350 |
+ |
&db_pool,
|
|
351 |
+ |
&config,
|
|
352 |
+ |
repo.id,
|
|
353 |
+ |
&req.repo_owner,
|
|
354 |
+ |
&req.repo_name,
|
|
355 |
+ |
&namespace,
|
|
356 |
+ |
)
|
|
357 |
+ |
.await?;
|
|
358 |
+ |
|
|
359 |
+ |
Ok(Json(ReindexResponse { namespace }))
|
|
360 |
+ |
}
|
|
361 |
+ |
|
|
362 |
+ |
/// Reindex after an in-process write, logging rather than failing.
|
|
363 |
+ |
///
|
|
364 |
+ |
/// The note is already in the repository by the time this runs. An index that
|
|
365 |
+ |
/// could not be updated is a stale projection, which the read paths tolerate;
|
|
366 |
+ |
/// turning it into an error the writer sees would be the projection vetoing the
|
|
367 |
+ |
/// thing it is a projection of.
|
|
368 |
+ |
pub(super) async fn reindex_after_write(
|
|
369 |
+ |
db_pool: &PgPool,
|
|
370 |
+ |
config: &Config,
|
|
371 |
+ |
repo_id: GitRepoId,
|
|
372 |
+ |
owner: &str,
|
|
373 |
+ |
repo_name: &str,
|
|
374 |
+ |
namespace: &str,
|
|
375 |
+ |
) {
|
|
376 |
+ |
if let Err(e) = reindex_namespace(db_pool, config, repo_id, owner, repo_name, namespace).await {
|
|
377 |
+ |
tracing::warn!(
|
|
378 |
+ |
owner, repo_name, namespace,
|
|
379 |
+ |
error = %e,
|
|
380 |
+ |
"notes index update failed; the repository is unaffected"
|
|
381 |
+ |
);
|
|
382 |
+ |
}
|
|
383 |
+ |
}
|
|
384 |
+ |
|
|
385 |
+ |
#[cfg(test)]
|
|
386 |
+ |
mod tests {
|
|
387 |
+ |
use super::*;
|
|
388 |
+ |
|
|
389 |
+ |
#[test]
|
|
390 |
+ |
fn a_notes_ref_yields_the_namespace_a_person_says() {
|
|
391 |
+ |
fn namespace(r: &str) -> Option<&str> {
|
|
392 |
+ |
r.strip_prefix(notes::NOTES_REF_PREFIX)
|
|
393 |
+ |
.filter(|n| !n.is_empty())
|
|
394 |
+ |
}
|
|
395 |
+ |
assert_eq!(namespace("refs/notes/commits"), Some("commits"));
|
|
396 |
+ |
assert_eq!(namespace("refs/notes/mnw/builds"), Some("mnw/builds"));
|
|
397 |
+ |
// The inbox is not a namespace, and neither is the bare prefix.
|
|
398 |
+ |
assert_eq!(namespace("refs/mnw/notes-inbox/commits"), None);
|
|
399 |
+ |
assert_eq!(namespace("refs/notes/"), None);
|
|
400 |
+ |
}
|
|
401 |
+ |
}
|