OXYGENHUB

A caching store in front of any store 454236d1108d

strata::CachingStore: reads through a local directory store, writes to
the remote first and then the cache, bounded by removing the objects
used least recently down to nine tenths of the limit; objects over an
eighth of the limit are read by range from the remote and not kept;
references always from the remote. Passes the conformance check.
refs #68
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-05 01:08 UTC
Signature
✔ signed by Andy McCall
Hash
454236d1108d3b62fa11d933e7cd2d7e05370c5794aa94388455378fb9e49f72
Depends on
33be24095b4b 648189388f1a 8fd78eb52ad2 a5f76c79666c a70dce536476 c07406bcc237
Depended on by
30d5fac7560d d069701a4997
In bookmarks
bare-hashes carried-not-removed carry-identical doctor-trust identical-sides keyless-tags main merge-remote-bookmark only-help-103 paths-for-resolutions-and-amend push-notes show-stat subrepo-pin-state switch-says
In tags
v0.10.0 v0.11.0 v0.4.1 v0.4.2 v0.4.3 v0.4.4 v0.4.5 v0.4.6 v0.5.0 v0.5.1 v0.6.0 v0.7.0 v0.8.0 v0.9.0
Edits
4 atoms, 0 tree operations, 3 files

Browse the tree at this change

modified CHANGELOG.md +2 −0
…
1 unchanged lines
2 2
3 3
## Unreleased
4 4
5 +
- **A caching store, and the operation head on a store (#68).** `strata::CachingStore` puts a local directory in front of any store: reads are kept, writes go to the remote first, the cache stays under a size limit by removing what was used least recently, and references always come from the remote. A repository kept on a store other than this disk's directory now holds its operation head there, moved by compare-and-swap with merge and retry.
6 +
5 7
- **A large merge's file no longer imports empty (#71).** When a merge rendered its files in batches, a file whose graph the import had already built came out empty, so the merge recorded the whole file as inserted again and conflicted with itself: VS Code's history failed verification from 2016 on. Built graphs are now copied. The 10 412-commit reproducer imports and verifies every commit.
6 8
7 9
- **Git import holds a budget of graph memory (#25).** A long-lived file's graph keeps every line it ever had, and the import shed a kept state's graphs only past 2 000 of them; it now also sheds them past 1.5 million atoms, and gives freed memory back after each merge. A 28 637-commit slice of VS Code imports at 5.1 GB instead of 16.9 GB, in 36 minutes instead of 61, with the same verify result. The dependency check no longer memoises every queried change's whole ancestry.
…
106 unchanged lines
modified crates/oxy-core/src/strata.rs +308 −0
…
647 unchanged lines
648 648
    }
649 649
}
650 650
651 +
// ---------------------------------------------------------------- caching
652 +
653 +
/// A store in front of another (oxygen#68): objects are read through a
654 +
/// local directory and written through it, to the remote first, so the
655 +
/// remote always holds everything and the cache can be dropped at any
656 +
/// time. Objects never change, so a cached one is always right (and every
657 +
/// read is hashed again above this layer). The cache is bounded: past its
658 +
/// limit the objects used least recently are removed. An object larger
659 +
/// than an eighth of the limit is not cached; it is read by range from
660 +
/// the remote. References are never cached: they are read from and
661 +
/// written to the remote every time, so compare-and-swap is the remote's.
662 +
pub struct CachingStore {
663 +
    cache: LocalStore,
664 +
    remote: std::sync::Arc<dyn Store>,
665 +
    limit: u64,
666 +
    /// Bytes in the cache, counted on first need and kept up to date.
667 +
    used: Mutex<Option<u64>>,
668 +
}
669 +
670 +
/// How stale a cached object's use time may get before a read renews it.
671 +
const TOUCH_AFTER: Duration = Duration::from_secs(60);
672 +
673 +
impl CachingStore {
674 +
    /// `remote` behind a cache in the directory `dir` of at most `limit`
675 +
    /// bytes.
676 +
    pub fn open(
677 +
        remote: std::sync::Arc<dyn Store>,
678 +
        dir: impl Into<PathBuf>,
679 +
        limit: u64,
680 +
    ) -> Result<CachingStore> {
681 +
        Ok(CachingStore {
682 +
            cache: LocalStore::open(dir)?,
683 +
            remote,
684 +
            limit,
685 +
            used: Mutex::new(None),
686 +
        })
687 +
    }
688 +
689 +
    pub fn remote(&self) -> &std::sync::Arc<dyn Store> {
690 +
        &self.remote
691 +
    }
692 +
693 +
    /// Bytes the cache holds now.
694 +
    pub fn cached_bytes(&self) -> Result<u64> {
695 +
        let mut used = self.used.lock().unwrap_or_else(|p| p.into_inner());
696 +
        if used.is_none() {
697 +
            *used = Some(self.cache.list("")?.iter().map(|e| e.size).sum());
698 +
        }
699 +
        Ok(used.unwrap_or(0))
700 +
    }
701 +
702 +
    /// Whether an object of `size` bytes is kept in the cache at all.
703 +
    fn cacheable(&self, size: u64) -> bool {
704 +
        size <= self.limit / 8
705 +
    }
706 +
707 +
    /// Keep a copy of an object the remote holds.
708 +
    fn keep(&self, key: &str, bytes: &[u8]) -> Result<()> {
709 +
        if !self.cacheable(bytes.len() as u64) {
710 +
            return Ok(());
711 +
        }
712 +
        match self.cache.put_new(key, bytes) {
713 +
            Ok(()) => self.added(bytes.len() as u64),
714 +
            Err(Error::Exists(_)) => Ok(()),
715 +
            Err(e) => Err(e),
716 +
        }
717 +
    }
718 +
719 +
    /// Count `n` new bytes, and remove the least recently used objects
720 +
    /// once the cache is past its limit, down to nine tenths of it.
721 +
    fn added(&self, n: u64) -> Result<()> {
722 +
        let total = {
723 +
            let mut used = self.used.lock().unwrap_or_else(|p| p.into_inner());
724 +
            let total = match *used {
725 +
                Some(u) => u + n,
726 +
                // Counted now, with the new object already in place.
727 +
                None => self.cache.list("")?.iter().map(|e| e.size).sum(),
728 +
            };
729 +
            *used = Some(total);
730 +
            total
731 +
        };
732 +
        if total <= self.limit {
733 +
            return Ok(());
734 +
        }
735 +
        let mut entries = self.cache.list("")?;
736 +
        entries.sort_by_key(|e| e.modified_ms.unwrap_or(0));
737 +
        let mut left: u64 = entries.iter().map(|e| e.size).sum();
738 +
        let target = self.limit / 10 * 9;
739 +
        for e in entries {
740 +
            if left <= target {
741 +
                break;
742 +
            }
743 +
            self.cache.delete(&e.key)?;
744 +
            left -= e.size;
745 +
        }
746 +
        *self.used.lock().unwrap_or_else(|p| p.into_inner()) = Some(left);
747 +
        Ok(())
748 +
    }
749 +
750 +
    /// Mark a cached object used now, at most once a minute.
751 +
    fn touch(&self, key: &str) {
752 +
        let Ok(path) = self.cache.path(key) else {
753 +
            return;
754 +
        };
755 +
        let stale = fs::metadata(&path)
756 +
            .and_then(|m| m.modified())
757 +
            .ok()
758 +
            .and_then(|t| t.elapsed().ok())
759 +
            .is_some_and(|age| age > TOUCH_AFTER);
760 +
        if stale {
761 +
            if let Ok(f) = fs::File::options().append(true).open(&path) {
762 +
                let _ = f.set_modified(std::time::SystemTime::now());
763 +
            }
764 +
        }
765 +
    }
766 +
}
767 +
768 +
impl Store for CachingStore {
769 +
    fn describe(&self) -> String {
770 +
        format!(
771 +
            "{} (cached in {})",
772 +
            self.remote.describe(),
773 +
            self.cache.root().display()
774 +
        )
775 +
    }
776 +
777 +
    fn has(&self, key: &str) -> Result<bool> {
778 +
        Ok(self.cache.has(key)? || self.remote.has(key)?)
779 +
    }
780 +
781 +
    fn get(&self, key: &str) -> Result<Vec<u8>> {
782 +
        match self.cache.get(key) {
783 +
            Ok(bytes) => {
784 +
                self.touch(key);
785 +
                return Ok(bytes);
786 +
            }
787 +
            // Not cached, or removed by another process's eviction meanwhile.
788 +
            Err(Error::NotFound(_)) => {}
789 +
            Err(e) => return Err(e),
790 +
        }
791 +
        let bytes = self.remote.get(key)?;
792 +
        self.keep(key, &bytes)?;
793 +
        Ok(bytes)
794 +
    }
795 +
796 +
    fn get_range(&self, key: &str, range: Range<u64>) -> Result<Vec<u8>> {
797 +
        match self.cache.get_range(key, range.clone()) {
798 +
            Ok(bytes) => {
799 +
                self.touch(key);
800 +
                return Ok(bytes);
801 +
            }
802 +
            Err(Error::NotFound(_)) => {}
803 +
            Err(e) if self.cache.has(key)? => return Err(e),
804 +
            Err(_) => {}
805 +
        }
806 +
        // A small object is fetched whole and kept; a large one is read by
807 +
        // range, every time.
808 +
        match self.remote.stat(key)? {
809 +
            Some(e) if self.cacheable(e.size) => {
810 +
                let bytes = self.get(key)?;
811 +
                let len = bytes.len() as u64;
812 +
                if range.end > len || range.start > range.end {
813 +
                    return Err(Error::Backend(format!(
814 +
                        "{key}: bytes {}..{} asked of an object of {len} bytes",
815 +
                        range.start, range.end
816 +
                    )));
817 +
                }
818 +
                Ok(bytes[range.start as usize..range.end as usize].to_vec())
819 +
            }
820 +
            Some(_) => self.remote.get_range(key, range),
821 +
            None => Err(Error::NotFound(key.to_string())),
822 +
        }
823 +
    }
824 +
825 +
    fn put_new(&self, key: &str, bytes: &[u8]) -> Result<()> {
826 +
        self.remote.put_new(key, bytes)?;
827 +
        self.keep(key, bytes)
828 +
    }
829 +
830 +
    fn put_many_new(&self, objects: &[(String, Vec<u8>)]) -> Result<()> {
831 +
        self.remote.put_many_new(objects)?;
832 +
        for (key, bytes) in objects {
833 +
            self.keep(key, bytes)?;
834 +
        }
835 +
        Ok(())
836 +
    }
837 +
838 +
    fn put_new_file(&self, key: &str, path: &Path) -> Result<()> {
839 +
        let size = fs::metadata(path)?.len();
840 +
        let copy = if self.cacheable(size) {
841 +
            Some(fs::read(path)?)
842 +
        } else {
843 +
            None
844 +
        };
845 +
        self.remote.put_new_file(key, path)?;
846 +
        match copy {
847 +
            Some(bytes) => self.keep(key, &bytes),
848 +
            None => Ok(()),
849 +
        }
850 +
    }
851 +
852 +
    fn list(&self, prefix: &str) -> Result<Vec<Entry>> {
853 +
        self.remote.list(prefix)
854 +
    }
855 +
856 +
    fn stat(&self, key: &str) -> Result<Option<Entry>> {
857 +
        self.remote.stat(key)
858 +
    }
859 +
860 +
    fn delete(&self, key: &str) -> Result<()> {
861 +
        self.remote.delete(key)?;
862 +
        let size = self.cache.stat(key)?.map(|e| e.size);
863 +
        self.cache.delete(key)?;
864 +
        if let Some(n) = size {
865 +
            let mut used = self.used.lock().unwrap_or_else(|p| p.into_inner());
866 +
            if let Some(u) = used.as_mut() {
867 +
                *u = u.saturating_sub(n);
868 +
            }
869 +
        }
870 +
        Ok(())
871 +
    }
872 +
873 +
    fn read_ref(&self, key: &str) -> Result<Option<(Vec<u8>, Version)>> {
874 +
        self.remote.read_ref(key)
875 +
    }
876 +
877 +
    fn write_ref(&self, key: &str, bytes: &[u8], expected: Option<&Version>) -> Result<Version> {
878 +
        self.remote.write_ref(key, bytes, expected)
879 +
    }
880 +
}
881 +
651 882
// ---------------------------------------------------------------- conformance
652 883
653 884
/// Exercise a store under `prefix` (which must be free, and is left
…
155 unchanged lines
809 1040
            assert!(check_key(bad).is_err(), "{bad:?}");
