OXYGENHUB

Several web processes can share a data directory (closes #274) ec84907043c2

The transparency log took appends from one process only. Each append now
holds append.lock in the log's directory and starts from the log as the
files hold it, finishing what a crashed append left; readers catch up
with every writer's entries, and every process serves the newest
checkpoint, whichever signed it. docs/roles.md drops the one-web-process
rule.
Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 2f8e2b706da6
When
· 2026-10-21 00:57 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
ec84907043c2ad9321efa120752a37be37c17620bacb7b4119ba57ed0644d524
Depends on
33d98147511b 4f40eed0588f 5588bb3631d2 6b195ed3b637 965b950f9b96 a7e6e8e6094c b5668108487f e57765dee61a e5b97ac399e7
Depended on by
356183b71fab
In bookmarks
api-unknown-fields diffusion-pin-bookmark draft-ready main proposal-list-fast
In tags
v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
Edits
23 atoms, 0 tree operations, 3 files

Browse the tree at this change

modified CHANGELOG.md +1 −0
…
3 unchanged lines
4 4
5 5
## Unreleased
6 6
7 +
- **Several web processes can share a data directory (#274):** the transparency log took appends from one process only, so only one process per data directory could have the web role. Each append now holds a lock in the log's directory and starts from the log as the files hold it, readers see every process's entries, and every process serves the newest checkpoint, whichever signed it.
7 8
- **A push reuses a job's pass on the same state (#265):** when main reaches a state a proposal's run already tested (landing it), a job that passed there with the same spec and sandbox is not run again: it shows passed, reused from that run, with its receipt, and its check says so. Jobs with an approval gate, an environment, secrets or identity tokens, or whose artifacts another job takes, always run. Maintainers can turn reuse off under Settings › Runs.
8 9
- **Drops are backed up (#280):** an owner's backups now include its drops, each copied like a repository and restored as a drop (secret or public, with its title and description); Admin › Backups counts them with the repositories they cover.
9 10
- **A skip marker quoted in code does not skip:** a change message that mentions `[skip ci]` between backticks or in a fenced block (documenting the marker, say) now starts its run as usual; only a marker in the text itself skips it.
…
274 unchanged lines
modified crates/hub/src/tlog.rs +122 −41
…
64 unchanged lines
65 65
    /// The log's name in its checkpoints: the hub's host and `/log`.
66 66
    pub origin: String,
67 67
    files: Mutex<Files>,
68 +
    /// `append.lock`: held, exclusively, around each append, so several
69 +
    /// processes sharing the data directory append one at a time (#274).
70 +
    lock: File,
68 71
    /// The latest signed checkpoint: its size and its note.
69 72
    checkpoint: Mutex<Option<(u64, String)>>,
70 73
    /// Witnesses this log asks to cosign its checkpoints.
…
1 unchanged lines
72 75
    /// Logs this hub witnesses: their origins and keys.
73 76
    witness_for: std::sync::RwLock<Vec<(String, Identity)>>,
74 77
    /// Whether this process writes the log: the web role's (#272). A
75 -
    /// process without it only reads what the log held when it opened.
78 +
    /// process without it only reads, catching up with the writers.
76 79
    writer: bool,
77 80
}
78 81
…
27 unchanged lines
106 109
107 110
    /// Open the log to read only, as a process without the web role does
108 111
    /// (#272): it neither appends nor signs, and leaves the files as they
109 -
    /// are, so it never cuts short an append the writer is making. Until
110 -
    /// the log has a cluster-wide writer (#274), one process per data
111 -
    /// directory has the web role.
112 +
    /// are, so it never cuts short an append a writer is making. Several
113 +
    /// web processes may share the data directory and write (#274).
112 114
    pub fn open_reader(dir: &Path, origin: &str) -> anyhow::Result<Log> {
113 115
        Self::open_as(dir, origin, false)
114 116
    }
…
2 unchanged lines
117 119
        std::fs::create_dir_all(dir)?;
118 120
        let open = |name: &str| OpenOptions::new().read(true).append(true).create(true).open(dir.join(name));
119 121
        let (entries, offsets, hashes) = (open("entries")?, open("offsets")?, open("hashes")?);
120 -
        let entries_on_disk = entries.metadata()?.len();
121 -
        // The whole entries: those whose offset was written and whose
122 -
        // bytes all were.
123 -
        let mut size = offsets.metadata()?.len() / 8;
122 +
        let lock = OpenOptions::new().read(true).write(true).create(true).truncate(false).open(dir.join("append.lock"))?;
123 +
        let mut files = Files { entries, offsets, hashes, size: 0, entries_len: 0 };
124 +
        if writer {
125 +
            lock.lock()?;
126 +
            let done = Self::recover(&mut files, true);
127 +
            lock.unlock()?;
128 +
            done?;
129 +
        } else {
130 +
            Self::recover(&mut files, false)?;
131 +
        }
132 +
        let size = files.size;
133 +
        Ok(Log {
134 +
            dir: dir.to_path_buf(),
135 +
            origin: origin.to_string(),
136 +
            files: Mutex::new(files),
137 +
            lock,
138 +
            checkpoint: Mutex::new(Self::read_checkpoint(dir, size)),
139 +
            witnesses: Default::default(),
140 +
            witness_for: Default::default(),
141 +
            writer,
142 +
        })
143 +
    }
144 +
145 +
    /// Read the log's extent from its files, as another process may have
146 +
    /// appended since (#274). The whole entries count: those whose offset
147 +
    /// and bytes were written, and whose stored hashes all were. A writer,
148 +
    /// holding the append lock, also finishes what an append cut short left
149 +
    /// (it truncates partial bytes and computes missing hashes again); a
150 +
    /// reader changes nothing and takes the whole entries only.
151 +
    fn recover(files: &mut Files, writer: bool) -> anyhow::Result<()> {
152 +
        let entries_on_disk = files.entries.metadata()?.len();
153 +
        let mut size = files.offsets.metadata()?.len() / 8;
124 154
        let mut entries_len = 0;
125 155
        while size > 0 {
126 -
            let off = read_u64_at(&offsets, (size - 1) * 8)?;
156 +
            let off = read_u64_at(&files.offsets, (size - 1) * 8)?;
127 157
            let mut len = [0u8; 2];
128 -
            if off + 2 <= entries_on_disk && entries.read_exact_at(&mut len, off).is_ok() {
158 +
            if off + 2 <= entries_on_disk && files.entries.read_exact_at(&mut len, off).is_ok() {
129 159
                let end = off + 2 + u16::from_be_bytes(len) as u64;
130 160
                if end <= entries_on_disk {
131 161
                    entries_len = end;
…
4 unchanged lines
136 166
        }
137 167
        // The hashes of every leaf whose hashes were all written; the rest
138 168
        // are computed again from the entries.
139 -
        let have = hashes.metadata()?.len() / 32;
169 +
        let have = files.hashes.metadata()?.len() / 32;
140 170
        let mut whole = size;
141 171
        while tlog::stored_hash_count(whole) > have {
142 172
            whole -= 1;
143 173
        }
144 174
        if !writer {
145 -
            // A reader takes the leaves whose hashes are all there and
146 -
            // changes nothing.
147 -
            let files = Files { entries, offsets, hashes, size: whole, entries_len };
148 -
            return Ok(Log {
149 -
                dir: dir.to_path_buf(),
150 -
                origin: origin.to_string(),
151 -
                files: Mutex::new(files),
152 -
                checkpoint: Mutex::new(Self::read_checkpoint(dir, whole)),
153 -
                witnesses: Default::default(),
154 -
                witness_for: Default::default(),
155 -
                writer,
156 -
            });
175 +
            files.size = whole;
176 +
            files.entries_len = entries_len;
177 +
            return Ok(());
157 178
        }
158 -
        offsets.set_len(size * 8)?;
159 -
        entries.set_len(entries_len)?;
160 -
        hashes.set_len(tlog::stored_hash_count(whole) * 32)?;
161 -
        let mut files = Files { entries, offsets, hashes, size: whole, entries_len };
179 +
        files.offsets.set_len(size * 8)?;
180 +
        files.entries.set_len(entries_len)?;
181 +
        files.hashes.set_len(tlog::stored_hash_count(whole) * 32)?;
182 +
        files.entries_len = entries_len;
162 183
        for n in whole..size {
163 -
            let bytes = Self::entry_bytes(&files, n)?;
184 +
            let bytes = Self::entry_bytes(files, n)?;
164 185
            let add = tlog::stored_hashes_for_leaf(n, tlog::leaf_hash(&bytes), &FileHashes(&files.hashes))?;
165 186
            for h in add {
166 187
                files.hashes.write_all(&h)?;
…
2 unchanged lines
169 190
        }
170 191
        files.size = size;
171 192
        files.hashes.sync_data()?;
172 -
        Ok(Log {
173 -
            dir: dir.to_path_buf(),
174 -
            origin: origin.to_string(),
175 -
            files: Mutex::new(files),
176 -
            checkpoint: Mutex::new(Self::read_checkpoint(dir, size)),
177 -
            witnesses: Default::default(),
178 -
            witness_for: Default::default(),
179 -
            writer,
180 -
        })
193 +
        Ok(())
194 +
    }
195 +
196 +
    /// Catch up with what other processes appended (#274).
197 +
    fn catch_up(&self, f: &mut Files) {
198 +
        if let Err(e) = Self::recover(f, false) {
199 +
            tracing::warn!("tlog: reading the log's extent: {e:#}");
200 +
        }
181 201
    }
182 202
183 203
    /// The signed checkpoint kept in `dir`, if it is of at most `size` entries.
…
16 unchanged lines
200 220
201 221
    /// Entry `n`'s bytes.
202 222
    pub fn entry(&self, n: u64) -> anyhow::Result<Vec<u8>> {
203 -
        let f = self.files.lock().unwrap();
223 +
        let mut f = self.files.lock().unwrap();
224 +
        if n >= f.size {
225 +
            self.catch_up(&mut f);
226 +
        }
204 227
        anyhow::ensure!(n < f.size, "entry {n} is not in the log");
205 228
        Self::entry_bytes(&f, n)
206 229
    }
207 230
208 231
    /// Entries in the log.
209 232
    pub fn size(&self) -> u64 {
210 -
        self.files.lock().unwrap().size
233 +
        let mut f = self.files.lock().unwrap();
234 +
        self.catch_up(&mut f);
235 +
        f.size
211 236
    }
212 237
213 238
    /// Append entries, durably; their numbers.
…
2 unchanged lines
216 241
            anyhow::bail!("this process does not write the log: it has no web role");
217 242
        }
218 243
        let mut f = self.files.lock().unwrap();
244 +
        // One append at a time across the processes sharing the directory
245 +
        // (#274), each from the log as the last left it.
246 +
        self.lock.lock()?;
247 +
        let appended = Self::recover(&mut f, true).and_then(|()| Self::append_locked(&mut f, entries));
248 +
        self.lock.unlock()?;
249 +
        appended
250 +
    }
251 +
252 +
    fn append_locked(f: &mut Files, entries: &[Entry]) -> anyhow::Result<Vec<u64>> {
219 253
        let mut out = Vec::with_capacity(entries.len());
220 254
        for e in entries {
221 255
            let bytes = e.encode();
…
118 unchanged lines
340 374
        Ok(())
341 375
    }
342 376
343 -
    /// The latest signed checkpoint note.
377 +
    /// The latest signed checkpoint note: the newer of this process's and
378 +
    /// the one kept in the directory, which the process leading the
379 +
    /// checkpointer signs (#274).
344 380
    pub fn checkpoint(&self) -> Option<String> {
345 -
        self.checkpoint.lock().unwrap().as_ref().map(|(_, n)| n.clone())
381 +
        let mut held = self.checkpoint.lock().unwrap();
382 +
        if let Some(kept) = Self::read_checkpoint(&self.dir, u64::MAX) {
383 +
            if held.as_ref().is_none_or(|(s, _)| kept.0 > *s) {
384 +
                *held = Some(kept);
385 +
            }
386 +
        }
387 +
        held.as_ref().map(|(_, n)| n.clone())
346 388
    }
347 389
348 390
    /// Sign a checkpoint of the log as it is, unless the last one already
…
3 unchanged lines
352 394
            return Ok(false);
353 395
        }
354 396
        let size = self.size();
397 +
        // From the newest kept, whichever process signed it.
398 +
        let _ = self.checkpoint();
355 399
        if self.checkpoint.lock().unwrap().as_ref().is_some_and(|(s, _)| *s >= size) {
356 400
            return Ok(false);
357 401
        }
…
453 unchanged lines
811 855
        assert!(log.tile(&Tile { level: 0, n: 1, width: 46 }).unwrap().is_none(), "not yet");
812 856
        let bundle = log.bundle(&Tile { level: 0, n: 1, width: 45 }).unwrap().unwrap();
813 857
        assert_eq!(tlog::decode_bundle(&bundle).unwrap()[0], first[256].encode());
858 +
    }
859 +
860 +
    /// oxygenhub#274: writers sharing the directory (each its own handles,
861 +
    /// as separate processes have) append one at a time, each from the log
862 +
    /// as the others left it; readers and checkpoints follow.
863 +
    #[test]
864 +
    fn writers_sharing_the_directory_append_one_at_a_time() {
865 +
        let dir = tempfile::tempdir().unwrap();
866 +
        let a = std::sync::Arc::new(Log::open(dir.path(), "hub.example/log").unwrap());
867 +
        let b = std::sync::Arc::new(Log::open(dir.path(), "hub.example/log").unwrap());
868 +
        let reader = Log::open_reader(dir.path(), "hub.example/log").unwrap();
869 +
        let spawn = |log: std::sync::Arc<Log>, from: u64| {
870 +
            std::thread::spawn(move || {
871 +
                let mut got = Vec::new();
872 +
                for i in 0..150 {
873 +
                    got.extend(log.append(&[entry(from + i)]).unwrap());
874 +
                }
875 +
                got
876 +
            })
877 +
        };
878 +
        let (ta, tb) = (spawn(a.clone(), 0), spawn(b.clone(), 1000));
879 +
        let mut numbers: Vec<u64> = ta.join().unwrap().into_iter().chain(tb.join().unwrap()).collect();
880 +
        numbers.sort();
881 +
        assert_eq!(numbers, (0..300).collect::<Vec<_>>(), "every entry its own place");
882 +
        assert_eq!((a.size(), b.size(), reader.size()), (300, 300, 300));
883 +
        let root = a.tree_hash(300).unwrap();
884 +
        assert_eq!(b.tree_hash(300).unwrap(), root);
885 +
        let fresh = Log::open(dir.path(), "hub.example/log").unwrap();
886 +
        assert_eq!(fresh.tree_hash(300).unwrap(), root, "the files are one log");
887 +
        assert_eq!(b.entry(299).unwrap(), fresh.entry(299).unwrap());
888 +
        assert_eq!(reader.entry(299).unwrap(), fresh.entry(299).unwrap());
889 +
        // A checkpoint one signs, the other serves.
890 +
        let sk = oxy_core::identity::SecretKey::generate();
891 +
        let hub = sk.identity("hub", "[email protected]", 1);
892 +
        assert!(a.refresh_checkpoint(&hub, &sk).unwrap());
893 +
        assert_eq!(b.checkpoint(), a.checkpoint());
894 +
        assert!(!b.refresh_checkpoint(&hub, &sk).unwrap(), "already covered by the other's");
814 895
    }
815 896
816 897
    #[test]
…
19 unchanged lines
modified docs/roles.md +10 −6
…
1 unchanged lines
2 2
3 3
Status: design, 2026-10-19 (overnight loop 17). H2 (`--role` and the
4 4
leader lock, #272) and H3 (the event bus, #273) are built; the
5 -
transparency log's cluster-wide writer (#274) is next.
5 +
transparency log takes appends from every web process sharing its directory (#274).
6 6
7 7
OxygenHub is one process today: pages, API, the `oxy` wire protocol, Flow's
8 8
scheduler and every background task share one runtime and one data
…
122 unchanged lines
131 131
  be watched; with no bind address it serves nothing. It opens the
132 132
  transparency log to read only: it never cuts short an append the web
133 133
  process is making.
134 -
- **Until the log has a cluster-wide writer (#274), only one process per
135 -
  data directory may have the web role.** Any number may be workers.
136 -
  Webhook nudges travel on the event bus (below, #273), so a separate
137 -
  worker delivers a push's webhooks at once, not on its once-a-minute
138 -
  pass.
134 +
- **Several web processes may share a data directory (#274).** Each
135 +
  append to the transparency log holds `append.lock` in the log's
136 +
  directory exclusively, and starts from the log as the files hold it (it
137 +
  finishes what a crashed append left, then appends); readers catch up
138 +
  with every writer's entries, and the checkpoint is read from its file,
139 +
  signed by whichever process leads `tlog.checkpointer`. Across machines
140 +
  that needs the data directory on one shared filesystem with working
141 +
  `flock`; a log role the web nodes call is the answer for that, later.
142 +
  Any number may be workers.
139 143
140 144
## Event bus: publish and subscribe
141 145
…
100 unchanged lines