Planlagt publisering (oppgave 14.12): periodisk scheduler i maskinrommet

Legger til en bakgrunns-scheduler som hvert 60. sekund sjekker for
belongs_to-edges med publish_at i fortiden der artikkelen ikke er rendret.
Ved treff: oppretter render_article-jobb (og render_index for berørte
samlinger) i jobbkøen. RSS-feeden oppdateres automatisk siden den
genereres dynamisk fra DB.

Detaljer:
- find_due_articles(): SQL-spørring med deduplisering mot eksisterende jobber
- run_publish_scheduler(): orkestrerer én runde med publisering
- start_publish_scheduler(): tokio::spawn med 60s poll-intervall
- Hooks inn i main.rs ved oppstart, parallelt med jobbkø og pruning

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
vegard 2026-03-18 02:14:04 +00:00
parent 1d2741a18a
commit eea66744d8
3 changed files with 145 additions and 2 deletions

View file

@ -140,6 +140,9 @@ async fn main() {
// Start periodisk CAS-pruning i bakgrunnen // Start periodisk CAS-pruning i bakgrunnen
pruning::start_pruning_loop(db.clone(), cas.clone()); pruning::start_pruning_loop(db.clone(), cas.clone());
// Start planlagt publisering-scheduler i bakgrunnen
publishing::start_publish_scheduler(db.clone());
let index_cache = publishing::new_index_cache(); let index_cache = publishing::new_index_cache();
let state = AppState { db, jwks, stdb, cas, index_cache }; let state = AppState { db, jwks, stdb, cas, index_cache };

View file

@ -26,6 +26,7 @@ use tokio::sync::RwLock;
use uuid::Uuid; use uuid::Uuid;
use crate::cas::CasStore; use crate::cas::CasStore;
use crate::jobs;
use crate::tiptap; use crate::tiptap;
use crate::AppState; use crate::AppState;
@ -1270,6 +1271,146 @@ pub async fn preview_theme(
.unwrap()) .unwrap())
} }
// =============================================================================
// Planlagt publisering — periodisk scheduler
// =============================================================================
/// Rad fra spørring for planlagte artikler som er klare for publisering.
#[derive(sqlx::FromRow, Debug)]
struct ScheduledArticle {
node_id: Uuid,
collection_id: Uuid,
}
/// Finn belongs_to-edges med publish_at i fortiden der artikkelen
/// ikke er rendret ennå. Returnerer (node_id, collection_id)-par.
async fn find_due_articles(db: &PgPool) -> Result<Vec<ScheduledArticle>, sqlx::Error> {
// En artikkel er "due" når:
// 1. belongs_to-edge har publish_at <= now()
// 2. Noden mangler metadata.rendered.html_hash (ikke rendret)
//
// Vi sjekker også at det ikke allerede finnes en pending/running
// render_article-jobb for denne noden, for å unngå duplikater.
sqlx::query_as::<_, ScheduledArticle>(
r#"
SELECT
e.source_id AS node_id,
e.target_id AS collection_id
FROM edges e
JOIN nodes n ON n.id = e.source_id
WHERE e.edge_type = 'belongs_to'
AND (e.metadata->>'publish_at')::timestamptz <= now()
AND (
n.metadata->'rendered'->>'html_hash' IS NULL
)
AND NOT EXISTS (
SELECT 1 FROM job_queue jq
WHERE jq.job_type = 'render_article'
AND jq.status IN ('pending', 'running', 'retry')
AND jq.payload->>'node_id' = n.id::text
)
"#,
)
.fetch_all(db)
.await
}
/// Kjør én runde med planlagt publisering.
/// Returnerer antall artikler som ble lagt i render-kø.
async fn run_publish_scheduler(db: &PgPool) -> Result<usize, String> {
let due = find_due_articles(db)
.await
.map_err(|e| format!("Spørring for planlagte artikler feilet: {e}"))?;
if due.is_empty() {
return Ok(0);
}
tracing::info!(count = due.len(), "Fant planlagte artikler klare for publisering");
// Samle unike collection_ids for index-oppdatering etterpå
let mut collections_to_reindex: std::collections::HashSet<Uuid> = std::collections::HashSet::new();
for article in &due {
let payload = serde_json::json!({
"node_id": article.node_id.to_string(),
"collection_id": article.collection_id.to_string(),
});
match jobs::enqueue(db, "render_article", payload, Some(article.collection_id), 5).await {
Ok(job_id) => {
tracing::info!(
job_id = %job_id,
node_id = %article.node_id,
collection_id = %article.collection_id,
"Render-jobb opprettet for planlagt artikkel"
);
collections_to_reindex.insert(article.collection_id);
}
Err(e) => {
tracing::error!(
node_id = %article.node_id,
error = %e,
"Kunne ikke opprette render-jobb for planlagt artikkel"
);
}
}
}
// Legg inn render_index-jobb for hver berørt samling (lavere prioritet)
for collection_id in &collections_to_reindex {
let payload = serde_json::json!({
"collection_id": collection_id.to_string(),
});
if let Err(e) = jobs::enqueue(db, "render_index", payload, Some(*collection_id), 3).await {
tracing::error!(
collection_id = %collection_id,
error = %e,
"Kunne ikke opprette render_index-jobb"
);
}
}
let count = due.len();
tracing::info!(
articles = count,
collections = collections_to_reindex.len(),
"Planlagt publisering: jobber lagt i kø"
);
Ok(count)
}
/// Start periodisk scheduler for planlagt publisering.
/// Sjekker hvert 60. sekund for artikler med publish_at i fortiden.
pub fn start_publish_scheduler(db: PgPool) {
tokio::spawn(async move {
// Vent 30 sekunder etter oppstart før første sjekk
tokio::time::sleep(std::time::Duration::from_secs(30)).await;
tracing::info!("Planlagt publisering-scheduler startet (intervall: 60s)");
loop {
match run_publish_scheduler(&db).await {
Ok(count) => {
if count > 0 {
tracing::info!(
articles = count,
"Planlagt publisering: {} artikler lagt i render-kø",
count,
);
}
}
Err(e) => {
tracing::error!(error = %e, "Planlagt publisering-scheduler feilet");
}
}
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
}
});
}
// ============================================================================= // =============================================================================
// Tester // Tester
// ============================================================================= // =============================================================================