810 1041
        }
811 1042
        assert!(check_key(&"x".repeat(MAX_KEY_LEN + 1)).is_err());
1043 +
    }
1044 +
1045 +
    #[test]
1046 +
    fn a_caching_store_conforms_and_reads_and_writes_through() {
1047 +
        let dir = tempfile::tempdir().unwrap();
1048 +
        let remote: Arc<dyn Store> = Arc::new(MemoryStore::new());
1049 +
        let store = CachingStore::open(remote.clone(), dir.path().join("cache"), 8 << 20).unwrap();
1050 +
        assert_eq!(conformance(&store, "probe").unwrap().len(), 8);
1051 +
        assert_eq!(
1052 +
            store.cached_bytes().unwrap(),
1053 +
            0,
1054 +
            "the probe left nothing cached"
1055 +
        );
1056 +
1057 +
        // Written through: the remote has it, and so does the cache.
1058 +
        store.put_new("loose/aa", b"written").unwrap();
1059 +
        assert_eq!(remote.get("loose/aa").unwrap(), b"written");
1060 +
        assert!(store.cache.has("loose/aa").unwrap());
1061 +
        // Read through: an object only the remote has is kept on first read.
1062 +
        remote.put_new("loose/bb", b"remote only").unwrap();
1063 +
        assert!(!store.cache.has("loose/bb").unwrap());
1064 +
        assert_eq!(store.get_range("loose/bb", 0..6).unwrap(), b"remote");
1065 +
        assert!(store.cache.has("loose/bb").unwrap());
1066 +
        // A cached object read after the remote lost it is still served.
1067 +
        remote.delete("loose/bb").unwrap();
1068 +
        assert_eq!(store.get("loose/bb").unwrap(), b"remote only");
1069 +
        // References are always the remote's.
1070 +
        let v = remote.write_ref("refs", b"one", None).unwrap();
1071 +
        assert_eq!(
1072 +
            store.read_ref("refs").unwrap(),
1073 +
            Some((b"one".to_vec(), v.clone()))
1074 +
        );
1075 +
        remote.write_ref("refs", b"two", Some(&v)).unwrap();
1076 +
        assert_eq!(store.read_ref("refs").unwrap().unwrap().0, b"two");
1077 +
        assert!(matches!(
1078 +
            store.write_ref("refs", b"three", Some(&v)),
1079 +
            Err(Error::Conflict(_))
1080 +
        ));
1081 +
        assert!(!store.cache.has("refs").unwrap());
1082 +
    }
