upleb.uk

Public git repos — served from a NIP-34 GRASP relay at git.upleb.uk

summaryrefslogtreecommitdiff
path: root/docs
diff options
context:
space:
mode:
Diffstat (limited to 'docs')
-rw-r--r--docs/explanation/announcements-purgatory-design.md128
-rw-r--r--docs/explanation/announcements-purgatory-implementation.md293
2 files changed, 385 insertions, 36 deletions
diff --git a/docs/explanation/announcements-purgatory-design.md b/docs/explanation/announcements-purgatory-design.md
index 4d0cc6d..a06a8b2 100644
--- a/docs/explanation/announcements-purgatory-design.md
+++ b/docs/explanation/announcements-purgatory-design.md
@@ -2,13 +2,13 @@
2 2
3## Problem Statement 3## Problem Statement
4 4
5**Primary problem:** serving an announcement event and also an empty bare git repos mislead clients into thinking we host content. 5**Primary problem:** Serving announcement events alongside empty bare git repos misleads clients into thinking we host content.
6 6
7When an announcement arrives, we must create the bare repo immediately (so git pushes can succeed). But if no git data ever arrives, we serve an empty repo and its announcement indefinitely. Clients see the announcement, try to clone, and get nothing. This is misleading. 7When an announcement arrives, we must create the bare repo immediately (so git pushes can succeed). But if no git data ever arrives, we serve an empty repo and its announcement indefinitely. Clients see the announcement, try to clone, and get nothing. This is misleading.
8 8
9**Secondary problem:** Sync downloads refs to deleted repos. 9**Secondary problem:** Sync downloads events for repos that may never have content.
10 10
11When a repo expires or is cleaned up, sync may still try to download state event refs to it. We need announcements to remain in a holding state until git data proves the repo has content worth serving. 11Without purgatory, sync would fetch all L2/L3 events (patches, issues, etc.) for announcements that may never receive git data. This wastes bandwidth and creates orphaned events.
12 12
13## Solution Overview 13## Solution Overview
14 14
@@ -57,15 +57,37 @@ This ensures we only serve announcements for repos that actually have content.
57 57
58**Why:** Prevents premature expiry during slow sync operations or multi-step pushes. 58**Why:** Prevents premature expiry during slow sync operations or multi-step pushes.
59 59
60### 5. State Events Consider Purgatory Announcements 60### 5. Authorization Must Check Purgatory Announcements
61 61
62**Decision:** When validating state events, check purgatory announcements for authorization. 62**Decision:** When validating state events or git operations, check purgatory announcements in addition to the database.
63 63
64**Why:** State events may arrive before git data promotes the announcement. They still need authorization from the announcement's maintainer set. 64**Why:** State events and git pushes may arrive before git data promotes the announcement. They still need authorization from the announcement's maintainer set.
65 65
66### 6. We need to request State Events in sysc for announcement in purgatory but not other l2 or l3 events because they will be rejected. 66**Where:** `fetch_repository_data()` and related authorization functions must query both DB and purgatory.
67 67
68### 7. When creating the authorised maintainers for a repositoriy we need to also get relivant announcement events from purgatory as well as db. 68### 6. Sync Only State Events for Purgatory Announcements
69
70**Decision:** Purgatory announcements trigger sync for state events only, not other L2/L3 events (patches, issues, PRs, etc.).
71
72**Why:** Other L2/L3 events would be rejected anyway (no promoted announcement in DB). Syncing them wastes bandwidth and creates work for announcements that may never promote.
73
74**How:** Sync uses a `SyncLevel` concept - `Full` for promoted repos, `StateOnly` for purgatory. On promotion, upgrade to `Full`.
75
76### 7. Soft Expiry Preserves Event Without Bare Repo
77
78**Decision:** When a purgatory announcement expires, delete the bare repo but retain the announcement event for an extended period (e.g., 24h).
79
80**Why:** This handles the case where a state event arrives after initial expiry. Without soft expiry, we'd either:
81- Add to `failed_events` and reject the state event (losing potential revival)
82- Re-fetch the announcement repeatedly (wasting sync bandwidth)
83
84**Behavior during soft expiry:**
85- Bare repo is deleted (saves disk space)
86- Announcement event retained in purgatory with `soft_expired` flag
87- Sync continues requesting state events (same as active purgatory)
88- If state event arrives: recreate bare repo, clear `soft_expired`, extend expiry
89- If announcement republished directly to us: treat as fresh arrival
90- After extended expiry: fully remove from purgatory
69 91
70## Data Structure 92## Data Structure
71 93
@@ -78,13 +100,14 @@ pub struct AnnouncementPurgatoryEntry {
78 pub identifier: String, 100 pub identifier: String,
79 pub owner: PublicKey, 101 pub owner: PublicKey,
80 pub repo_path: PathBuf, 102 pub repo_path: PathBuf,
103 pub relays: HashSet<String>, // For sync registration
81 pub created_at: Instant, 104 pub created_at: Instant,
82 pub expires_at: Instant, 105 pub expires_at: Instant,
106 pub soft_expired: bool, // Bare repo deleted, event retained
83} 107}
84``` 108```
85 109
86**Indexed by `(pubkey, identifier)`** because identifier is not unique across different owners. 110**Indexed by `(pubkey, identifier)`** because identifier is not unique across different owners. Lookups are primarily from nostr events which have pubkey and identifier readily available.
87question: would it be more efficent to index by repo_path? this contains the pubkey and identifier data?
88 111
89## Flows 112## Flows
90 113
@@ -138,27 +161,51 @@ Is there an active announcement?
138 161
139## Edge Cases 162## Edge Cases
140 163
141| Scenario | Behavior | 164| Scenario | Behavior |
142| ------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------- | 165| ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------- |
143| Git data before announcement | Push fails (no repo exists) | 166| Git data before announcement | Push fails (no repo exists) |
144| Announcement expires, no git data | Delete bare repo, discard announcement | 167| Announcement expires, no git data | Delete bare repo, set `soft_expired` flag, retain event for extended period |
145| State expires, announcement in purgatory | Announcement keeps its own expiry | 168| Soft-expired announcement fully expires | Remove from purgatory entirely |
146| Multiple owners, same identifier | Each tracked separately by `(pubkey, identifier)` | 169| State event arrives for soft-expired announcement | Recreate bare repo, clear `soft_expired`, extend expiry |
147| **Newer announcement replaces older (same pubkey)** | Replace purgatory entry, extend expiry, and state event expiry | 170| State expires, announcement in purgatory | Announcement keeps its own expiry |
148| **Newer announcement changes services (unacceptable)** | Clear older announcement from purgatory for that `(pubkey, identifier)`, delete bare repo, remove state event for puragatory if exists | 171| Multiple owners, same identifier | Each tracked separately by `(pubkey, identifier)` |
149| Deletion event for purgatory announcement | Remove from purgatory, delete bare repo | 172| **Newer announcement replaces older (same pubkey)** | Replace purgatory entry, extend expiry, and state event expiry |
173| **Newer announcement changes services (unacceptable)** | Clear older announcement from purgatory, delete bare repo, remove state events from purgatory if exists |
174| Deletion event for purgatory announcement | Remove from purgatory, delete bare repo |
175
176## Purgatory Lifecycle
150 177
151## Purgatory Exit Conditions 178An announcement progresses through purgatory states:
152 179
153An announcement leaves purgatory via: 180```
181 ┌─────────────────────────────────────┐
182 │ │
183 v │
184Announcement ──> ACTIVE ──────────────────────────────────┤
185 arrives (bare repo exists) │
186 │ │
187 ├── Git data ──> PROMOTED (exit) │
188 │ │
189 ├── Deletion ──> REMOVED (exit) │
190 │ │
191 v │
192 SOFT_EXPIRED ──────────────────────────────┘
193 (bare repo deleted, ^
194 event retained) │
195 │ │
196 ├── State event arrives (revival)
197
198 └── Extended expiry ──> REMOVED (exit)
199```
154 200
155| Exit | Trigger | Action | 201| Exit | Trigger | Action |
156| ------------------ | ---------------------------------------------- | ---------------------------------- | 202| -------------- | ---------------------------------------------- | -------------------------------------------- |
157| **Promotion** | Git data arrives | Move to database, serve to clients | 203| **Promotion** | Git data arrives | Move to database, upgrade sync to Full |
158| **Expiry** | Timeout | Delete bare repo, discard | 204| **Soft expiry**| Initial timeout | Delete bare repo, retain event, continue sync|
159| **Deletion** | Kind 5 event | Delete bare repo, discard | 205| **Full expiry**| Extended timeout (soft-expired) | Remove from purgatory entirely |
160| **Replacement** | Newer announcement (same pubkey, identifier) | Replace entry | 206| **Deletion** | Kind 5 event | Delete bare repo, remove from purgatory |
161| **Service change** | Newer announcement no longer lists our service | Discard old entry | 207| **Replacement**| Newer announcement (same pubkey, identifier) | Replace entry |
208| **Service change** | Newer announcement removes our service | Remove from purgatory |
162 209
163## Integration Points 210## Integration Points
164 211
@@ -169,24 +216,33 @@ An announcement leaves purgatory via:
169| `src/nostr/policy/announcement.rs` | Route new announcements to purgatory | 216| `src/nostr/policy/announcement.rs` | Route new announcements to purgatory |
170| `src/git/receive.rs` | Promote on git data arrival | 217| `src/git/receive.rs` | Promote on git data arrival |
171| `src/git/auth.rs` | Extend purgatory expiry when extending state event expiry | 218| `src/git/auth.rs` | Extend purgatory expiry when extending state event expiry |
219| `src/git/authorization.rs` | Check purgatory announcements for maintainer authorization|
172| `src/nostr/policy/state.rs` | Check purgatory for authorization | 220| `src/nostr/policy/state.rs` | Check purgatory for authorization |
221| `src/sync/mod.rs` | Add `SyncLevel` to `RepoSyncNeeds` |
222| `src/sync/filters.rs` | Respect sync level when building filters |
223| `src/sync/self_subscriber.rs` | Register purgatory announcements with `StateOnly` level |
224
225See [announcements-purgatory-implementation.md](./announcements-purgatory-implementation.md) for detailed implementation notes.
173 226
174## Testing 227## Testing
175 228
176- Announcement to purgatory, git data promotes it 229- Announcement to purgatory, git data promotes it
177- Announcement expires without git data (repo deleted) 230- Announcement soft-expires without git data (repo deleted, event retained)
231- State event revives soft-expired announcement (repo recreated)
232- Soft-expired announcement fully expires after extended period
178- State event extends purgatory expiry 233- State event extends purgatory expiry
179- Git auth extends purgatory expiry 234- Git auth extends purgatory expiry
180- Newer announcement replaces older in purgatory 235- Newer announcement replaces older in purgatory
181- Service change clears purgatory entry 236- Service change clears purgatory entry
182- `(pubkey, identifier)` indexing with multiple owners 237- `(pubkey, identifier)` indexing with multiple owners
238- Sync requests only state events for purgatory announcements
239- Sync upgrades to full on promotion
183 240
184## Risks 241## Risks
185 242
186| Risk | Mitigation | 243| Risk | Mitigation |
187| ------------------------------------ | ------------------------------------ | 244| ------------------------------------ | ------------------------------------------------------- |
188| Disk exhaustion from purgatory repos | Short expiry, monitor purgatory size | 245| Disk exhaustion from purgatory repos | Short expiry, soft expiry deletes repo early |
189| Race between promotion and expiry | Atomic operations | 246| Race between promotion and expiry | Atomic operations |
190| Sync re-fetching expired events | Track expired event IDs | 247| Sync re-fetching expired events | Soft expiry retains event; no need for `failed_events` |
191 248| Filter explosion from many purgatory | Existing consolidation handles this (threshold at 70) |
192question: do expired annoucements go on failed_events list? what if a new state event comes in for it? surely then we want it again? but if not we dont want to keep donwloading it and havea a repo made for it. Should we have a longer period were we keep the event just in case, but delete the bare repo and only remake it when the state event arrives?
diff --git a/docs/explanation/announcements-purgatory-implementation.md b/docs/explanation/announcements-purgatory-implementation.md
new file mode 100644
index 0000000..d5b8698
--- /dev/null
+++ b/docs/explanation/announcements-purgatory-implementation.md
@@ -0,0 +1,293 @@
1# Announcements Purgatory Implementation Details
2
3This document provides detailed implementation notes for the [Announcements Purgatory Design](./announcements-purgatory-design.md).
4
5## Sync Integration
6
7### Current Sync Architecture
8
9The sync system uses a two-index approach:
10
11```rust
12// What we WANT to sync - source of truth from self-subscription
13// Key: repo addressable ref (30617:pubkey:identifier)
14pub type RepoSyncIndex = Arc<RwLock<HashMap<String, RepoSyncNeeds>>>;
15
16pub struct RepoSyncNeeds {
17 pub relays: HashSet<String>, // Relay URLs from announcement
18 pub root_events: HashSet<EventId>, // 1617/1618/1621 event IDs
19}
20
21// What we have CONFIRMED syncing + connection state
22// Key: relay URL
23pub type RelaySyncIndex = Arc<RwLock<HashMap<String, RelayState>>>;
24```
25
26**Three-Layer Sync Strategy:**
271. **Layer 1:** Announcements (kinds 30617, 10317)
282. **Layer 2:** Repo-tagging events (events with `a`/`A`/`q` tags + kind 30618 by identifier)
293. **Layer 3:** Root-event-tagging events (events with `e`/`E`/`q` tags)
30
31### Adding SyncLevel
32
33Add a `sync_level` field to distinguish purgatory from promoted repos:
34
35```rust
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
37pub enum SyncLevel {
38 #[default]
39 Full, // L2 + L3 (promoted repos)
40 StateOnly, // Only state events (purgatory announcements)
41}
42
43pub struct RepoSyncNeeds {
44 pub relays: HashSet<String>,
45 pub root_events: HashSet<EventId>,
46 pub sync_level: SyncLevel, // NEW
47}
48```
49
50### Filter Building Changes
51
52In `src/sync/filters.rs`, modify filter building to respect sync level:
53
54```rust
55// For StateOnly repos, only build state event filters
56pub fn build_layer2_and_layer3_filters(
57 repos: &HashMap<String, RepoSyncNeeds>,
58 // ...
59) -> Vec<Filter> {
60 let (full_repos, state_only_repos): (Vec<_>, Vec<_>) = repos
61 .iter()
62 .partition(|(_, needs)| needs.sync_level == SyncLevel::Full);
63
64 let mut filters = Vec::new();
65
66 // Full repos get all L2/L3 filters
67 if !full_repos.is_empty() {
68 filters.extend(tagged_one_of_our_repo_event_filters(&full_repos));
69 filters.extend(state_event_filters_for_our_repos(&full_repos));
70 filters.extend(tagged_one_of_our_root_event_filters(&full_repos));
71 }
72
73 // StateOnly repos get only state event filters
74 if !state_only_repos.is_empty() {
75 filters.extend(state_event_filters_for_our_repos(&state_only_repos));
76 }
77
78 filters
79}
80```
81
82The existing `state_event_filters_for_our_repos()` function already builds kind 30618 filters with `#d` tags, which is exactly what we need.
83
84### Self-Subscriber Changes
85
86In `src/sync/self_subscriber.rs`, add purgatory announcements to the sync index:
87
88```rust
89// When announcement enters purgatory
90fn on_announcement_to_purgatory(
91 &self,
92 event: &Event,
93 identifier: &str,
94 relays: HashSet<String>,
95) {
96 let key = format!("30617:{}:{}", event.pubkey, identifier);
97 let mut index = self.repo_sync_index.write().unwrap();
98 index.insert(key, RepoSyncNeeds {
99 relays,
100 root_events: HashSet::new(),
101 sync_level: SyncLevel::StateOnly,
102 });
103}
104
105// When announcement promotes to database
106fn on_announcement_promoted(
107 &self,
108 event: &Event,
109 identifier: &str,
110) {
111 let key = format!("30617:{}:{}", event.pubkey, identifier);
112 let mut index = self.repo_sync_index.write().unwrap();
113 if let Some(needs) = index.get_mut(&key) {
114 needs.sync_level = SyncLevel::Full;
115 }
116}
117```
118
119### Algorithm Changes
120
121In `src/sync/algorithms.rs`, preserve sync level when inverting repo->relay:
122
123```rust
124pub fn derive_relay_targets(
125 repo_index: &RepoSyncIndex,
126) -> HashMap<String, RelaySyncNeeds> {
127 // ... existing inversion logic ...
128 // Ensure sync_level is preserved/aggregated per relay
129 // A relay gets Full if ANY of its repos are Full
130}
131```
132
133## Authorization Integration
134
135### Current Authorization Flow
136
137Authorization lookups happen in `src/git/authorization.rs`:
138
139| Function | Purpose | Currently Queries |
140|----------|---------|-------------------|
141| `fetch_repository_data()` | Get announcements + states by identifier | DB only |
142| `collect_authorized_maintainers()` | Build maintainer set from announcements | DB only |
143| `pubkey_authorised_for_repo_owners()` | Check if pubkey authorized | DB only |
144
145### Required Changes
146
147Modify `fetch_repository_data()` to also query purgatory:
148
149```rust
150pub async fn fetch_repository_data(
151 db: &Database,
152 purgatory: &Purgatory, // NEW parameter
153 identifier: &str,
154) -> Result<RepositoryData> {
155 // Existing DB query
156 let db_events = db.query(/* kind 30617, 30618 by identifier */).await?;
157
158 // NEW: Also check purgatory for announcements
159 let purgatory_announcements = purgatory
160 .get_announcements_by_identifier(identifier);
161
162 // Merge results
163 let mut announcements = parse_announcements(db_events);
164 announcements.extend(purgatory_announcements);
165
166 // ... rest of function
167}
168```
169
170This affects:
171- `StatePolicy::process_state_event()` - state event validation
172- `get_state_authorization_for_specific_owner_repo()` - git push authorization
173- `AnnouncementPolicy::is_maintainer_in_any_announcement()` - maintainer exception
174
175## Purgatory Store Changes
176
177### New Fields
178
179```rust
180pub struct AnnouncementPurgatoryEntry {
181 pub event: Event,
182 pub identifier: String,
183 pub owner: PublicKey,
184 pub repo_path: PathBuf,
185 pub relays: HashSet<String>, // For sync registration
186 pub created_at: Instant,
187 pub expires_at: Instant,
188 pub soft_expired: bool, // Bare repo deleted, event retained
189}
190```
191
192### New Methods
193
194```rust
195impl Purgatory {
196 /// Get announcements by identifier (for authorization)
197 pub fn get_announcements_by_identifier(
198 &self,
199 identifier: &str,
200 ) -> Vec<&AnnouncementPurgatoryEntry> {
201 self.announcement_purgatory
202 .iter()
203 .filter(|entry| entry.identifier == identifier)
204 .collect()
205 }
206
207 /// Transition to soft-expired state
208 pub fn soft_expire_announcement(
209 &self,
210 key: &(PublicKey, String),
211 ) -> Option<PathBuf> {
212 if let Some(mut entry) = self.announcement_purgatory.get_mut(key) {
213 entry.soft_expired = true;
214 entry.expires_at = Instant::now() + SOFT_EXPIRY_DURATION; // e.g., 24h
215 Some(entry.repo_path.clone()) // Return path for bare repo deletion
216 } else {
217 None
218 }
219 }
220
221 /// Revive soft-expired announcement (caller must recreate bare repo)
222 pub fn revive_announcement(
223 &self,
224 key: &(PublicKey, String),
225 ) -> Option<PathBuf> {
226 if let Some(mut entry) = self.announcement_purgatory.get_mut(key) {
227 if entry.soft_expired {
228 entry.soft_expired = false;
229 entry.expires_at = Instant::now() + ACTIVE_EXPIRY_DURATION;
230 return Some(entry.repo_path.clone()); // Caller recreates bare repo
231 }
232 }
233 None
234 }
235}
236```
237
238## Expiry Cleanup Task
239
240The existing cleanup task needs to handle the two-phase expiry:
241
242```rust
243async fn cleanup_expired_announcements(&self) {
244 let now = Instant::now();
245
246 for entry in self.announcement_purgatory.iter() {
247 if entry.expires_at <= now {
248 let key = (entry.owner.clone(), entry.identifier.clone());
249
250 if entry.soft_expired {
251 // Fully expired - remove entirely
252 self.announcement_purgatory.remove(&key);
253 self.unregister_from_sync(&key);
254 } else {
255 // First expiry - transition to soft-expired
256 if let Some(repo_path) = self.soft_expire_announcement(&key) {
257 delete_bare_repo(&repo_path).await;
258 }
259 // Note: stays in sync index with StateOnly level
260 }
261 }
262 }
263}
264```
265
266## State Event Revival Flow
267
268When a state event arrives for a soft-expired announcement, the state policy must:
269
2701. Check purgatory for a matching announcement (in addition to DB)
2712. Validate authorization against the purgatory announcement
2723. If soft-expired, call `revive_announcement()` and recreate the bare repo
2734. Extend the announcement's expiry
2745. Route the state event to state purgatory
275
276The exact integration will depend on the current structure of `StatePolicy::process_state_event()` - see implementation phase for details.
277
278## File Change Summary
279
280| File | Estimated Lines | Changes |
281|------|-----------------|---------|
282| `src/sync/mod.rs` | ~10 | Add `SyncLevel` enum, field to `RepoSyncNeeds` |
283| `src/sync/filters.rs` | ~20 | Partition repos by sync level, build appropriate filters |
284| `src/sync/algorithms.rs` | ~15 | Preserve sync level in relay target derivation |
285| `src/sync/self_subscriber.rs` | ~40 | Register purgatory announcements, handle promotion |
286| `src/purgatory/mod.rs` | ~80 | Add announcement store, soft expiry methods |
287| `src/purgatory/types.rs` | ~20 | Add `AnnouncementPurgatoryEntry` |
288| `src/git/authorization.rs` | ~30 | Query purgatory in `fetch_repository_data()` |
289| `src/nostr/policy/state.rs` | ~40 | Handle soft-expired revival |
290| `src/nostr/policy/announcement.rs` | ~30 | Route to purgatory, check for replacements |
291| `src/git/receive.rs` | ~20 | Trigger promotion on git data |
292
293**Total: ~305 lines of changes**