View file

@ -154,8 +154,7 @@ Uavhengige faser kan fortsatt plukkes.
- [x] 14.9 Custom domains: bruker registrerer domene i `publishing`-trait. Maskinrommet validerer DNS, Caddy on-demand TLS med validerings-callback. Re-rendring med riktig canonical URL. - [x] 14.9 Custom domains: bruker registrerer domene i `publishing`-trait. Maskinrommet validerer DNS, Caddy on-demand TLS med validerings-callback. Re-rendring med riktig canonical URL.
- [x] 14.10 Redaksjonell innsending: `submitted_to`-edge med status-metadata (`pending`, `in_review`, `revision_requested`, `rejected`, `approved`). Maskinrommet validerer at kun roller i `submission_roles` kan opprette `submitted_to`, og kun owner/admin kan endre status eller opprette `belongs_to`. Ref: `docs/concepts/publisering.md` § "Innsending". - [x] 14.10 Redaksjonell innsending: `submitted_to`-edge med status-metadata (`pending`, `in_review`, `revision_requested`, `rejected`, `approved`). Maskinrommet validerer at kun roller i `submission_roles` kan opprette `submitted_to`, og kun owner/admin kan endre status eller opprette `belongs_to`. Ref: `docs/concepts/publisering.md` § "Innsending".
- [x] 14.11 Redaktørens arbeidsflate: frontend-visning av noder med `submitted_to`-edge til samling, gruppert på status. Kanban-stil drag-and-drop for statusendring. Siste kolonne ("Planlagt") setter `publish_at` i edge-metadata. - [x] 14.11 Redaktørens arbeidsflate: frontend-visning av noder med `submitted_to`-edge til samling, gruppert på status. Kanban-stil drag-and-drop for statusendring. Siste kolonne ("Planlagt") setter `publish_at` i edge-metadata.
- [~] 14.12 Planlagt publisering: maskinrommet sjekker periodisk (cron/intervall) for `belongs_to`-edges med `publish_at` i fortiden som ikke er rendret. Ved treff: render HTML → CAS → oppdater RSS. - [x] 14.12 Planlagt publisering: maskinrommet sjekker periodisk (cron/intervall) for `belongs_to`-edges med `publish_at` i fortiden som ikke er rendret. Ved treff: render HTML → CAS → oppdater RSS.
> Påbegynt: 2026-03-18T02:10
- [ ] 14.13 Redaksjonell samtale: ved innsending kan redaktør opprette kommunikasjonsnode knyttet til artikkel + forfatter for diskusjon/feedback utover kort notat i edge-metadata. - [ ] 14.13 Redaksjonell samtale: ved innsending kan redaktør opprette kommunikasjonsnode knyttet til artikkel + forfatter for diskusjon/feedback utover kort notat i edge-metadata.
- [ ] 14.14 Bulk re-rendering: batch-jobb via jobbkø ved temaendring. Paginert (100 artikler om gangen), oppdaterer `renderer_version`. Artikler serveres med gammelt tema til re-rendret. - [ ] 14.14 Bulk re-rendering: batch-jobb via jobbkø ved temaendring. Paginert (100 artikler om gangen), oppdaterer `renderer_version`. Artikler serveres med gammelt tema til re-rendret.
- [ ] 14.15 Dynamiske sider: kategori-sider (filtrert på tag-edges), arkiv (kronologisk med månedsgruppering), søk (PG fulltekst). Alle paginerte, cachet i maskinrommet. Om-side som statisk CAS-node. - [ ] 14.15 Dynamiske sider: kategori-sider (filtrert på tag-edges), arkiv (kronologisk med månedsgruppering), søk (PG fulltekst). Alle paginerte, cachet i maskinrommet. Om-side som statisk CAS-node.