1083 +
1084 +
    #[test]
1085 +
    fn a_caching_store_stays_within_its_limit() {
1086 +
        let dir = tempfile::tempdir().unwrap();
1087 +
        let remote: Arc<dyn Store> = Arc::new(MemoryStore::new());
1088 +
        let store = CachingStore::open(remote.clone(), dir.path().join("cache"), 8000).unwrap();
1089 +
        for i in 0..20 {
1090 +
            store
1091 +
                .put_new(&format!("loose/{i:02}"), &[i as u8; 900])
1092 +
                .unwrap();
1093 +
            assert!(store.cached_bytes().unwrap() <= 8000);
1094 +
        }
1095 +
        assert_eq!(
1096 +
            remote.list("loose/").unwrap().len(),
1097 +
            20,
1098 +
            "the remote holds everything"
1099 +
        );
1100 +
        let cached = store.cache.list("loose/").unwrap();
1101 +
        assert!(cached.len() < 20 && cached.len() >= 7, "{}", cached.len());
1102 +
        assert!(
1103 +
            cached.iter().any(|e| e.key == "loose/19"),
1104 +
            "the newest is kept"
1105 +
        );
1106 +
        for i in 0..20 {
1107 +
            assert_eq!(
1108 +
                store.get(&format!("loose/{i:02}")).unwrap(),
1109 +
                vec![i as u8; 900]
1110 +
            );
1111 +
        }
1112 +
        // An object over an eighth of the limit is read by range, not kept.
1113 +
        remote.put_new("pack-big", &[7u8; 2000]).unwrap();
1114 +
        assert_eq!(store.get_range("pack-big", 10..20).unwrap(), vec![7u8; 10]);
1115 +
        assert_eq!(store.get("pack-big").unwrap().len(), 2000);
1116 +
        assert!(!store.cache.has("pack-big").unwrap());
1117 +
        // Counting from scratch agrees with what was kept track of.
1118 +
        let fresh = CachingStore::open(remote, dir.path().join("cache"), 8000).unwrap();
1119 +
        assert_eq!(fresh.cached_bytes().unwrap(), store.cached_bytes().unwrap());
812 1120
    }
813 1121
814 1122
    #[test]
…
104 unchanged lines
modified docs/stores.md +18 −0
…
112 unchanged lines
113 113
  holds no copy of its own and the data lives where its owner chose.
114 114
  B1 (the store over the trait) is done; B2 is the caching store and
115 115
  the references by compare-and-swap.
116 +
- **The operation head on a store** (B2, 2026-10-05): a repository whose
117 +
  objects are on any store but this disk's directory keeps its
118 +
  operation head in that store's `ops/head` reference record. Recording
119 +
  an operation reads the record and its version, and moves it only if
120 +
  the version is unchanged. When another process moved it first, the
121 +
  two operations are merged by the same rules as on disk, and the write
122 +
  is tried again (eight times at most). A repository on this disk keeps
123 +
  `.oxy/ops/head` under its lock file, as before, so older oxy builds
124 +
  share it safely.
125 +
- **The caching store** (B2, 2026-10-05): `strata::CachingStore` puts a
126 +
  directory store in front of any other. Reads go through it: a missing
127 +
  object is fetched from the remote and kept. Writes go to the remote
128 +
  first and then the cache, so the remote always holds everything and
129 +
  the cache can be deleted at any time. The cache has a size limit; past
130 +
  it the objects used least recently are removed, down to nine tenths of
131 +
  the limit. An object larger than an eighth of the limit is not kept:
132 +
  ranges of it are read from the remote each time. References are never
133 +
  cached, so compare-and-swap is always the remote's.
116 134
117 135
## 4. Layout in the bucket (as built, 2026-10-03)
118 136
…
141 unchanged lines