├── .github └── workflows │ ├── docker_image.yml │ ├── push.yml │ └── release.yml ├── .gitignore ├── Dockerfile ├── Dockerfile.test ├── LICENSE ├── Makefile ├── README.md ├── backup_client.go ├── backup_client_test.go ├── client.go ├── client_test.go ├── cmd ├── litefs-bench │ └── main.go └── litefs │ ├── config.go │ ├── config_test.go │ ├── etc │ └── litefs.yml │ ├── export.go │ ├── export_test.go │ ├── import.go │ ├── import_test.go │ ├── main.go │ ├── main_test.go │ ├── mount_darwin.go │ ├── mount_linux.go │ ├── mount_test.go │ ├── run.go │ └── testdata │ └── config │ ├── multi_exec.yml │ └── single_exec.yml ├── consul └── consul.go ├── db.go ├── docs └── ARCHITECTURE.md ├── fly └── environment.go ├── fuse ├── database_node.go ├── file_system.go ├── file_system_test.go ├── fuse.go ├── fuse_test.go ├── journal_node.go ├── lag_node.go ├── lock_node.go ├── pos_node.go ├── primary_node.go ├── root_node.go ├── shm_node.go └── wal_node.go ├── go.mod ├── go.sum ├── http ├── client.go ├── http.go ├── http_test.go ├── proxy_server.go └── server.go ├── internal ├── chunk │ ├── chunk.go │ └── chunk_test.go ├── internal.go ├── internal_test.go ├── system_os.go └── testingutil │ └── testingutil.go ├── lease.go ├── lease_test.go ├── lfsc ├── backup_client.go └── backup_client_test.go ├── litefs.go ├── litefs_test.go ├── mock ├── client.go ├── lease.go └── os.go ├── rwmutex.go ├── rwmutex_test.go ├── store.go ├── store_test.go ├── testdata ├── db │ ├── enforce-retention │ │ └── database │ └── write-snapshot-to │ │ └── database ├── store │ ├── open-and-write-snapshot │ │ └── dbs │ │ │ └── sqlite.db │ │ │ ├── database │ │ │ └── ltx │ │ │ └── 000000000000000d-000000000000000d.ltx │ ├── open-invalid-database-header │ │ └── dbs │ │ │ └── test.db │ │ │ └── database │ ├── open-name-only │ │ └── dbs │ │ │ └── test.db │ │ │ └── database │ └── open-short-database │ │ └── dbs │ │ └── test.db │ │ └── database └── wal-reader │ ├── frame-checksum-mismatch │ └── wal │ ├── ok │ └── wal │ └── salt-mismatch │ └── wal └── tests ├── litefs.yml └── test.sh /.github/workflows/docker_image.yml: -------------------------------------------------------------------------------- 1 | on: 2 | release: 3 | types: 4 | - published 5 | push: 6 | branches: 7 | - main 8 | pull_request: 9 | types: 10 | - opened 11 | - synchronize 12 | - reopened 13 | branches-ignore: 14 | - "dependabot/**" 15 | 16 | name: "Docker Image" 17 | jobs: 18 | docker: 19 | name: "Publish to Docker" 20 | runs-on: ubuntu-latest 21 | env: 22 | PLATFORMS: "${{ github.event_name == 'release' && 'linux/amd64,linux/arm64,linux/arm/v7' || 'linux/amd64' }}" 23 | VERSION: "${{ github.event_name == 'release' && github.event.release.name || github.ref_name }}" 24 | 25 | steps: 26 | - uses: actions/checkout@v4 27 | - uses: docker/setup-qemu-action@v3 28 | - uses: docker/setup-buildx-action@v3 29 | - uses: docker/login-action@v3 30 | id: login 31 | env: 32 | token_is_present: "${{ secrets.DOCKERHUB_TOKEN && true }}" 33 | if: ${{ env.token_is_present }} 34 | with: 35 | username: ${{ secrets.DOCKERHUB_USERNAME || 'benbjohnson' }} 36 | password: ${{ secrets.DOCKERHUB_TOKEN }} 37 | 38 | - id: meta 39 | uses: docker/metadata-action@v5 40 | with: 41 | images: flyio/litefs 42 | tags: | 43 | type=ref,event=branch 44 | type=ref,event=pr 45 | type=sha 46 | type=sha,format=long 47 | type=semver,pattern={{version}} 48 | type=semver,pattern={{major}}.{{minor}} 49 | 50 | - uses: docker/build-push-action@v6 51 | with: 52 | context: . 53 | push: ${{ steps.login.outcome != 'skipped' }} 54 | platforms: ${{ env.PLATFORMS }} 55 | tags: ${{ steps.meta.outputs.tags }} 56 | labels: ${{ steps.meta.outputs.labels }} 57 | build-args: | 58 | LITEFS_VERSION=${{ env.VERSION }} 59 | LITEFS_COMMIT=${{ github.sha }} 60 | -------------------------------------------------------------------------------- /.github/workflows/push.yml: -------------------------------------------------------------------------------- 1 | name: "Push" 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | types: 8 | - opened 9 | - synchronize 10 | - reopened 11 | 12 | jobs: 13 | build: 14 | name: "Build" 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - uses: actions/setup-go@v5 20 | with: 21 | go-version-file: 'go.mod' 22 | 23 | - name: apt install 24 | run: sudo apt install -y fuse3 libsqlite3-dev 25 | 26 | - name: Build binaries 27 | run: go install ./cmd/... 28 | 29 | unit: 30 | name: "Unit Tests" 31 | runs-on: ubuntu-latest 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | journal_mode: [delete, persist, truncate, wal] 36 | steps: 37 | - uses: actions/checkout@v4 38 | 39 | - uses: actions/setup-go@v5 40 | with: 41 | go-version-file: 'go.mod' 42 | 43 | - name: apt install 44 | run: | 45 | wget -O - https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg 46 | echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list 47 | sudo apt update && sudo apt install -y fuse3 libsqlite3-dev consul 48 | 49 | - name: check fuse version 50 | run: fusermount -V 51 | 52 | - name: Run unit tests 53 | run: go test -v . ./internal/... 54 | 55 | - name: Run FUSE tests 56 | run: go test -v -p 1 -timeout 10m ./fuse -long -journal-mode ${{ matrix.journal_mode }} 57 | timeout-minutes: 5 58 | 59 | - name: Start consul in dev mode 60 | run: consul agent -dev & 61 | 62 | - name: Run cmd tests 63 | run: go test -v -p 1 -timeout 10m ./cmd/litefs -journal-mode ${{ matrix.journal_mode }} 64 | timeout-minutes: 5 65 | 66 | functional: 67 | name: "Functional Tests" 68 | runs-on: ubuntu-latest 69 | strategy: 70 | fail-fast: false 71 | matrix: 72 | journal_mode: [delete, persist, truncate, wal] 73 | steps: 74 | - uses: actions/checkout@v4 75 | 76 | - uses: actions/setup-go@v5 77 | with: 78 | go-version-file: 'go.mod' 79 | 80 | - name: apt install 81 | run: | 82 | wget -O - https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg 83 | echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list 84 | sudo apt update && sudo apt install -y fuse3 libsqlite3-dev consul 85 | 86 | - name: Start consul in dev mode 87 | run: consul agent -dev & 88 | 89 | - name: Run functional tests 90 | run: go test -v -p 1 -run=TestFunctional_OK ./cmd/litefs -funtime 30s -journal-mode ${{ matrix.journal_mode }} 91 | timeout-minutes: 10 92 | 93 | staticcheck: 94 | name: "Staticcheck" 95 | runs-on: ubuntu-latest 96 | steps: 97 | - uses: actions/checkout@v4 98 | 99 | - uses: actions/setup-go@v5 100 | with: 101 | go-version-file: 'go.mod' 102 | 103 | - uses: dominikh/staticcheck-action@v1.3.1 104 | with: 105 | install-go: false 106 | 107 | errcheck: 108 | name: "Errcheck" 109 | runs-on: ubuntu-latest 110 | steps: 111 | - uses: actions/checkout@v4 112 | 113 | - uses: actions/setup-go@v5 114 | with: 115 | go-version-file: 'go.mod' 116 | 117 | - run: go install github.com/kisielk/errcheck@latest 118 | 119 | - run: errcheck ./... 120 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | on: 2 | release: 3 | types: 4 | - created 5 | pull_request: 6 | types: 7 | - opened 8 | - synchronize 9 | - reopened 10 | branches-ignore: 11 | - "dependabot/**" 12 | 13 | name: Release 14 | jobs: 15 | linux: 16 | permissions: 17 | contents: write 18 | runs-on: ubuntu-latest 19 | strategy: 20 | matrix: 21 | include: 22 | - arch: amd64 23 | cc: gcc 24 | - arch: arm64 25 | cc: aarch64-linux-gnu-gcc 26 | - arch: arm 27 | arm: 6 28 | cc: arm-linux-gnueabi-gcc 29 | - arch: arm 30 | arm: 7 31 | cc: arm-linux-gnueabihf-gcc 32 | 33 | env: 34 | GOOS: linux 35 | GOARCH: ${{ matrix.arch }} 36 | GOARM: ${{ matrix.arm }} 37 | CC: ${{ matrix.cc }} 38 | VERSION: "${{ github.event_name == 'release' && github.event.release.name || github.sha }}" 39 | MAIN_VERSION: "${{ github.event_name == 'release' && github.event.release.name || '' }}" 40 | 41 | steps: 42 | - uses: actions/checkout@v4 43 | - uses: actions/setup-go@v5 44 | with: 45 | go-version-file: 'go.mod' 46 | 47 | - id: release 48 | uses: bruceadams/get-release@v1.3.2 49 | if: github.event_name == 'release' 50 | env: 51 | GITHUB_TOKEN: ${{ github.token }} 52 | 53 | - name: Install cross-compilers 54 | run: | 55 | sudo apt-get update 56 | sudo apt-get install -y gcc-aarch64-linux-gnu gcc-arm-linux-gnueabihf gcc-arm-linux-gnueabi 57 | 58 | - name: Build binary 59 | run: | 60 | rm -rf dist 61 | mkdir -p dist 62 | CGO_ENABLED=1 go build -ldflags "-s -w -extldflags "-static" -X 'main.Version=${{ env.MAIN_VERSION }}' -X 'main.Commit=${{ github.sha }}'" -tags osusergo,netgo -o dist/litefs ./cmd/litefs 63 | cd dist 64 | tar -czvf litefs-${{ env.VERSION }}-${{ env.GOOS }}-${{ env.GOARCH }}${{ env.GOARM }}.tar.gz litefs 65 | 66 | - name: Upload binary artifact 67 | uses: actions/upload-artifact@v4 68 | with: 69 | name: litefs-${{ env.VERSION }}-${{ env.GOOS }}-${{ env.GOARCH }}${{ env.GOARM }} 70 | path: dist/litefs 71 | if-no-files-found: error 72 | 73 | - name: Upload release tarball 74 | uses: actions/upload-release-asset@v1.0.2 75 | if: github.event_name == 'release' 76 | env: 77 | GITHUB_TOKEN: ${{ github.token }} 78 | with: 79 | upload_url: ${{ steps.release.outputs.upload_url }} 80 | asset_path: ./dist/litefs-${{ env.VERSION }}-${{ env.GOOS }}-${{ env.GOARCH }}${{ env.GOARM }}.tar.gz 81 | asset_name: litefs-${{ env.VERSION }}-${{ env.GOOS }}-${{ env.GOARCH }}${{ env.GOARM }}.tar.gz 82 | asset_content_type: application/gzip 83 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | go.work 3 | coverprofile 4 | .vscode 5 | 6 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.21.5 AS builder 2 | 3 | WORKDIR /src/litefs 4 | COPY . . 5 | 6 | ARG LITEFS_VERSION= 7 | ARG LITEFS_COMMIT= 8 | 9 | RUN --mount=type=cache,target=/root/.cache/go-build \ 10 | --mount=type=cache,target=/go/pkg \ 11 | go build -ldflags "-s -w -X 'main.Version=${LITEFS_VERSION}' -X 'main.Commit=${LITEFS_COMMIT}' -extldflags '-static'" -tags osusergo,netgo,sqlite_omit_load_extension -o /usr/local/bin/litefs ./cmd/litefs 12 | 13 | FROM alpine 14 | 15 | RUN apk add --no-cache fuse3 16 | 17 | COPY --from=builder /usr/local/bin/litefs /usr/local/bin/litefs 18 | 19 | ENTRYPOINT ["/usr/local/bin/litefs"] 20 | CMD ["mount", "-skip-unmount"] 21 | -------------------------------------------------------------------------------- /Dockerfile.test: -------------------------------------------------------------------------------- 1 | FROM golang:1.21 as builder 2 | 3 | WORKDIR /src/litefs 4 | COPY ../. . 5 | 6 | RUN --mount=type=cache,target=/root/.cache/go-build \ 7 | --mount=type=cache,target=/go/pkg \ 8 | go build -o /usr/local/bin/litefs ./cmd/litefs 9 | 10 | 11 | FROM debian:buster-20221219-slim 12 | RUN set -ex \ 13 | && apt-get update \ 14 | && apt-get upgrade -y --no-install-recommends \ 15 | && apt-get install -y curl ca-certificates unzip \ 16 | && apt-get install -y build-essential tcl tcl-dev zlib1g-dev \ 17 | && apt-get install -y kmod procps nano \ 18 | && apt-get install -y fuse 19 | 20 | COPY --from=builder /usr/local/bin/litefs /usr/local/bin/litefs 21 | ADD tests/litefs.yml /etc/litefs.yml 22 | ADD tests/test.sh /usr/bin/test.sh 23 | RUN chmod +x /usr/bin/test.sh 24 | 25 | RUN adduser --disabled-password --gecos "" build 26 | USER build 27 | WORKDIR /home/build 28 | 29 | RUN curl -fsSLO --compressed --create-dirs "https://sqlite.org/2022/sqlite-src-3400100.zip" 30 | RUN unzip sqlite-src-3400100.zip && \ 31 | mv sqlite-src-3400100 sqlite && \ 32 | cd sqlite && ./configure && make testfixture 33 | 34 | ENTRYPOINT ["/usr/bin/test.sh"] 35 | CMD ["extraquick.test"] 36 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: default 2 | default: 3 | 4 | .PHONY: check 5 | check: 6 | errcheck ./... 7 | staticcheck ./... 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | LiteFS 2 | ![GitHub release (latest by date)](https://img.shields.io/github/v/release/superfly/litefs) 3 | ![Status](https://img.shields.io/badge/status-beta-blue) 4 | ![GitHub](https://img.shields.io/github/license/superfly/litefs) 5 | ====== 6 | 7 | LiteFS is a FUSE-based file system for replicating SQLite databases across a 8 | cluster of machines. It works as a passthrough file system that intercepts 9 | writes to SQLite databases in order to detect transaction boundaries and record 10 | changes on a per-transaction level in [LTX files](https://github.com/superfly/ltx). 11 | 12 | This project is actively maintained but is currently in a beta state. Please 13 | report any bugs as an issue on the GitHub repository. 14 | 15 | You can find a [Getting Started guide](https://fly.io/docs/litefs/getting-started/) 16 | on [LiteFS' documentation site](https://fly.io/docs/litefs/). Please see the 17 | [ARCHITECTURE.md](/docs/ARCHITECTURE.md) design document for details about how 18 | LiteFS works. 19 | 20 | 21 | ## SQLite TCL Test Suite 22 | 23 | It's a goal of LiteFS to pass the SQLite TCL test suite, however, this is 24 | currently a work in progress. LiteFS doesn't have database deletion implemented 25 | yet so that causes many tests to fail during teardown. 26 | 27 | To run a test from the suite against LiteFS, you can use the `Dockerfile.test` 28 | to run it in isolation. First build the Dockerfile: 29 | 30 | ```sh 31 | docker build -t litefs-test -f Dockerfile.test . 32 | ``` 33 | 34 | Then run it with the filename of the test you want to run. In this case, we 35 | are running `select1.test`: 36 | 37 | ```sh 38 | docker run --device /dev/fuse --cap-add SYS_ADMIN -it litefs-test select1.test 39 | ``` 40 | 41 | 42 | ## Contributing 43 | 44 | LiteFS contributions work a little different than most GitHub projects. If you 45 | have a small bug fix or typo fix, please PR directly to this repository. 46 | 47 | If you would like to contribute a feature, please follow these steps: 48 | 49 | 1. Discuss the feature in an issue on this GitHub repository. 50 | 2. Create a pull request to **your fork** of the repository. 51 | 3. Post a link to your pull request in the issue for consideration. 52 | 53 | This project has a roadmap and features are added and tested in a certain order. 54 | Additionally, it's likely that code style, implementation details, and test 55 | coverage will need to be tweaked so it's easier to for me to grab your 56 | implementation as a starting point when implementing a feature. 57 | -------------------------------------------------------------------------------- /backup_client.go: -------------------------------------------------------------------------------- 1 | package litefs 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "io" 8 | "net/url" 9 | "os" 10 | "path/filepath" 11 | "sort" 12 | "sync" 13 | 14 | "github.com/superfly/litefs/internal" 15 | "github.com/superfly/ltx" 16 | ) 17 | 18 | type BackupClient interface { 19 | // URL of the backup service. 20 | URL() string 21 | 22 | // PosMap returns the replication position for all databases on the backup service. 23 | PosMap(ctx context.Context) (map[string]ltx.Pos, error) 24 | 25 | // WriteTx writes an LTX file to the backup service. The file must be 26 | // contiguous with the latest LTX file on the backup service or else it 27 | // will return an ltx.PosMismatchError. 28 | // 29 | // Returns the high-water mark that indicates it is safe to remove LTX files 30 | // before that transaction ID. 31 | WriteTx(ctx context.Context, name string, r io.Reader) (hwm ltx.TXID, err error) 32 | 33 | // FetchSnapshot requests a full snapshot of the database as it exists on 34 | // the backup service. This should be used if the LiteFS node has become 35 | // out of sync with the backup service. 36 | FetchSnapshot(ctx context.Context, name string) (io.ReadCloser, error) 37 | } 38 | 39 | var _ BackupClient = (*FileBackupClient)(nil) 40 | 41 | // FileBackupClient is a reference implemenation for BackupClient. 42 | // This implementation is typically only used for testing. 43 | type FileBackupClient struct { 44 | mu sync.Mutex 45 | path string 46 | } 47 | 48 | // NewFileBackupClient returns a new instance of FileBackupClient. 49 | func NewFileBackupClient(path string) *FileBackupClient { 50 | return &FileBackupClient{path: path} 51 | } 52 | 53 | // Open validates & creates the path the client was initialized with. 54 | func (c *FileBackupClient) Open() (err error) { 55 | // Ensure root path exists and we can get the absolute path from it. 56 | if c.path == "" { 57 | return fmt.Errorf("backup path required") 58 | } else if c.path, err = filepath.Abs(c.path); err != nil { 59 | return err 60 | } 61 | 62 | if err := os.MkdirAll(c.path, 0o777); err != nil { 63 | return err 64 | } 65 | return nil 66 | } 67 | 68 | // URL of the backup service. 69 | func (c *FileBackupClient) URL() string { 70 | return (&url.URL{ 71 | Scheme: "file", 72 | Path: filepath.ToSlash(c.path), 73 | }).String() 74 | } 75 | 76 | // PosMap returns the replication position for all databases on the backup service. 77 | func (c *FileBackupClient) PosMap(ctx context.Context) (map[string]ltx.Pos, error) { 78 | c.mu.Lock() 79 | defer c.mu.Unlock() 80 | 81 | ents, err := os.ReadDir(c.path) 82 | if err != nil && !os.IsNotExist(err) { 83 | return nil, err 84 | } 85 | 86 | m := make(map[string]ltx.Pos) 87 | for _, ent := range ents { 88 | if !ent.IsDir() { 89 | continue 90 | } 91 | pos, err := c.pos(ctx, ent.Name()) 92 | if err != nil { 93 | return nil, err 94 | } 95 | m[ent.Name()] = pos 96 | } 97 | 98 | return m, nil 99 | } 100 | 101 | // pos returns the replication position for a single database. 102 | func (c *FileBackupClient) pos(ctx context.Context, name string) (ltx.Pos, error) { 103 | dir := filepath.Join(c.path, name) 104 | ents, err := os.ReadDir(dir) 105 | if err != nil && !os.IsNotExist(err) { 106 | return ltx.Pos{}, err 107 | } 108 | 109 | var max string 110 | for _, ent := range ents { 111 | if ent.IsDir() || filepath.Ext(ent.Name()) != ".ltx" { 112 | continue 113 | } 114 | if max == "" || ent.Name() > max { 115 | max = ent.Name() 116 | } 117 | } 118 | 119 | // No LTX files, return empty position. 120 | if max == "" { 121 | return ltx.Pos{}, nil 122 | } 123 | 124 | // Determine position from last file in directory. 125 | f, err := os.Open(filepath.Join(dir, max)) 126 | if err != nil { 127 | return ltx.Pos{}, err 128 | } 129 | defer func() { _ = f.Close() }() 130 | 131 | dec := ltx.NewDecoder(f) 132 | if err := dec.Verify(); err != nil { 133 | return ltx.Pos{}, err 134 | } 135 | 136 | return ltx.Pos{ 137 | TXID: dec.Header().MaxTXID, 138 | PostApplyChecksum: dec.Trailer().PostApplyChecksum, 139 | }, nil 140 | } 141 | 142 | // WriteTx writes an LTX file to the backup service. The file must be 143 | // contiguous with the latest LTX file on the backup service or else it 144 | // will return an ltx.PosMismatchError. 145 | func (c *FileBackupClient) WriteTx(ctx context.Context, name string, r io.Reader) (hwm ltx.TXID, err error) { 146 | c.mu.Lock() 147 | defer c.mu.Unlock() 148 | 149 | // Determine the current position. 150 | pos, err := c.pos(ctx, name) 151 | if err != nil { 152 | return 0, err 153 | } 154 | 155 | // Peek at the LTX header. 156 | buf := make([]byte, ltx.HeaderSize) 157 | var hdr ltx.Header 158 | if _, err := io.ReadFull(r, buf); err != nil { 159 | return 0, err 160 | } else if err := hdr.UnmarshalBinary(buf); err != nil { 161 | return 0, err 162 | } 163 | r = io.MultiReader(bytes.NewReader(buf), r) 164 | 165 | // Ensure LTX file is contiguous with current replication position. 166 | if pos.TXID+1 != hdr.MinTXID || pos.PostApplyChecksum != hdr.PreApplyChecksum { 167 | return 0, ltx.NewPosMismatchError(pos) 168 | } 169 | 170 | // Write file to a temporary file. 171 | filename := filepath.Join(c.path, name, ltx.FormatFilename(hdr.MinTXID, hdr.MaxTXID)) 172 | tempFilename := filename + ".tmp" 173 | if err := os.MkdirAll(filepath.Dir(tempFilename), 0o777); err != nil { 174 | return 0, err 175 | } 176 | f, err := os.Create(tempFilename) 177 | if err != nil { 178 | return 0, err 179 | } 180 | defer func() { _ = f.Close() }() 181 | 182 | // Copy & sync the file. 183 | if _, err := io.Copy(f, r); err != nil { 184 | return 0, err 185 | } else if err := f.Sync(); err != nil { 186 | return 0, err 187 | } 188 | 189 | // Verify the contents of the file. 190 | if _, err := f.Seek(0, io.SeekStart); err != nil { 191 | return 0, err 192 | } else if err := ltx.NewDecoder(f).Verify(); err != nil { 193 | return 0, err 194 | } 195 | if err := f.Close(); err != nil { 196 | return 0, err 197 | } 198 | 199 | // Atomically rename the file. 200 | if err := os.Rename(tempFilename, filename); err != nil { 201 | return 0, err 202 | } else if err := internal.Sync(filepath.Dir(filename)); err != nil { 203 | return 0, err 204 | } 205 | 206 | return hdr.MaxTXID, nil 207 | } 208 | 209 | // FetchSnapshot requests a full snapshot of the database as it exists on 210 | // the backup service. This should be used if the LiteFS node has become 211 | // out of sync with the backup service. 212 | func (c *FileBackupClient) FetchSnapshot(ctx context.Context, name string) (_ io.ReadCloser, retErr error) { 213 | c.mu.Lock() 214 | defer c.mu.Unlock() 215 | 216 | dir := filepath.Join(c.path, name) 217 | ents, err := os.ReadDir(dir) 218 | if err != nil && !os.IsNotExist(err) { 219 | return nil, err 220 | } 221 | 222 | // Collect the filenames of all LTX files. 223 | var filenames []string 224 | for _, ent := range ents { 225 | if ent.IsDir() || filepath.Ext(ent.Name()) != ".ltx" { 226 | continue 227 | } 228 | filenames = append(filenames, ent.Name()) 229 | } 230 | 231 | // Return an error if we have no LTX data for the database. 232 | if len(filenames) == 0 { 233 | return nil, os.ErrNotExist 234 | } 235 | sort.Strings(filenames) 236 | 237 | // Build sorted reader list for compactor. 238 | var rdrs []io.Reader 239 | defer func() { 240 | if retErr != nil { 241 | for _, r := range rdrs { 242 | _ = r.(io.Closer).Close() 243 | } 244 | } 245 | }() 246 | for _, filename := range filenames { 247 | f, err := os.Open(filepath.Join(dir, filename)) 248 | if err != nil { 249 | return nil, err 250 | } 251 | rdrs = append(rdrs, f) 252 | } 253 | 254 | // Send compaction through a pipe so we can convert it to an io.Reader. 255 | pr, pw := io.Pipe() 256 | go func() { 257 | compactor := ltx.NewCompactor(pw, rdrs) 258 | compactor.HeaderFlags = ltx.HeaderFlagCompressLZ4 259 | _ = pw.CloseWithError(compactor.Compact(ctx)) 260 | }() 261 | return pr, nil 262 | } 263 | -------------------------------------------------------------------------------- /backup_client_test.go: -------------------------------------------------------------------------------- 1 | package litefs_test 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "errors" 7 | "io" 8 | "reflect" 9 | "testing" 10 | 11 | "github.com/superfly/litefs" 12 | "github.com/superfly/ltx" 13 | ) 14 | 15 | func TestFileBackupClient_URL(t *testing.T) { 16 | c := litefs.NewFileBackupClient("/path/to/data") 17 | if got, want := c.URL(), `file:///path/to/data`; got != want { 18 | t.Fatalf("URL=%s, want %s", got, want) 19 | } 20 | } 21 | 22 | func TestFileBackupClient_WriteTx(t *testing.T) { 23 | t.Run("OK", func(t *testing.T) { 24 | c := newOpenFileBackupClient(t) 25 | 26 | // Write several transaction files to the client. 27 | if hwm, err := c.WriteTx(context.Background(), "db", ltxFileSpecReader(t, <x.FileSpec{ 28 | Header: ltx.Header{Version: 1, PageSize: 512, Commit: 1, MinTXID: 1, MaxTXID: 1}, 29 | Pages: []ltx.PageSpec{{Header: ltx.PageHeader{Pgno: 1}, Data: bytes.Repeat([]byte{1}, 512)}}, 30 | Trailer: ltx.Trailer{PostApplyChecksum: 0xe4e4aaa102377eee}, 31 | })); err != nil { 32 | t.Fatal(err) 33 | } else if got, want := hwm, ltx.TXID(1); got != want { 34 | t.Fatalf("hwm=%s, want %s", got, want) 35 | } 36 | 37 | if _, err := c.WriteTx(context.Background(), "db", ltxFileSpecReader(t, <x.FileSpec{ 38 | Header: ltx.Header{Version: 1, PageSize: 512, Commit: 2, MinTXID: 2, MaxTXID: 2, PreApplyChecksum: 0xe4e4aaa102377eee}, 39 | Pages: []ltx.PageSpec{{Header: ltx.PageHeader{Pgno: 2}, Data: bytes.Repeat([]byte{2}, 512)}}, 40 | Trailer: ltx.Trailer{PostApplyChecksum: 0x99b1d11ab98cc555}, 41 | })); err != nil { 42 | t.Fatal(err) 43 | } 44 | 45 | if _, err := c.WriteTx(context.Background(), "db", ltxFileSpecReader(t, <x.FileSpec{ 46 | Header: ltx.Header{Version: 1, PageSize: 512, Commit: 2, MinTXID: 3, MaxTXID: 4, PreApplyChecksum: 0x99b1d11ab98cc555}, 47 | Pages: []ltx.PageSpec{{Header: ltx.PageHeader{Pgno: 1}, Data: bytes.Repeat([]byte{3}, 512)}}, 48 | Trailer: ltx.Trailer{PostApplyChecksum: 0x8b87423eeeeeeeee}, 49 | })); err != nil { 50 | t.Fatal(err) 51 | } 52 | 53 | // Write to a different database. 54 | if _, err := c.WriteTx(context.Background(), "db2", ltxFileSpecReader(t, <x.FileSpec{ 55 | Header: ltx.Header{Version: 1, PageSize: 512, Commit: 1, MinTXID: 1, MaxTXID: 1}, 56 | Pages: []ltx.PageSpec{{Header: ltx.PageHeader{Pgno: 1}, Data: bytes.Repeat([]byte{5}, 512)}}, 57 | Trailer: ltx.Trailer{PostApplyChecksum: 0x99b1d11ab98cc555}, 58 | })); err != nil { 59 | t.Fatal(err) 60 | } 61 | 62 | // Read snapshot from backup service. 63 | var other ltx.FileSpec 64 | if rc, err := c.FetchSnapshot(context.Background(), "db"); err != nil { 65 | t.Fatal(err) 66 | } else if _, err := other.ReadFrom(rc); err != nil { 67 | t.Fatal(err) 68 | } else if err := rc.Close(); err != nil { 69 | t.Fatal(err) 70 | } 71 | 72 | // Verify contents of the snapshot. 73 | if got, want := &other, (<x.FileSpec{ 74 | Header: ltx.Header{Version: 1, Flags: ltx.HeaderFlagCompressLZ4, PageSize: 512, Commit: 2, MinTXID: 1, MaxTXID: 4}, 75 | Pages: []ltx.PageSpec{ 76 | {Header: ltx.PageHeader{Pgno: 1}, Data: bytes.Repeat([]byte{3}, 512)}, 77 | {Header: ltx.PageHeader{Pgno: 2}, Data: bytes.Repeat([]byte{2}, 512)}, 78 | }, 79 | Trailer: ltx.Trailer{ 80 | PostApplyChecksum: 0x8b87423eeeeeeeee, 81 | FileChecksum: 0xb8e6a652b0ec8453, 82 | }, 83 | }); !reflect.DeepEqual(got, want) { 84 | t.Fatalf("spec mismatch:\ngot: %#v\nwant: %#v", got, want) 85 | } 86 | }) 87 | 88 | t.Run("ErrPosMismatch/TXID", func(t *testing.T) { 89 | c := newOpenFileBackupClient(t) 90 | 91 | // Write the initial transaction. 92 | if _, err := c.WriteTx(context.Background(), "db", ltxFileSpecReader(t, <x.FileSpec{ 93 | Header: ltx.Header{Version: 1, PageSize: 512, Commit: 1, MinTXID: 1, MaxTXID: 1}, 94 | Pages: []ltx.PageSpec{{Header: ltx.PageHeader{Pgno: 1}, Data: bytes.Repeat([]byte{1}, 512)}}, 95 | Trailer: ltx.Trailer{PostApplyChecksum: 0xe4e4aaa102377eee}, 96 | })); err != nil { 97 | t.Fatal(err) 98 | } 99 | 100 | // Write a transaction that doesn't line up with the TXID. 101 | var pmErr *ltx.PosMismatchError 102 | if _, err := c.WriteTx(context.Background(), "db", ltxFileSpecReader(t, <x.FileSpec{ 103 | Header: ltx.Header{Version: 1, PageSize: 512, Commit: 2, MinTXID: 3, MaxTXID: 3, PreApplyChecksum: 0xe4e4aaa102377eee}, 104 | Pages: []ltx.PageSpec{{Header: ltx.PageHeader{Pgno: 2}, Data: bytes.Repeat([]byte{2}, 512)}}, 105 | Trailer: ltx.Trailer{PostApplyChecksum: ltx.ChecksumFlag | 2000}, 106 | })); !errors.As(err, &pmErr) { 107 | t.Fatalf("unexpected error: %s", err) 108 | } else if got, want := pmErr.Pos, (ltx.Pos{TXID: 1, PostApplyChecksum: 0xe4e4aaa102377eee}); !reflect.DeepEqual(got, want) { 109 | t.Fatalf("pos=%s, want %s", got, want) 110 | } 111 | }) 112 | 113 | t.Run("ErrPosMismatch/PostApplyChecksum", func(t *testing.T) { 114 | c := newOpenFileBackupClient(t) 115 | 116 | // Write the initial transaction. 117 | if _, err := c.WriteTx(context.Background(), "db", ltxFileSpecReader(t, <x.FileSpec{ 118 | Header: ltx.Header{Version: 1, PageSize: 512, Commit: 1, MinTXID: 1, MaxTXID: 1}, 119 | Pages: []ltx.PageSpec{{Header: ltx.PageHeader{Pgno: 1}, Data: bytes.Repeat([]byte{1}, 512)}}, 120 | Trailer: ltx.Trailer{PostApplyChecksum: 0xe4e4aaa102377eee}, 121 | })); err != nil { 122 | t.Fatal(err) 123 | } 124 | 125 | // Write a transaction that doesn't line up with the TXID. 126 | var pmErr *ltx.PosMismatchError 127 | if _, err := c.WriteTx(context.Background(), "db", ltxFileSpecReader(t, <x.FileSpec{ 128 | Header: ltx.Header{Version: 1, PageSize: 512, Commit: 2, MinTXID: 2, MaxTXID: 2, PreApplyChecksum: ltx.ChecksumFlag | 2000}, 129 | Pages: []ltx.PageSpec{{Header: ltx.PageHeader{Pgno: 2}, Data: bytes.Repeat([]byte{2}, 512)}}, 130 | Trailer: ltx.Trailer{PostApplyChecksum: ltx.ChecksumFlag | 2000}, 131 | })); !errors.As(err, &pmErr) { 132 | t.Fatalf("unexpected error: %s", err) 133 | } else if got, want := pmErr.Pos, (ltx.Pos{TXID: 1, PostApplyChecksum: 0xe4e4aaa102377eee}); !reflect.DeepEqual(got, want) { 134 | t.Fatalf("pos=%s, want %s", got, want) 135 | } 136 | }) 137 | 138 | t.Run("ErrPosMismatch/FirstTx", func(t *testing.T) { 139 | c := newOpenFileBackupClient(t) 140 | 141 | var pmErr *ltx.PosMismatchError 142 | if _, err := c.WriteTx(context.Background(), "db", ltxFileSpecReader(t, <x.FileSpec{ 143 | Header: ltx.Header{Version: 1, PageSize: 512, Commit: 1, MinTXID: 2, MaxTXID: 2, PreApplyChecksum: ltx.ChecksumFlag | 2000}, 144 | Pages: []ltx.PageSpec{{Header: ltx.PageHeader{Pgno: 1}, Data: bytes.Repeat([]byte{1}, 512)}}, 145 | Trailer: ltx.Trailer{PostApplyChecksum: ltx.ChecksumFlag | 1000}, 146 | })); !errors.As(err, &pmErr) { 147 | t.Fatalf("unexpected error: %s", err) 148 | } else if got, want := pmErr.Pos, (ltx.Pos{}); !reflect.DeepEqual(got, want) { 149 | t.Fatalf("pos=%s, want %s", got, want) 150 | } 151 | }) 152 | } 153 | 154 | func TestFileBackupClient_PosMap(t *testing.T) { 155 | t.Run("OK", func(t *testing.T) { 156 | c := newOpenFileBackupClient(t) 157 | 158 | if _, err := c.WriteTx(context.Background(), "db1", ltxFileSpecReader(t, <x.FileSpec{ 159 | Header: ltx.Header{Version: 1, PageSize: 512, Commit: 1, MinTXID: 1, MaxTXID: 1}, 160 | Pages: []ltx.PageSpec{{Header: ltx.PageHeader{Pgno: 1}, Data: bytes.Repeat([]byte{1}, 512)}}, 161 | Trailer: ltx.Trailer{PostApplyChecksum: 0xe4e4aaa102377eee}, 162 | })); err != nil { 163 | t.Fatal(err) 164 | } 165 | 166 | if _, err := c.WriteTx(context.Background(), "db1", ltxFileSpecReader(t, <x.FileSpec{ 167 | Header: ltx.Header{Version: 1, PageSize: 512, Commit: 2, MinTXID: 2, MaxTXID: 2, PreApplyChecksum: 0xe4e4aaa102377eee}, 168 | Pages: []ltx.PageSpec{{Header: ltx.PageHeader{Pgno: 2}, Data: bytes.Repeat([]byte{2}, 512)}}, 169 | Trailer: ltx.Trailer{PostApplyChecksum: ltx.ChecksumFlag | 2000}, 170 | })); err != nil { 171 | t.Fatal(err) 172 | } 173 | 174 | // Write to a different database. 175 | if _, err := c.WriteTx(context.Background(), "db2", ltxFileSpecReader(t, <x.FileSpec{ 176 | Header: ltx.Header{Version: 1, PageSize: 512, Commit: 1, MinTXID: 1, MaxTXID: 1}, 177 | Pages: []ltx.PageSpec{{Header: ltx.PageHeader{Pgno: 1}, Data: bytes.Repeat([]byte{5}, 512)}}, 178 | Trailer: ltx.Trailer{PostApplyChecksum: 0x99b1d11ab98cc555}, 179 | })); err != nil { 180 | t.Fatal(err) 181 | } 182 | 183 | // Read snapshot from backup service. 184 | if m, err := c.PosMap(context.Background()); err != nil { 185 | t.Fatal(err) 186 | } else if got, want := m, map[string]ltx.Pos{ 187 | "db1": {TXID: 0x2, PostApplyChecksum: 0x80000000000007d0}, 188 | "db2": {TXID: 0x1, PostApplyChecksum: 0x99b1d11ab98cc555}, 189 | }; !reflect.DeepEqual(got, want) { 190 | t.Fatalf("map=%#v, want %#v", got, want) 191 | } 192 | }) 193 | 194 | t.Run("NoDatabases", func(t *testing.T) { 195 | c := newOpenFileBackupClient(t) 196 | if m, err := c.PosMap(context.Background()); err != nil { 197 | t.Fatal(err) 198 | } else if got, want := m, map[string]ltx.Pos{}; !reflect.DeepEqual(got, want) { 199 | t.Fatalf("map=%#v, want %#v", got, want) 200 | } 201 | }) 202 | } 203 | 204 | func newOpenFileBackupClient(tb testing.TB) *litefs.FileBackupClient { 205 | tb.Helper() 206 | c := litefs.NewFileBackupClient(tb.TempDir()) 207 | if err := c.Open(); err != nil { 208 | tb.Fatal(err) 209 | } 210 | return c 211 | } 212 | 213 | // ltxFileSpecReader returns a spec as an io.Reader of its serialized bytes. 214 | func ltxFileSpecReader(tb testing.TB, spec *ltx.FileSpec) io.Reader { 215 | tb.Helper() 216 | var buf bytes.Buffer 217 | if _, err := spec.WriteTo(&buf); err != nil { 218 | tb.Fatal(err) 219 | } 220 | return bytes.NewReader(buf.Bytes()) 221 | } 222 | -------------------------------------------------------------------------------- /client_test.go: -------------------------------------------------------------------------------- 1 | package litefs_test 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "reflect" 7 | "testing" 8 | 9 | "github.com/superfly/litefs" 10 | ) 11 | 12 | func TestReadWriteStreamFrame(t *testing.T) { 13 | t.Run("LTXStreamFrame", func(t *testing.T) { 14 | frame := &litefs.LTXStreamFrame{Size: 100, Name: "test.db"} 15 | 16 | var buf bytes.Buffer 17 | if err := litefs.WriteStreamFrame(&buf, frame); err != nil { 18 | t.Fatal(err) 19 | } 20 | if other, err := litefs.ReadStreamFrame(&buf); err != nil { 21 | t.Fatal(err) 22 | } else if !reflect.DeepEqual(frame, other) { 23 | t.Fatalf("got %#v, want %#v", frame, other) 24 | } 25 | }) 26 | t.Run("ReadyStreamFrame", func(t *testing.T) { 27 | frame := &litefs.ReadyStreamFrame{} 28 | 29 | var buf bytes.Buffer 30 | if err := litefs.WriteStreamFrame(&buf, frame); err != nil { 31 | t.Fatal(err) 32 | } 33 | if other, err := litefs.ReadStreamFrame(&buf); err != nil { 34 | t.Fatal(err) 35 | } else if !reflect.DeepEqual(frame, other) { 36 | t.Fatalf("got %#v, want %#v", frame, other) 37 | } 38 | }) 39 | 40 | t.Run("ErrEOF", func(t *testing.T) { 41 | if _, err := litefs.ReadStreamFrame(bytes.NewReader(nil)); err == nil || err != io.EOF { 42 | t.Fatalf("unexpected error: %#v", err) 43 | } 44 | }) 45 | t.Run("ErrStreamTypeOnly", func(t *testing.T) { 46 | if _, err := litefs.ReadStreamFrame(bytes.NewReader([]byte{0, 0, 0, 1})); err == nil || err != io.ErrUnexpectedEOF { 47 | t.Fatalf("unexpected error: %#v", err) 48 | } 49 | }) 50 | t.Run("ErrInvalidStreamType", func(t *testing.T) { 51 | if _, err := litefs.ReadStreamFrame(bytes.NewReader([]byte{1, 2, 3, 4})); err == nil || err.Error() != `invalid stream frame type: 0x1020304` { 52 | t.Fatalf("unexpected error: %#v", err) 53 | } 54 | }) 55 | t.Run("ErrPartialPayload", func(t *testing.T) { 56 | if _, err := litefs.ReadStreamFrame(bytes.NewReader([]byte{0, 0, 0, 1, 1, 2})); err == nil || err != io.ErrUnexpectedEOF { 57 | t.Fatalf("unexpected error: %#v", err) 58 | } 59 | }) 60 | t.Run("ErrWriteType", func(t *testing.T) { 61 | if err := litefs.WriteStreamFrame(&errWriter{}, &litefs.LTXStreamFrame{}); err == nil || err.Error() != `write error occurred` { 62 | t.Fatalf("unexpected error: %#v", err) 63 | } 64 | }) 65 | } 66 | 67 | func TestLTXStreamFrame_ReadFrom(t *testing.T) { 68 | t.Run("ErrUnexpectedEOF", func(t *testing.T) { 69 | frame := &litefs.LTXStreamFrame{Name: "test.db"} 70 | var buf bytes.Buffer 71 | if _, err := frame.WriteTo(&buf); err != nil { 72 | t.Fatal(err) 73 | } 74 | for i := 0; i < buf.Len(); i++ { 75 | var other litefs.LTXStreamFrame 76 | if _, err := other.ReadFrom(bytes.NewReader(buf.Bytes()[:i])); err != io.ErrUnexpectedEOF { 77 | t.Fatalf("expected error at %d bytes: %s", i, err) 78 | } 79 | } 80 | }) 81 | } 82 | 83 | func TestLTXStreamFrame_WriteTo(t *testing.T) { 84 | t.Run("ErrUnexpectedEOF", func(t *testing.T) { 85 | frame := &litefs.LTXStreamFrame{Name: "test.db"} 86 | var buf bytes.Buffer 87 | if _, err := frame.WriteTo(&buf); err != nil { 88 | t.Fatal(err) 89 | } 90 | 91 | for i := 0; i < buf.Len(); i++ { 92 | if _, err := frame.WriteTo(&errWriter{afterN: i}); err == nil || err.Error() != `write error occurred` { 93 | t.Fatalf("expected error at %d bytes: %s", i, err) 94 | } 95 | } 96 | }) 97 | } 98 | 99 | func TestReadyStreamFrame_ReadFrom(t *testing.T) { 100 | t.Run("ErrUnexpectedEOF", func(t *testing.T) { 101 | frame := &litefs.ReadyStreamFrame{} 102 | var buf bytes.Buffer 103 | if _, err := frame.WriteTo(&buf); err != nil { 104 | t.Fatal(err) 105 | } 106 | for i := 1; i < buf.Len(); i++ { 107 | var other litefs.ReadyStreamFrame 108 | if _, err := other.ReadFrom(bytes.NewReader(buf.Bytes()[:i])); err != io.ErrUnexpectedEOF { 109 | t.Fatalf("expected error at %d bytes: %s", i, err) 110 | } 111 | } 112 | }) 113 | } 114 | 115 | func TestReadyStreamFrame_WriteTo(t *testing.T) { 116 | t.Run("ErrUnexpectedEOF", func(t *testing.T) { 117 | frame := &litefs.ReadyStreamFrame{} 118 | var buf bytes.Buffer 119 | if _, err := frame.WriteTo(&buf); err != nil { 120 | t.Fatal(err) 121 | } 122 | 123 | for i := 0; i < buf.Len(); i++ { 124 | if _, err := frame.WriteTo(&errWriter{afterN: i}); err == nil || err.Error() != `write error occurred` { 125 | t.Fatalf("expected error at %d bytes: %s", i, err) 126 | } 127 | } 128 | }) 129 | } 130 | 131 | func TestHWMStreamFrame_ReadFrom(t *testing.T) { 132 | t.Run("ErrUnexpectedEOF", func(t *testing.T) { 133 | frame := &litefs.HWMStreamFrame{TXID: 1234, Name: "test.db"} 134 | var buf bytes.Buffer 135 | if _, err := frame.WriteTo(&buf); err != nil { 136 | t.Fatal(err) 137 | } 138 | for i := 0; i < buf.Len(); i++ { 139 | var other litefs.HWMStreamFrame 140 | if _, err := other.ReadFrom(bytes.NewReader(buf.Bytes()[:i])); err != io.ErrUnexpectedEOF { 141 | t.Fatalf("expected error at %d bytes: %s", i, err) 142 | } 143 | } 144 | }) 145 | } 146 | 147 | func TestHWMStreamFrame_WriteTo(t *testing.T) { 148 | t.Run("ErrUnexpectedEOF", func(t *testing.T) { 149 | frame := &litefs.HWMStreamFrame{TXID: 1234, Name: "test.db"} 150 | var buf bytes.Buffer 151 | if _, err := frame.WriteTo(&buf); err != nil { 152 | t.Fatal(err) 153 | } 154 | 155 | for i := 0; i < buf.Len(); i++ { 156 | if _, err := frame.WriteTo(&errWriter{afterN: i}); err == nil || err.Error() != `write error occurred` { 157 | t.Fatalf("expected error at %d bytes: %s", i, err) 158 | } 159 | } 160 | }) 161 | } 162 | -------------------------------------------------------------------------------- /cmd/litefs-bench/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "flag" 7 | "fmt" 8 | "log" 9 | "math/rand" 10 | "os" 11 | "sync" 12 | "time" 13 | 14 | _ "github.com/mattn/go-sqlite3" 15 | litefsgo "github.com/superfly/litefs-go" 16 | ) 17 | 18 | var dsn string 19 | 20 | var ( 21 | mode = flag.String("mode", "", "benchmark mode") 22 | journalMode = flag.String("journal-mode", "", "journal mode") 23 | seed = flag.Int64("seed", 0, "prng seed") 24 | cacheSize = flag.Int("cache-size", -2000, "SQLite cache size") 25 | iter = flag.Int("iter", 0, "number of iterations") 26 | maxRowSize = flag.Int("max-row-size", 256, "maximum row size") 27 | maxRowsPerIter = flag.Int("max-rows-per-iter", 10, "maximum number of rows per iteration") 28 | iterPerSec = flag.Float64("iter-per-sec", 0, "iterations per second") 29 | ) 30 | 31 | func main() { 32 | flag.Usage = Usage 33 | 34 | if err := run(context.Background()); err == flag.ErrHelp { 35 | os.Exit(1) 36 | } else if err != nil { 37 | fmt.Fprintf(os.Stderr, "ERROR: %s\n", err) 38 | os.Exit(1) 39 | } 40 | } 41 | 42 | func run(ctx context.Context) error { 43 | flag.Parse() 44 | if flag.NArg() == 0 { 45 | flag.Usage() 46 | return flag.ErrHelp 47 | } else if flag.NArg() > 1 { 48 | return fmt.Errorf("too many arguments") 49 | } else if *mode == "" { 50 | return fmt.Errorf("required: -mode MODE") 51 | } 52 | 53 | // Initialize PRNG. 54 | if *seed == 0 { 55 | *seed = time.Now().UnixNano() 56 | } 57 | fmt.Printf("running litefs-bench: seed=%d\n", seed) 58 | 59 | // Open database. 60 | dsn = flag.Arg(0) 61 | db, err := sql.Open("sqlite3", dsn) 62 | if err != nil { 63 | return err 64 | } 65 | defer func() { _ = db.Close() }() 66 | 67 | if _, err := db.Exec(`PRAGMA busy_timeout = 5000`); err != nil { 68 | return fmt.Errorf("set busy timeout: %w", err) 69 | } 70 | 71 | // Initialize cache. 72 | if _, err := db.Exec(fmt.Sprintf(`PRAGMA cache_size = %d`, cacheSize)); err != nil { 73 | return fmt.Errorf("set cache size to %d: %w", *cacheSize, err) 74 | } 75 | 76 | // Set journal mode, if set. Otherwise defaults to "DELETE" for new databases. 77 | if *journalMode != "" { 78 | log.Printf("setting journal mode to %q", *journalMode) 79 | if _, err := db.Exec(`PRAGMA journal_mode = ` + *journalMode); err != nil { 80 | return fmt.Errorf("set journal mode: %w", err) 81 | } 82 | } 83 | 84 | // Run migrations, if necessary. 85 | if err := migrate(ctx, db); err != nil { 86 | return fmt.Errorf("migrate: %w", err) 87 | } 88 | 89 | // Begin monitoring stats. 90 | go monitor(ctx) 91 | 92 | // Enforce rate limit. 93 | rate := time.Nanosecond 94 | if *iterPerSec > 0 { 95 | rate = time.Duration(float64(time.Second) / *iterPerSec) 96 | } 97 | ticker := time.NewTicker(rate) 98 | defer ticker.Stop() 99 | 100 | // Execute once for each iteration. 101 | for i := 0; *iter == 0 || i < *iter; i++ { 102 | rand := rand.New(rand.NewSource(*seed + int64(i))) 103 | 104 | select { 105 | case <-ctx.Done(): 106 | return context.Cause(ctx) 107 | 108 | case <-ticker.C: 109 | var err error 110 | switch *mode { 111 | case "insert": 112 | err = runInsertIter(ctx, db, rand) 113 | case "query": 114 | err = runQueryIter(ctx, db, rand) 115 | default: 116 | return fmt.Errorf("invalid bench mode: %q", *mode) 117 | } 118 | if err != nil { 119 | return fmt.Errorf("iter %d: %w", i, err) 120 | } 121 | } 122 | } 123 | 124 | if err := db.Close(); err != nil { 125 | return err 126 | } 127 | 128 | return nil 129 | } 130 | 131 | func migrate(ctx context.Context, db *sql.DB) error { 132 | if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS t (id INTEGER PRIMARY KEY, num INTEGER, data TEXT)`); err != nil { 133 | return fmt.Errorf("create table: %w", err) 134 | } 135 | if _, err := db.Exec(`CREATE INDEX IF NOT EXISTS idx ON t (num)`); err != nil { 136 | return fmt.Errorf("create index: %w", err) 137 | } 138 | return nil 139 | } 140 | 141 | // runInsertIter runs a single "insert" iteration. 142 | func runInsertIter(ctx context.Context, db *sql.DB, rand *rand.Rand) error { 143 | buf := make([]byte, *maxRowSize) 144 | 145 | return litefsgo.WithHalt(dsn, func() error { 146 | tx, err := db.Begin() 147 | if err != nil { 148 | return fmt.Errorf("begin: %w", err) 149 | } 150 | defer func() { _ = tx.Rollback() }() 151 | 152 | rowN := rand.Intn(*maxRowsPerIter) + 1 153 | for i := 0; i < rowN; i++ { 154 | _, _ = rand.Read(buf) 155 | num := rand.Int63() 156 | rowSize := rand.Intn(*maxRowSize) 157 | data := fmt.Sprintf("%x", buf)[:rowSize] 158 | 159 | if _, err := tx.Exec(`INSERT INTO t (num, data) VALUES (?, ?)`, num, data); err != nil { 160 | return fmt.Errorf("insert(%d): %w", i, err) 161 | } 162 | } 163 | 164 | if err := tx.Commit(); err != nil { 165 | return fmt.Errorf("commit: %w", err) 166 | } 167 | 168 | // Update stats on success. 169 | statsMu.Lock() 170 | defer statsMu.Unlock() 171 | stats.TxN++ 172 | stats.RowN += rowN 173 | 174 | // Vacuum periodically. 175 | if rand.Intn(100) == 0 { 176 | if _, err := db.Exec(`VACUUM`); err != nil { 177 | return fmt.Errorf("vacuum: %w", err) 178 | } 179 | } 180 | 181 | // Truncate periodically. 182 | if rand.Intn(10) == 0 { 183 | if _, err := db.Exec(`PRAGMA wal_checkpoint(TRUNCATE)`); err != nil { 184 | return fmt.Errorf("truncate: %w", err) 185 | } 186 | } 187 | return err 188 | }) 189 | } 190 | 191 | // runQueryIter runs a single "query" iteration. 192 | func runQueryIter(ctx context.Context, db *sql.DB, rand *rand.Rand) error { 193 | tx, err := db.Begin() 194 | if err != nil { 195 | return fmt.Errorf("begin: %w", err) 196 | } 197 | defer func() { _ = tx.Rollback() }() 198 | 199 | // Determine highest possible ID. 200 | var maxID int 201 | if err := tx.QueryRow(`SELECT MAX(id) FROM t`).Scan(&maxID); err == sql.ErrNoRows { 202 | log.Printf("no rows available, skipping") 203 | return nil 204 | } else if err != nil { 205 | return fmt.Errorf("query max id: %w", err) 206 | } 207 | 208 | // Read data starting from a random row. 209 | rows, err := tx.Query(`SELECT id, num, data FROM t WHERE id >= ?`, maxID) 210 | if err != nil { 211 | return fmt.Errorf("query: %w", err) 212 | } 213 | defer func() { _ = rows.Close() }() 214 | 215 | rowN := rand.Intn(*maxRowsPerIter) + 1 216 | for i := 0; rows.Next() && i < rowN; i++ { 217 | var id, num int 218 | var data []byte 219 | if err := rows.Scan(&id, &num, &data); err != nil { 220 | return fmt.Errorf("scan: %w", err) 221 | } 222 | } 223 | if err := rows.Close(); err != nil { 224 | return fmt.Errorf("close rows: %w", err) 225 | } 226 | 227 | if err := tx.Rollback(); err != nil { 228 | return fmt.Errorf("rollback: %w", err) 229 | } 230 | 231 | // Update stats on success. 232 | statsMu.Lock() 233 | defer statsMu.Unlock() 234 | stats.TxN++ 235 | stats.RowN += rowN 236 | 237 | return nil 238 | } 239 | 240 | // monitor periodically prints stats. 241 | func monitor(ctx context.Context) { 242 | ticker := time.NewTicker(1 * time.Second) 243 | defer ticker.Stop() 244 | 245 | prevTime := time.Now() 246 | var prev Stats 247 | for { 248 | select { 249 | case <-ctx.Done(): 250 | return 251 | case <-ticker.C: 252 | statsMu.Lock() 253 | curr := stats 254 | statsMu.Unlock() 255 | 256 | currTime := time.Now() 257 | elapsed := currTime.Sub(prevTime).Seconds() 258 | 259 | log.Printf("stats: tx/sec=%0.03f rows/sec=%0.03f", 260 | float64(curr.TxN-prev.TxN)/elapsed, 261 | float64(curr.RowN-prev.RowN)/elapsed, 262 | ) 263 | 264 | prev, prevTime = curr, currTime 265 | } 266 | } 267 | } 268 | 269 | var statsMu sync.Mutex 270 | var stats Stats 271 | 272 | type Stats struct { 273 | TxN int 274 | RowN int 275 | } 276 | 277 | func Usage() { 278 | fmt.Printf(` 279 | litefs-bench is a tool for simulating load against a SQLite database. 280 | 281 | Usage: 282 | 283 | litefs-bench MODE [arguments] DSN 284 | 285 | Modes: 286 | 287 | insert continuous INSERTs into a single indexed table 288 | query continuous short SELECT queries against a table 289 | 290 | Arguments: 291 | 292 | `[1:]) 293 | flag.PrintDefaults() 294 | fmt.Println() 295 | } 296 | -------------------------------------------------------------------------------- /cmd/litefs/config_test.go: -------------------------------------------------------------------------------- 1 | package main_test 2 | 3 | import ( 4 | "bytes" 5 | "strings" 6 | "testing" 7 | 8 | main "github.com/superfly/litefs/cmd/litefs" 9 | "gopkg.in/yaml.v3" 10 | ) 11 | 12 | func TestConfig(t *testing.T) { 13 | t.Run("EmptyExec", func(t *testing.T) { 14 | var config main.Config 15 | dec := yaml.NewDecoder(strings.NewReader("exec:\n")) 16 | dec.KnownFields(true) 17 | if err := dec.Decode(&config); err != nil { 18 | t.Fatal(err) 19 | } 20 | 21 | if got, want := len(config.Exec), 0; got != want { 22 | t.Fatalf("len=%v, want %v", got, want) 23 | } 24 | }) 25 | 26 | t.Run("InlineExec", func(t *testing.T) { 27 | var config main.Config 28 | dec := yaml.NewDecoder(strings.NewReader(`exec: "run me"`)) 29 | dec.KnownFields(true) 30 | if err := dec.Decode(&config); err != nil { 31 | t.Fatal(err) 32 | } 33 | 34 | if got, want := config.Exec[0].Cmd, "run me"; got != want { 35 | t.Fatalf("Cmd=%q, want %q", got, want) 36 | } 37 | }) 38 | 39 | t.Run("SingleExec", func(t *testing.T) { 40 | buf, err := testdata.ReadFile("testdata/config/single_exec.yml") 41 | if err != nil { 42 | t.Fatal(err) 43 | } 44 | 45 | var config main.Config 46 | dec := yaml.NewDecoder(bytes.NewReader(buf)) 47 | dec.KnownFields(true) 48 | if err := dec.Decode(&config); err != nil { 49 | t.Fatal(err) 50 | } 51 | 52 | if got, want := config.Exec[0].Cmd, "run me"; got != want { 53 | t.Fatalf("Cmd=%q, want %q", got, want) 54 | } 55 | }) 56 | 57 | t.Run("MultiExec", func(t *testing.T) { 58 | buf, err := testdata.ReadFile("testdata/config/multi_exec.yml") 59 | if err != nil { 60 | t.Fatal(err) 61 | } 62 | 63 | var config main.Config 64 | dec := yaml.NewDecoder(bytes.NewReader(buf)) 65 | dec.KnownFields(true) 66 | if err := dec.Decode(&config); err != nil { 67 | t.Fatal(err) 68 | } 69 | 70 | if got, want := config.Exec[0].Cmd, "run me"; got != want { 71 | t.Fatalf("Cmd=%q, want %q", got, want) 72 | } else if got, want := config.Exec[0].IfCandidate, true; got != want { 73 | t.Fatalf("IfCandidate=%v, want %v", got, want) 74 | } 75 | if got, want := config.Exec[1].Cmd, "run me too"; got != want { 76 | t.Fatalf("Cmd=%q, want %q", got, want) 77 | } 78 | }) 79 | } 80 | -------------------------------------------------------------------------------- /cmd/litefs/etc/litefs.yml: -------------------------------------------------------------------------------- 1 | # The FUSE section handles settings on the FUSE file system. FUSE 2 | # provides a layer for intercepting SQLite transactions on the 3 | # primary node so they can be shipped to replica nodes transparently. 4 | fuse: 5 | # Required. This is the mount directory that applications will 6 | # use to access their SQLite databases. 7 | dir: "/litefs" 8 | 9 | # Set this flag to true to allow non-root users to access mount. 10 | # You must set the "user_allow_other" option in /etc/fuse.conf first. 11 | allow-other: false 12 | 13 | # The debug flag enables debug logging of all FUSE API calls. 14 | # This will produce a lot of logging. Not for general use. 15 | debug: false 16 | 17 | # The data section specifies where internal LiteFS data is stored 18 | # and how long to retain the transaction files. 19 | # 20 | # Transaction files are used to ship changes to replica nodes so 21 | # they should persist long enough for replicas to retrieve them, 22 | # even in the face of a short network interruption or a redeploy. 23 | # Under high load, these files can grow large so it's not advised 24 | # to extend retention too long. 25 | data: 26 | # Path to internal data storage. 27 | dir: "/var/lib/litefs" 28 | 29 | # Duration to keep LTX files. Latest LTX file is always kept. 30 | retention: "10m" 31 | 32 | # Frequency with which to check for LTX files to delete. 33 | retention-monitor-interval: "1m" 34 | 35 | # The exec field specifies a command to run as a subprocess of 36 | # LiteFS. This command will be executed after LiteFS either 37 | # becomes primary or is connected to the primary node. LiteFS 38 | # will forward signals to the subprocess and LiteFS will 39 | # automatically shut itself down when the subprocess stops. 40 | # 41 | # This can also be specified after a double-dash (--) on the 42 | # command line invocation of the 'litefs mount' command. 43 | exec: "myapp -addr :8081" 44 | 45 | # If true, then LiteFS will not wait until the node becomes the 46 | # primary or connects to the primary before starting the subprocess. 47 | skip-sync: false 48 | 49 | # If true, then LiteFS will not exit if there is a validation 50 | # issue on startup. This can be useful for debugging issues as 51 | # it avoids constantly restarting the node on ephemeral hosting. 52 | exit-on-error: false 53 | 54 | # This section defines settings for the LiteFS HTTP API server. 55 | # This API server is how nodes communicate with each other. 56 | http: 57 | # Specifies the bind address of the HTTP API server. 58 | addr: ":20202" 59 | 60 | # This section defines settings for the option HTTP proxy. 61 | # This proxy can handle primary forwarding & replica consistency 62 | # for applications that use a single SQLite database. 63 | proxy: 64 | # Specifies the bind address of the proxy server. 65 | addr: ":8080" 66 | 67 | # The hostport of the target application. 68 | target: "localhost:8081" 69 | 70 | # The name of the database used for TXID tracking. 71 | db: "my.db" 72 | 73 | # If true, enables verbose logging of requests by the proxy. 74 | debug: false 75 | 76 | # List of paths that are ignored by the proxy. The asterisk is 77 | # the only available wildcard. These requests are passed 78 | # through to the target as-is. 79 | passthrough: ["/debug/*", "*.png"] 80 | 81 | # The lease section defines how LiteFS creates a cluster and 82 | # implements leader election. For dynamic clusters, use the 83 | # "consul". This allows the primary to change automatically when 84 | # the current primary goes down. For a simpler setup, use 85 | # "static" which assigns a single node to be the primary and does 86 | # not failover. 87 | lease: 88 | # Required. Must be either "consul" or "static". 89 | type: "consul" 90 | 91 | # Required. The URL for this node's LiteFS API. 92 | # Should match HTTP port. 93 | advertise-url: "http://myhost:20202" 94 | 95 | # Sets the hostname that other nodes will use to reference this 96 | # node. Automatically assigned based on hostname(1) if not set. 97 | hostname: "myhost" 98 | 99 | # Specifies whether the node can become the primary. If using 100 | # "static" leasing, this should be set to true on the primary 101 | # and false on the replicas. 102 | candidate: true 103 | 104 | # A Consul server provides leader election and ensures that the 105 | # responsibility of the primary node can be moved in the event 106 | # of a deployment or a failure. 107 | consul: 108 | # Required. The base URL of the Consul server. 109 | url: "http://myhost:8500" 110 | 111 | # Required. The key used for obtaining a lease by the primary. 112 | # This must be unique for each cluster of LiteFS servers 113 | key: "litefs/primary" 114 | 115 | # Length of time before a lease expires. The primary will 116 | # automatically renew the lease while it is alive, however, 117 | # if it fails to renew in time then a new primary may be 118 | # elected after the TTL. This only occurs for unexpected loss 119 | # of the leader as normal operation will allow the leader to 120 | # handoff the lease to another replica without downtime. 121 | # 122 | # Consul does not allow a TTL of less than 10 seconds. 123 | ttl: "10s" 124 | 125 | # Length of time after the lease expires before a candidate 126 | # can become leader. This buffer is intended to prevent 127 | # overlap in leadership due to clock skew or in-flight calls. 128 | lock-delay: "1s" 129 | 130 | # The tracing section enables a rolling, on-disk tracing log. 131 | # This records every operation to the database so it can be 132 | # verbose and it can degrade performance. This is for debugging 133 | # only and should not typically be enabled in production. 134 | tracing: 135 | # Output path on disk. 136 | path: "/var/log/lifefs/trace.log" 137 | 138 | # Maximum size of a single trace log before rolling. 139 | # Specified in megabytes. 140 | max-size: 64 141 | 142 | # Maximum number of trace logs to retain. 143 | max-count: 10 144 | 145 | # If true, historical logs will be compressed using gzip. 146 | compress: true 147 | -------------------------------------------------------------------------------- /cmd/litefs/export.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "fmt" 7 | "io" 8 | "os" 9 | "path/filepath" 10 | "time" 11 | 12 | "github.com/superfly/litefs/http" 13 | "github.com/superfly/litefs/internal" 14 | ) 15 | 16 | // ExportCommand represents a command to export a database from the cluster. 17 | type ExportCommand struct { 18 | // Target LiteFS URL 19 | URL string 20 | 21 | // Name of database on LiteFS cluster. 22 | Name string 23 | 24 | // Path to export the database to. 25 | Path string 26 | } 27 | 28 | // NewExportCommand returns a new instance of ExportCommand. 29 | func NewExportCommand() *ExportCommand { 30 | return &ExportCommand{ 31 | URL: DefaultURL, 32 | } 33 | } 34 | 35 | // ParseFlags parses the command line flags & config file. 36 | func (c *ExportCommand) ParseFlags(ctx context.Context, args []string) (err error) { 37 | fs := flag.NewFlagSet("litefs-export", flag.ContinueOnError) 38 | fs.StringVar(&c.URL, "url", "http://localhost:20202", "LiteFS API URL") 39 | fs.StringVar(&c.Name, "name", "", "database name") 40 | fs.Usage = func() { 41 | fmt.Println(` 42 | The export command will download a SQLite database from a LiteFS cluster. If the 43 | database doesn't exist then an error will be returned. 44 | 45 | Usage: 46 | 47 | litefs export [arguments] PATH 48 | 49 | Arguments: 50 | `[1:]) 51 | fs.PrintDefaults() 52 | fmt.Println("") 53 | } 54 | if err := fs.Parse(args); err != nil { 55 | return err 56 | } else if fs.NArg() == 0 { 57 | fs.Usage() 58 | return flag.ErrHelp 59 | } else if fs.NArg() > 1 { 60 | return fmt.Errorf("too many arguments") 61 | } 62 | 63 | // Copy first arg as database path. 64 | c.Path = fs.Arg(0) 65 | 66 | return nil 67 | } 68 | 69 | // Run executes the command. 70 | func (c *ExportCommand) Run(ctx context.Context) (err error) { 71 | // Clear existing database and related files. 72 | for _, suffix := range []string{"", "-journal", "-wal", "-shm"} { 73 | if err := os.Remove(c.Path + suffix); err != nil && !os.IsNotExist(err) { 74 | return err 75 | } 76 | } 77 | 78 | tmpPath := c.Path + ".tmp" 79 | defer func() { _ = os.Remove(tmpPath) }() 80 | 81 | f, err := os.Create(tmpPath) 82 | if err != nil { 83 | return err 84 | } 85 | defer func() { _ = f.Close() }() 86 | 87 | t := time.Now() 88 | 89 | // Fetch snapshot from the server. 90 | client := http.NewClient() 91 | r, err := client.Export(ctx, c.URL, c.Name) 92 | if err != nil { 93 | return err 94 | } 95 | defer func() { _ = r.Close() }() 96 | 97 | // Copy bytes to temp file. 98 | if _, err := io.Copy(f, r); err != nil { 99 | return err 100 | } else if err := r.Close(); err != nil { 101 | return err 102 | } 103 | 104 | // Sync & close file. 105 | if err := f.Sync(); err != nil { 106 | return err 107 | } else if err := f.Close(); err != nil { 108 | return err 109 | } 110 | 111 | // Atomically rename & sync parent directory. 112 | if err := os.Rename(tmpPath, c.Path); err != nil { 113 | return err 114 | } else if err := internal.Sync(filepath.Dir(c.Path)); err != nil { 115 | return err 116 | } 117 | 118 | // Notify user of success and elapsed time. 119 | fmt.Printf("Export of database %q in %s\n", c.Name, time.Since(t)) 120 | 121 | return nil 122 | } 123 | -------------------------------------------------------------------------------- /cmd/litefs/export_test.go: -------------------------------------------------------------------------------- 1 | //go:build linux 2 | 3 | package main_test 4 | 5 | import ( 6 | "context" 7 | "path/filepath" 8 | "testing" 9 | 10 | "github.com/superfly/litefs" 11 | main "github.com/superfly/litefs/cmd/litefs" 12 | "github.com/superfly/litefs/internal/testingutil" 13 | ) 14 | 15 | // Ensure a database can be exported from a LiteFS server. 16 | func TestExportCommand(t *testing.T) { 17 | t.Run("OK", func(t *testing.T) { 18 | m0 := runMountCommand(t, newMountCommand(t, t.TempDir(), nil)) 19 | waitForPrimary(t, m0) 20 | 21 | // Create database on LiteFS. 22 | db := testingutil.OpenSQLDB(t, filepath.Join(m0.Config.FUSE.Dir, "my.db")) 23 | if _, err := db.Exec(`CREATE TABLE t (x)`); err != nil { 24 | t.Fatal(err) 25 | } else if _, err := db.Exec(`INSERT INTO t VALUES (100)`); err != nil { 26 | t.Fatal(err) 27 | } else if err := db.Close(); err != nil { 28 | t.Fatal(err) 29 | } 30 | 31 | // Export database from LiteFS. 32 | dsn := filepath.Join(t.TempDir(), "db") 33 | cmd := main.NewExportCommand() 34 | cmd.URL = m0.HTTPServer.URL() 35 | cmd.Name = "my.db" 36 | cmd.Path = dsn 37 | if err := cmd.Run(context.Background()); err != nil { 38 | t.Fatal(err) 39 | } 40 | 41 | // Read from exported database. 42 | db = testingutil.OpenSQLDB(t, dsn) 43 | var x int 44 | if err := db.QueryRow(`SELECT x FROM t`).Scan(&x); err != nil { 45 | t.Fatal(err) 46 | } else if got, want := x, 100; got != want { 47 | t.Fatalf("x=%d, want %d", got, want) 48 | } 49 | }) 50 | 51 | t.Run("ErrDatabaseNotFound", func(t *testing.T) { 52 | m0 := runMountCommand(t, newMountCommand(t, t.TempDir(), nil)) 53 | waitForPrimary(t, m0) 54 | 55 | // Export missing database from LiteFS. 56 | cmd := main.NewExportCommand() 57 | cmd.URL = m0.HTTPServer.URL() 58 | cmd.Name = "nosuchdatabase" 59 | cmd.Path = filepath.Join(t.TempDir(), "db") 60 | if err := cmd.Run(context.Background()); err == nil || err != litefs.ErrDatabaseNotFound { 61 | t.Fatalf("unexpected error: %v", err) 62 | } 63 | }) 64 | } 65 | -------------------------------------------------------------------------------- /cmd/litefs/import.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "fmt" 7 | "os" 8 | "time" 9 | 10 | "github.com/superfly/litefs/http" 11 | ) 12 | 13 | // ImportCommand represents a command to import an existing SQLite database into a cluster. 14 | type ImportCommand struct { 15 | // Target LiteFS URL 16 | URL string 17 | 18 | // Name of database on LiteFS cluster. 19 | Name string 20 | 21 | // SQLite database path to be imported 22 | Path string 23 | } 24 | 25 | // NewImportCommand returns a new instance of ImportCommand. 26 | func NewImportCommand() *ImportCommand { 27 | return &ImportCommand{ 28 | URL: DefaultURL, 29 | } 30 | } 31 | 32 | // ParseFlags parses the command line flags & config file. 33 | func (c *ImportCommand) ParseFlags(ctx context.Context, args []string) (err error) { 34 | fs := flag.NewFlagSet("litefs-import", flag.ContinueOnError) 35 | fs.StringVar(&c.URL, "url", "http://localhost:20202", "LiteFS API URL") 36 | fs.StringVar(&c.Name, "name", "", "database name") 37 | fs.Usage = func() { 38 | fmt.Println(` 39 | The import command will upload a SQLite database to a LiteFS cluster. If the 40 | named database doesn't exist, it will be created. If it does exist, it will be 41 | replaced. This command is safe to used on a live database. 42 | 43 | The database file is not validated for integrity by LiteFS. You can perform an 44 | integrity check first by running "PRAGMA integrity_check" from the SQLite CLI. 45 | 46 | Usage: 47 | 48 | litefs import [arguments] PATH 49 | 50 | Arguments: 51 | `[1:]) 52 | fs.PrintDefaults() 53 | fmt.Println("") 54 | } 55 | if err := fs.Parse(args); err != nil { 56 | return err 57 | } else if fs.NArg() == 0 { 58 | fs.Usage() 59 | return flag.ErrHelp 60 | } else if fs.NArg() > 1 { 61 | return fmt.Errorf("too many arguments") 62 | } 63 | 64 | // Copy first arg as database path. 65 | c.Path = fs.Arg(0) 66 | 67 | return nil 68 | } 69 | 70 | // Run executes the command. 71 | func (c *ImportCommand) Run(ctx context.Context) (err error) { 72 | f, err := os.Open(c.Path) 73 | if err != nil { 74 | return err 75 | } 76 | defer func() { _ = f.Close() }() 77 | 78 | t := time.Now() 79 | 80 | client := http.NewClient() 81 | if err := client.Import(ctx, c.URL, c.Name, f); err != nil { 82 | return err 83 | } 84 | 85 | // Notify user of success and elapsed time. 86 | fmt.Printf("Import of database %q in %s\n", c.Name, time.Since(t)) 87 | 88 | return nil 89 | } 90 | -------------------------------------------------------------------------------- /cmd/litefs/import_test.go: -------------------------------------------------------------------------------- 1 | //go:build linux 2 | 3 | package main_test 4 | 5 | import ( 6 | "context" 7 | "path/filepath" 8 | "strings" 9 | "testing" 10 | 11 | main "github.com/superfly/litefs/cmd/litefs" 12 | "github.com/superfly/litefs/internal/testingutil" 13 | ) 14 | 15 | // Ensure a new, fresh database can be imported to a LiteFS server. 16 | func TestImportCommand_Create(t *testing.T) { 17 | // Generate a database on the regular file system. 18 | dsn := filepath.Join(t.TempDir(), "db") 19 | db := testingutil.OpenSQLDB(t, dsn) 20 | if _, err := db.Exec(`CREATE TABLE t (x)`); err != nil { 21 | t.Fatal(err) 22 | } else if _, err := db.Exec(`INSERT INTO t VALUES (100)`); err != nil { 23 | t.Fatal(err) 24 | } else if err := db.Close(); err != nil { 25 | t.Fatal(err) 26 | } 27 | 28 | // Run a LiteFS mount. 29 | m0 := runMountCommand(t, newMountCommand(t, t.TempDir(), nil)) 30 | waitForPrimary(t, m0) 31 | 32 | // Import database into LiteFS. 33 | cmd := main.NewImportCommand() 34 | cmd.URL = m0.HTTPServer.URL() 35 | cmd.Name = "my.db" 36 | cmd.Path = dsn 37 | if err := cmd.Run(context.Background()); err != nil { 38 | t.Fatal(err) 39 | } 40 | 41 | // Read from LiteFS mount. 42 | db = testingutil.OpenSQLDB(t, filepath.Join(m0.Config.FUSE.Dir, "my.db")) 43 | var x int 44 | if err := db.QueryRow(`SELECT x FROM t`).Scan(&x); err != nil { 45 | t.Fatal(err) 46 | } else if got, want := x, 100; got != want { 47 | t.Fatalf("x=%d, want %d", got, want) 48 | } 49 | } 50 | 51 | // Ensure an existing database can be overwritten by an import. 52 | func TestImportCommand_Overwrite(t *testing.T) { 53 | dir := t.TempDir() 54 | 55 | // Generate a database on the regular file system. 56 | dsn := filepath.Join(t.TempDir(), "db") 57 | dbx := testingutil.OpenSQLDB(t, dsn) 58 | if _, err := dbx.Exec(`CREATE TABLE u (y)`); err != nil { 59 | t.Fatal(err) 60 | } else if _, err := dbx.Exec(`INSERT INTO u VALUES (100)`); err != nil { 61 | t.Fatal(err) 62 | } else if err := dbx.Close(); err != nil { 63 | t.Fatal(err) 64 | } 65 | 66 | // Run an LiteFS mount. 67 | m0 := runMountCommand(t, newMountCommand(t, dir, nil)) 68 | waitForPrimary(t, m0) 69 | 70 | // Generate data into the mount. 71 | db := testingutil.OpenSQLDB(t, filepath.Join(m0.Config.FUSE.Dir, "db")) 72 | if _, err := db.Exec(`CREATE TABLE t (x)`); err != nil { 73 | t.Fatal(err) 74 | } 75 | for i := 0; i < 100; i++ { 76 | if _, err := db.Exec(`INSERT INTO t VALUES (?)`, strings.Repeat("x", 256)); err != nil { 77 | t.Fatal(err) 78 | } 79 | } 80 | 81 | // Overwrite database on LiteFS. 82 | cmd := main.NewImportCommand() 83 | cmd.URL = m0.HTTPServer.URL() 84 | cmd.Name = "db" 85 | cmd.Path = dsn 86 | if err := cmd.Run(context.Background()); err != nil { 87 | t.Fatal(err) 88 | } 89 | 90 | // Read from LiteFS mount. 91 | var y int 92 | if err := db.QueryRow(`SELECT y FROM u`).Scan(&y); err != nil { 93 | t.Fatal(err) 94 | } else if got, want := y, 100; got != want { 95 | t.Fatalf("y=%d, want %d", got, want) 96 | } else if err := db.Close(); err != nil { 97 | t.Fatal(err) 98 | } 99 | 100 | // Reconnect and verify correctness. 101 | db = testingutil.OpenSQLDB(t, filepath.Join(m0.Config.FUSE.Dir, "db")) 102 | if err := db.QueryRow(`SELECT y FROM u`).Scan(&y); err != nil { 103 | t.Fatal(err) 104 | } else if got, want := y, 100; got != want { 105 | t.Fatalf("y=%d, want %d", got, want) 106 | } 107 | 108 | // Add new transactions. 109 | if _, err := db.Exec(`INSERT INTO u VALUES (200)`); err != nil { 110 | t.Fatal(err) 111 | } 112 | if err := db.Close(); err != nil { 113 | t.Fatal(err) 114 | } 115 | 116 | // Restart mount. 117 | if err := m0.Close(); err != nil { 118 | t.Fatal(err) 119 | } 120 | m0 = runMountCommand(t, newMountCommand(t, dir, m0)) 121 | 122 | db = testingutil.OpenSQLDB(t, filepath.Join(m0.Config.FUSE.Dir, "db")) 123 | var sum int 124 | if err := db.QueryRow(`SELECT SUM(y) FROM u`).Scan(&sum); err != nil { 125 | t.Fatal(err) 126 | } else if got, want := sum, 300; got != want { 127 | t.Fatalf("sum=%d, want %d", got, want) 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /cmd/litefs/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "flag" 7 | "fmt" 8 | "log" 9 | "os" 10 | "os/exec" 11 | "os/signal" 12 | "strings" 13 | "syscall" 14 | ) 15 | 16 | // Build information. 17 | var ( 18 | Version = "" 19 | Commit = "" 20 | ) 21 | 22 | // DefaultURL refers to the LiteFS API on the local machine. 23 | const DefaultURL = "http://localhost:20202" 24 | 25 | func main() { 26 | log.SetFlags(0) 27 | 28 | if err := run(context.Background(), os.Args[1:]); err == flag.ErrHelp { 29 | os.Exit(2) 30 | } else if err != nil { 31 | fmt.Fprintf(os.Stderr, "ERROR: %s\n", err) 32 | os.Exit(1) 33 | } 34 | } 35 | 36 | func run(ctx context.Context, args []string) error { 37 | // Extract command name. 38 | var cmd string 39 | if len(args) > 0 { 40 | cmd, args = args[0], args[1:] 41 | } 42 | 43 | switch cmd { 44 | case "export": 45 | c := NewExportCommand() 46 | if err := c.ParseFlags(ctx, args); err != nil { 47 | return err 48 | } 49 | return c.Run(ctx) 50 | 51 | case "import": 52 | c := NewImportCommand() 53 | if err := c.ParseFlags(ctx, args); err != nil { 54 | return err 55 | } 56 | return c.Run(ctx) 57 | 58 | case "mount": 59 | return runMount(ctx, args) 60 | 61 | case "run": 62 | c := NewRunCommand() 63 | if err := c.ParseFlags(ctx, args); err != nil { 64 | return err 65 | } 66 | return c.Run(ctx) 67 | 68 | case "version": 69 | fmt.Println(VersionString()) 70 | return nil 71 | 72 | default: 73 | if cmd == "" || cmd == "help" || strings.HasPrefix(cmd, "-") { 74 | printUsage() 75 | return flag.ErrHelp 76 | } 77 | return fmt.Errorf("litefs %s: unknown command", cmd) 78 | } 79 | } 80 | 81 | func runMount(ctx context.Context, args []string) error { 82 | signalCh := make(chan os.Signal, 2) 83 | signal.Notify(signalCh, syscall.SIGINT, syscall.SIGTERM) 84 | 85 | ctx, cancel := context.WithCancelCause(ctx) 86 | 87 | // Set HOSTNAME environment variable, if unset by environment. 88 | // This can be used for variable expansion in the config file. 89 | if os.Getenv("HOSTNAME") == "" { 90 | hostname, _ := os.Hostname() 91 | _ = os.Setenv("HOSTNAME", hostname) 92 | } 93 | 94 | // Initialize binary and parse CLI flags & config. 95 | c := NewMountCommand() 96 | if err := c.ParseFlags(ctx, args); err == flag.ErrHelp { 97 | os.Exit(2) 98 | } else if err != nil { 99 | fmt.Fprintf(os.Stderr, "ERROR: %s\n", err) 100 | os.Exit(2) 101 | } 102 | 103 | // Validate configuration. 104 | if err := c.Validate(ctx); err != nil { 105 | fmt.Fprintf(os.Stderr, "ERROR: %s\n", err) 106 | os.Exit(2) 107 | } 108 | 109 | if err := c.Run(ctx); err != nil { 110 | fmt.Fprintf(os.Stderr, "ERROR: %s\n", err) 111 | 112 | // Only exit the process if enabled in the config. A user want to 113 | // continue running so that an ephemeral node can be debugged intsead 114 | // of continually restarting on error. 115 | if c.Config.ExitOnError { 116 | _ = c.Close() 117 | os.Exit(1) 118 | } 119 | 120 | // Ensure proxy server is closed on error. Otherwise it can be in a 121 | // state where it is accepting connections but not processing them. 122 | // See: https://github.com/superfly/litefs/pull/278#issuecomment-1419460935 123 | if c.ProxyServer != nil { 124 | log.Printf("closing proxy server on startup error") 125 | _ = c.ProxyServer.Close() 126 | } 127 | } 128 | 129 | fmt.Println("waiting for signal or subprocess to exit") 130 | 131 | // Wait for signal or subcommand exit to stop program. 132 | var exitCode int 133 | select { 134 | case err := <-c.ExecCh(): 135 | cancel(fmt.Errorf("canceled, subprocess exited")) 136 | 137 | var exitErr *exec.ExitError 138 | if errors.As(err, &exitErr) { 139 | exitCode = exitErr.ProcessState.ExitCode() 140 | fmt.Printf("subprocess exited with error code %d, litefs shutting down\n", exitCode) 141 | } else if err != nil { 142 | exitCode = 1 143 | fmt.Printf("subprocess exited with error, litefs shutting down: %s\n", err) 144 | } else { 145 | fmt.Println("subprocess exited successfully, litefs shutting down") 146 | } 147 | 148 | case sig := <-signalCh: 149 | if cmd := c.Cmd(); cmd != nil { 150 | fmt.Println("sending signal to exec process") 151 | if err := cmd.Process.Signal(sig); err != nil { 152 | return fmt.Errorf("cannot signal exec process: %w", err) 153 | } 154 | 155 | fmt.Println("waiting for exec process to close") 156 | if err := <-c.ExecCh(); err != nil && !strings.HasPrefix(err.Error(), "signal:") { 157 | return fmt.Errorf("cannot wait for exec process: %w", err) 158 | } 159 | } 160 | 161 | cancel(fmt.Errorf("canceled, signal received")) 162 | fmt.Println("signal received, litefs shutting down") 163 | } 164 | 165 | if err := c.Close(); err != nil { 166 | return err 167 | } 168 | 169 | fmt.Println("litefs shut down complete") 170 | os.Exit(exitCode) 171 | 172 | return nil 173 | } 174 | 175 | func VersionString() string { 176 | // Print version & commit information, if available. 177 | if Version != "" { 178 | return fmt.Sprintf("LiteFS %s, commit=%s", Version, Commit) 179 | } else if Commit != "" { 180 | return fmt.Sprintf("LiteFS commit=%s", Commit) 181 | } 182 | return "LiteFS development build" 183 | } 184 | 185 | // printUsage prints the help screen to STDOUT. 186 | func printUsage() { 187 | fmt.Println(` 188 | litefs is a distributed file system for replicating SQLite databases. 189 | 190 | Usage: 191 | 192 | litefs [arguments] 193 | 194 | The commands are: 195 | 196 | export export a database from a LiteFS cluster to disk 197 | import import a SQLite database into a LiteFS cluster 198 | mount mount the LiteFS FUSE file system 199 | run executes a subcommand for remote writes 200 | version prints the version 201 | `[1:]) 202 | } 203 | -------------------------------------------------------------------------------- /cmd/litefs/main_test.go: -------------------------------------------------------------------------------- 1 | package main_test 2 | 3 | import ( 4 | "embed" 5 | "flag" 6 | "log" 7 | "os" 8 | "testing" 9 | 10 | "github.com/superfly/litefs" 11 | "golang.org/x/exp/slog" 12 | ) 13 | 14 | //go:embed testdata 15 | var testdata embed.FS 16 | 17 | var ( 18 | fuseDebug = flag.Bool("fuse.debug", false, "enable fuse debugging") 19 | tracing = flag.Bool("tracing", false, "enable trace logging") 20 | funTime = flag.Duration("funtime", 0, "long-running, functional test time") 21 | ) 22 | 23 | func init() { 24 | log.SetFlags(0) 25 | litefs.LogLevel.Set(slog.LevelDebug) 26 | } 27 | 28 | func TestMain(m *testing.M) { 29 | flag.Parse() 30 | if *tracing { 31 | litefs.TraceLog = log.New(os.Stdout, "", litefs.TraceLogFlags) 32 | } 33 | os.Exit(m.Run()) 34 | } 35 | -------------------------------------------------------------------------------- /cmd/litefs/mount_darwin.go: -------------------------------------------------------------------------------- 1 | // go:build darwin 2 | package main 3 | 4 | import ( 5 | "context" 6 | "fmt" 7 | "os/exec" 8 | 9 | "github.com/superfly/litefs" 10 | "github.com/superfly/litefs/http" 11 | ) 12 | 13 | // MountCommand represents a command to mount the file system. 14 | type MountCommand struct { 15 | Config Config 16 | Store *litefs.Store 17 | 18 | ProxyServer *http.ProxyServer 19 | } 20 | 21 | // NewMountCommand returns a new instance of MountCommand. 22 | func NewMountCommand() *MountCommand { 23 | return &MountCommand{} 24 | } 25 | 26 | // Close closes the command. 27 | func (c *MountCommand) Close() error { return nil } 28 | 29 | // ExecCh always returns nil. 30 | func (c *MountCommand) ExecCh() chan error { return nil } 31 | 32 | // Cmd always returns nil. 33 | func (c *MountCommand) Cmd() *exec.Cmd { return nil } 34 | 35 | // ParseFlags returns an error for non-Linux systems. 36 | func (c *MountCommand) ParseFlags(ctx context.Context, args []string) error { 37 | return fmt.Errorf("litefs-mount is not available on macOS") 38 | } 39 | 40 | func (c *MountCommand) Validate(ctx context.Context) (err error) { 41 | return fmt.Errorf("litefs-mount is not available on macOS") 42 | } 43 | 44 | func (c *MountCommand) Run(ctx context.Context) (err error) { 45 | return fmt.Errorf("litefs-mount is not available on macOS") 46 | } 47 | -------------------------------------------------------------------------------- /cmd/litefs/run.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "fmt" 7 | "io" 8 | "log" 9 | "os" 10 | "os/exec" 11 | "path/filepath" 12 | "time" 13 | 14 | "github.com/superfly/litefs" 15 | litefsgo "github.com/superfly/litefs-go" 16 | "github.com/superfly/litefs/http" 17 | ) 18 | 19 | // RunCommand represents a command to run a program with the HALT lock. 20 | type RunCommand struct { 21 | // Promote the local node to primary before running the command. 22 | Promote bool 23 | 24 | // Only run the command if this node is a candidate. 25 | IfCandidate bool 26 | 27 | // The database to acquire a halt lock on. 28 | WithHaltLockOn string 29 | 30 | // Subcommand & args 31 | Cmd string 32 | Args []string 33 | 34 | // Target LiteFS URL 35 | URL string 36 | 37 | // If true, enables verbose logging. 38 | Verbose bool 39 | } 40 | 41 | // NewRunCommand returns a new instance of RunCommand. 42 | func NewRunCommand() *RunCommand { 43 | return &RunCommand{} 44 | } 45 | 46 | // ParseFlags parses the command line flags & config file. 47 | func (c *RunCommand) ParseFlags(ctx context.Context, args []string) (err error) { 48 | // Split the args list if there is a double dash arg included. 49 | args0, args1 := splitArgs(args) 50 | 51 | fs := flag.NewFlagSet("litefs-run", flag.ContinueOnError) 52 | fs.StringVar(&c.URL, "url", "http://localhost:20202", "LiteFS API URL") 53 | fs.BoolVar(&c.Promote, "promote", false, "promote node to primary") 54 | fs.BoolVar(&c.IfCandidate, "if-candidate", false, "only execute if node is a candidate") 55 | fs.StringVar(&c.WithHaltLockOn, "with-halt-lock-on", "", "full database path to halt") 56 | fs.BoolVar(&c.Verbose, "v", false, "enable verbose logging") 57 | fs.Usage = func() { 58 | fmt.Println(` 59 | The run command will execute a subcommand with certain guarantees provided by 60 | LiteFS. Typically, this is executed with --with-halt-lock-on to acquire a HALT lock 61 | so that write transactions can temporarily be executed on the local node. 62 | 63 | Usage: 64 | 65 | litefs run [arguments] -- CMD [ARG...] 66 | 67 | Arguments: 68 | `[1:]) 69 | fs.PrintDefaults() 70 | fmt.Println("") 71 | } 72 | if err := fs.Parse(args0); err != nil { 73 | return err 74 | } else if fs.NArg() == 0 && len(args1) == 0 { 75 | fs.Usage() 76 | return flag.ErrHelp 77 | } else if fs.NArg() > 0 { 78 | return fmt.Errorf("too many arguments, specify a '--' to specify an exec command") 79 | } 80 | 81 | if len(args1) == 0 { 82 | return fmt.Errorf("no subcommand specified") 83 | } 84 | c.Cmd, c.Args = args1[0], args1[1:] 85 | 86 | // Optionally disable logging. 87 | if !c.Verbose { 88 | log.SetOutput(io.Discard) 89 | } 90 | 91 | return nil 92 | } 93 | 94 | // Run executes the command. 95 | func (c *RunCommand) Run(ctx context.Context) (err error) { 96 | client := http.NewClient() 97 | 98 | // It doesn't make any sense to promote the node and then acquire a halt 99 | // lock since that is a no-op on the primary. 100 | if c.Promote && c.WithHaltLockOn != "" { 101 | return fmt.Errorf("cannot specify both -promote & -with-halt-lock-on") 102 | } 103 | 104 | // Fetch node info if flags require it. 105 | var info litefs.NodeInfo 106 | if c.IfCandidate || c.Promote { 107 | log.Printf("fetching node metadata") 108 | if info, err = client.Info(ctx, c.URL); err != nil { 109 | return err 110 | } 111 | } 112 | 113 | // Exit if we should only run on a candidate node. 114 | // This is typically paired with the 'promote' flag. 115 | if c.IfCandidate { 116 | if !info.Candidate { 117 | fmt.Fprintf(os.Stderr, "node is not a candidate, skipping execution\n") 118 | return nil 119 | } 120 | } 121 | 122 | // Attempt to promote local node to be the primary node via lease handoff. 123 | if c.Promote { 124 | if info.IsPrimary { 125 | log.Printf("node is already primary, skipping promotion") 126 | } else { 127 | log.Printf("promoting node to primary") 128 | if err := client.Promote(ctx, c.URL); err != nil { 129 | return err 130 | } 131 | log.Printf("promotion successful") 132 | } 133 | } 134 | 135 | // Acquire the halt lock on the given database, if specified. 136 | var f *os.File 137 | if c.WithHaltLockOn != "" { 138 | // Ensure database exists first. 139 | if _, err := os.Stat(c.WithHaltLockOn); os.IsNotExist(err) { 140 | return fmt.Errorf("database does not exist: %s", c.WithHaltLockOn) 141 | } else if err != nil { 142 | return err 143 | } 144 | 145 | // Attempt to lock the database. 146 | if f, err = os.OpenFile(c.WithHaltLockOn+"-lock", os.O_RDWR, 0o666); os.IsNotExist(err) { 147 | return fmt.Errorf("lock file not available, are you sure %q is a LiteFS mount?", filepath.Dir(c.WithHaltLockOn)) 148 | } else if err != nil { 149 | return err 150 | } 151 | defer func() { _ = f.Close() }() 152 | 153 | t := time.Now() 154 | log.Printf("acquiring halt lock") 155 | if err := litefsgo.Halt(f); err != nil { 156 | return err 157 | } 158 | log.Printf("halt lock acquired in %s", time.Since(t)) 159 | } 160 | 161 | // Execute subcommand. 162 | cmd := exec.CommandContext(ctx, c.Cmd, c.Args...) 163 | cmd.Stdin = os.Stdin 164 | cmd.Stdout = os.Stdout 165 | cmd.Stderr = os.Stderr 166 | if f != nil { 167 | cmd.ExtraFiles = []*os.File{f} // pass along, otherwise the file is flushed 168 | } 169 | if err := cmd.Run(); err != nil { 170 | return err 171 | } 172 | 173 | // Unhalt, if database specified. 174 | if f != nil { 175 | t := time.Now() 176 | log.Printf("releasing halt lock") 177 | if err := litefsgo.Unhalt(f); err != nil { 178 | return err 179 | } 180 | log.Printf("halt lock released in %s", time.Since(t)) 181 | 182 | if err := f.Close(); err != nil { 183 | return err 184 | } 185 | } 186 | return nil 187 | } 188 | -------------------------------------------------------------------------------- /cmd/litefs/testdata/config/multi_exec.yml: -------------------------------------------------------------------------------- 1 | exec: 2 | - cmd: "run me" 3 | if-candidate: true 4 | 5 | - cmd: "run me too" 6 | -------------------------------------------------------------------------------- /cmd/litefs/testdata/config/single_exec.yml: -------------------------------------------------------------------------------- 1 | exec: 2 | - cmd: "run me" 3 | -------------------------------------------------------------------------------- /docs/ARCHITECTURE.md: -------------------------------------------------------------------------------- 1 | LiteFS Architecture 2 | =================== 3 | 4 | The LiteFS system is composed of 3 major parts: 5 | 6 | 1. FUSE file system: intercepts file system calls to record transactions. 7 | 1. Leader election: currently implemented by [Consul](https://www.consul.io/) using sessions 8 | 1. HTTP server: provides an API for replica nodes to receive changes. 9 | 10 | 11 | ### Lite Transaction Files (LTX) 12 | 13 | Each transaction in SQLite is simply a collection of one or more pages to be 14 | written. This is done safely by the rollback journal or the write-ahead log (WAL) 15 | within a SQLite database. 16 | 17 | An [LTX file](https://github.com/superfly/ltx) is an additional packaging format 18 | for these change sets. Unlike the journal or the WAL, the LTX file is optimized 19 | for use in a replication system by utilizing the following: 20 | 21 | - Checksumming across the LTX file to ensure consistency. 22 | - Rolling checksum of the entire database on every transaction. 23 | - Sorted pages for efficient compactions to ensure fast recovery time. 24 | - Page-level encryption (future work) 25 | - Transactional event data (future work) 26 | 27 | Each LTX file is associated with an autoincrementing transaction ID (TXID) so 28 | that replicas can know their position relative to the primary node. This TXID 29 | is also associated with a rolling checksum of the entire database to ensure that 30 | the database is never corrupted if a split brain occurs. Please see the 31 | _Guarantees_ section to understand how async replication and split brain works. 32 | 33 | 34 | ### File system 35 | 36 | The FUSE-based file system allows the user to mount LiteFS to a directory. For 37 | the primary node in the cluster, this means it can intercept write transactions 38 | via the file system interface and it is transparent to the application and SQLite. 39 | 40 | For replica nodes, the file system adds protections by ensuring databases are 41 | not writeable. The file system also provides information about the current 42 | primary node to the application via the `.primary` file. 43 | 44 | In SQLite, write transactions work by copying pages out to the rollback journal, 45 | updating pages in the database file, and then deleting the rollback journal when 46 | complete. LiteFS passes all these file system calls through to the underlying 47 | files, however, it intercepts the journal deletion at the end to convert the 48 | updated pages to an LTX file. 49 | 50 | Currently, LiteFS supports both the rollback and WAL mode journal mechanisms. 51 | It will possibly support [`wal2`](https://www.sqlite.org/cgi/src/doc/wal2/doc/wal2.md) 52 | in the future. 53 | 54 | 55 | ### Leader election 56 | 57 | Because LiteFS is meant to be used in ephemeral deployments such as 58 | [Fly.io](https://fly.io/) or [Kubernetes](https://kubernetes.io/), it cannot use 59 | a distributed consensus algorithm that requires strong membership such as Raft. 60 | Instead, it delegates leader election to Consul sessions and uses a time-based 61 | lease system. 62 | 63 | Distributed leases work by obtaining a lock on a key within Consul which 64 | guarantees that only one node can be the primary at any given time. This lease 65 | has a time-to-live (TTL) which is automatically renewed by the primary as long 66 | as it is alive. If the primary shuts down cleanly, the lease is destroyed and 67 | another node can immediately become the new primary. If the primary dies 68 | unexpectedly then the TTL must expire before a new node will become primary. 69 | 70 | Since LiteFS uses async replication, replica nodes may be at different 71 | replication positions, however, whichever node becomes primary will dictate the 72 | state of the database. This means replicas which are further ahead could 73 | potentially lose some transactions. See the _Guarantees_ section below for more 74 | information. 75 | 76 | 77 | ### HTTP server 78 | 79 | Replica nodes communicate with the primary node over HTTP. When they connect to 80 | the primary node, they specify their replication position, which is their 81 | transaction ID and a rolling checksum of the entire database. The primary node 82 | will then begin sending transaction data to the replica starting from that 83 | position. If the primary no longer has that transaction position available, it 84 | will resend a snapshot of the current database and begin replicating 85 | transactions from there. 86 | 87 | 88 | ## Guarantees 89 | 90 | LiteFS is intended to provide easy, live, asynchronous replication across 91 | ephemeral nodes in a cluster. This approach makes trade-offs as compared with 92 | simpler disaster recovery tools such as [Litestream](https://litestream.io/) and 93 | more complex but strongly-consistent tools such as 94 | [rqlite](https://github.com/rqlite/rqlite). 95 | 96 | As with any async replication system, there's a window of time where 97 | transactions are only durable on the primary node and have not been replicated 98 | to a replica node. A catastrophic crash on the primary would cause these 99 | transactions to be lost. Typically, this window is subsecond as transactions can 100 | quickly be shuttled from the primary to the replicas. 101 | 102 | Synchronous replication and time-bounded asynchronous replication is planned for 103 | future versions of LiteFS. 104 | 105 | 106 | ### Ensuring consistency during split brain 107 | 108 | Because LiteFS uses async replication, there is the potential that a primary 109 | could receive writes but is unable to replicate them during a network partition. 110 | If the primary node loses its leader status and later connects to the new leader, 111 | its database state will have diverged from the new leader. If it naively began 112 | applying transactions from the new leader, it could corrupt its database state. 113 | 114 | Instead, LiteFS utilizes a rolling checksum which represents a checksum of the 115 | entire database at every transaction. When the old primary node connects to the 116 | new primary node, it will see that its checksum is different even though its 117 | transaction ID could be the same. At this point, it will resnapshot the database 118 | from the new primary to ensure consistency. 119 | 120 | 121 | ### Rolling checksum implementation 122 | 123 | The rolling checksum is implemented by combining checksums of every page 124 | together. When a page is written, LiteFS will compute the CRC64 of the page 125 | number and the page data and XOR them into the rolling checksum. It will also 126 | compute this same page checksum for the old page data and XOR that value out 127 | of the rolling checksum. 128 | 129 | This approach gives us strong guarantees about the exact byte contents of the 130 | database at every transaction and it is fast to compute. As XOR is associative, 131 | it is also possible to compute on a raw database file from scratch to ensure 132 | consistency. 133 | 134 | -------------------------------------------------------------------------------- /fly/environment.go: -------------------------------------------------------------------------------- 1 | package fly 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "fmt" 8 | "net" 9 | "net/http" 10 | "net/url" 11 | "os" 12 | "path" 13 | "sync/atomic" 14 | "time" 15 | 16 | "golang.org/x/exp/slog" 17 | ) 18 | 19 | const DefaultTimeout = 2 * time.Second 20 | 21 | type Environment struct { 22 | setPrimaryStatusCancel atomic.Value 23 | 24 | HTTPClient *http.Client 25 | Timeout time.Duration 26 | } 27 | 28 | func NewEnvironment() *Environment { 29 | e := &Environment{ 30 | HTTPClient: &http.Client{ 31 | Transport: &http.Transport{ 32 | DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { 33 | return net.Dial("unix", "/.fly/api") 34 | }, 35 | }, 36 | }, 37 | Timeout: DefaultTimeout, 38 | } 39 | e.setPrimaryStatusCancel.Store(context.CancelCauseFunc(func(error) {})) 40 | 41 | return e 42 | } 43 | 44 | func (e *Environment) Type() string { return "fly.io" } 45 | 46 | func (e *Environment) SetPrimaryStatus(ctx context.Context, isPrimary bool) { 47 | const retryN = 5 48 | 49 | appName := AppName() 50 | if appName == "" { 51 | slog.Debug("cannot set environment metadata", slog.String("reason", "app name unavailable")) 52 | return 53 | } 54 | 55 | machineID := MachineID() 56 | if machineID == "" { 57 | slog.Debug("cannot set environment metadata", slog.String("reason", "machine id unavailable")) 58 | return 59 | } 60 | 61 | // Ensure we only have a single in-flight command at a time as the primary 62 | // status can change while we are retrying. This status is only for 63 | // informational purposes so it is not critical to correct functioning. 64 | ctx, cancel := context.WithCancelCause(ctx) 65 | oldCancel := e.setPrimaryStatusCancel.Swap(cancel).(context.CancelCauseFunc) 66 | oldCancel(fmt.Errorf("interrupted by new status update")) 67 | 68 | // Continuously retry status update in case the unix socket is unavailable 69 | // or in case we have exceeded the rate limit. We run this in a goroutine 70 | // so that we are not blocking the main lease loop. 71 | go func() { 72 | ticker := time.NewTicker(1 * time.Second) 73 | defer ticker.Stop() 74 | 75 | var err error 76 | for i := 0; ; i++ { 77 | if err = e.setPrimaryStatus(ctx, isPrimary); err == nil { 78 | return 79 | } else if i >= retryN { 80 | break 81 | } 82 | 83 | select { 84 | case <-ctx.Done(): 85 | slog.Debug("cannot set environment metadata", 86 | slog.String("reason", "context canceled"), 87 | slog.Any("err", context.Cause(ctx))) 88 | return 89 | case <-ticker.C: 90 | } 91 | } 92 | 93 | slog.Info("cannot set environment metadata", 94 | slog.String("reason", "retries exceeded"), 95 | slog.Any("err", err)) 96 | }() 97 | } 98 | 99 | func (e *Environment) setPrimaryStatus(ctx context.Context, isPrimary bool) error { 100 | role := "replica" 101 | if isPrimary { 102 | role = "primary" 103 | } 104 | 105 | reqBody, err := json.Marshal(postMetadataRequest{ 106 | Value: role, 107 | }) 108 | if err != nil { 109 | return fmt.Errorf("marshal metadata request body: %w", err) 110 | } 111 | 112 | u := url.URL{ 113 | Scheme: "http", 114 | Host: "localhost", 115 | Path: path.Join("/v1", "apps", AppName(), "machines", MachineID(), "metadata", "role"), 116 | } 117 | req, err := http.NewRequest("POST", u.String(), bytes.NewReader(reqBody)) 118 | if err != nil { 119 | return err 120 | } 121 | 122 | ctx, cancel := context.WithTimeout(ctx, e.Timeout) 123 | defer cancel() 124 | req = req.WithContext(ctx) 125 | 126 | resp, err := e.HTTPClient.Do(req) 127 | if err != nil { 128 | return err 129 | } 130 | defer func() { _ = resp.Body.Close() }() 131 | 132 | switch resp.StatusCode { 133 | case http.StatusOK, http.StatusNoContent: 134 | return nil 135 | default: 136 | return fmt.Errorf("cannot set machine metadata: code=%d", resp.StatusCode) 137 | } 138 | } 139 | 140 | type postMetadataRequest struct { 141 | Value string `json:"value"` 142 | } 143 | 144 | // Available returns true if currently running in a Fly.io environment. 145 | func Available() bool { return AppName() != "" } 146 | 147 | // AppName returns the name of the current Fly.io application. 148 | func AppName() string { 149 | return os.Getenv("FLY_APP_NAME") 150 | } 151 | 152 | // MachineID returns the identifier for the current Fly.io machine. 153 | func MachineID() string { 154 | return os.Getenv("FLY_MACHINE_ID") 155 | } 156 | -------------------------------------------------------------------------------- /fuse/database_node.go: -------------------------------------------------------------------------------- 1 | package fuse 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "log" 7 | "os" 8 | "syscall" 9 | 10 | "bazil.org/fuse" 11 | "bazil.org/fuse/fs" 12 | "github.com/superfly/litefs" 13 | ) 14 | 15 | var _ fs.Node = (*DatabaseNode)(nil) 16 | var _ fs.NodeOpener = (*DatabaseNode)(nil) 17 | var _ fs.NodeFsyncer = (*DatabaseNode)(nil) 18 | var _ fs.NodeForgetter = (*DatabaseNode)(nil) 19 | var _ fs.NodeListxattrer = (*DatabaseNode)(nil) 20 | var _ fs.NodeGetxattrer = (*DatabaseNode)(nil) 21 | var _ fs.NodeSetxattrer = (*DatabaseNode)(nil) 22 | var _ fs.NodeRemovexattrer = (*DatabaseNode)(nil) 23 | var _ fs.NodePoller = (*DatabaseNode)(nil) 24 | 25 | // DatabaseNode represents a SQLite database file. 26 | type DatabaseNode struct { 27 | fsys *FileSystem 28 | db *litefs.DB 29 | } 30 | 31 | func newDatabaseNode(fsys *FileSystem, db *litefs.DB) *DatabaseNode { 32 | return &DatabaseNode{ 33 | fsys: fsys, 34 | db: db, 35 | } 36 | } 37 | 38 | func (n *DatabaseNode) Attr(ctx context.Context, attr *fuse.Attr) error { 39 | fi, err := os.Stat(n.db.DatabasePath()) 40 | if os.IsNotExist(err) { 41 | return syscall.ENOENT 42 | } else if err != nil { 43 | return err 44 | } 45 | 46 | if n.db.Store().IsPrimary() { 47 | attr.Mode = 0666 48 | } else { 49 | attr.Mode = 0444 50 | } 51 | 52 | attr.Size = uint64(fi.Size()) 53 | attr.Uid = uint32(n.fsys.Uid) 54 | attr.Gid = uint32(n.fsys.Gid) 55 | attr.Valid = 0 56 | return nil 57 | } 58 | 59 | func (n *DatabaseNode) Setattr(ctx context.Context, req *fuse.SetattrRequest, resp *fuse.SetattrResponse) error { 60 | if req.Valid.Size() { 61 | if err := n.db.TruncateDatabase(ctx, int64(req.Size)); err != nil { 62 | return err 63 | } 64 | } 65 | return n.Attr(ctx, &resp.Attr) 66 | } 67 | 68 | func (n *DatabaseNode) Open(ctx context.Context, req *fuse.OpenRequest, resp *fuse.OpenResponse) (fs.Handle, error) { 69 | resp.Flags |= fuse.OpenKeepCache 70 | 71 | f, err := n.db.OpenDatabase(ctx) 72 | if err != nil { 73 | return nil, err 74 | } 75 | return newDatabaseHandle(n, f), nil 76 | } 77 | 78 | func (n *DatabaseNode) Fsync(ctx context.Context, req *fuse.FsyncRequest) error { 79 | return n.db.SyncDatabase(ctx) 80 | } 81 | 82 | func (n *DatabaseNode) Forget() { n.fsys.root.ForgetNode(n) } 83 | 84 | // ENOSYS is a special return code for xattr requests that will be treated as a permanent failure for any such 85 | // requests in the future without being sent to the filesystem. 86 | // Source: https://github.com/libfuse/libfuse/blob/0b6d97cf5938f6b4885e487c3bd7b02144b1ea56/include/fuse_lowlevel.h#L811 87 | 88 | func (n *DatabaseNode) Listxattr(ctx context.Context, req *fuse.ListxattrRequest, resp *fuse.ListxattrResponse) error { 89 | return fuse.ToErrno(syscall.ENOSYS) 90 | } 91 | 92 | func (n *DatabaseNode) Getxattr(ctx context.Context, req *fuse.GetxattrRequest, resp *fuse.GetxattrResponse) error { 93 | return fuse.ToErrno(syscall.ENOSYS) 94 | } 95 | 96 | func (n *DatabaseNode) Setxattr(ctx context.Context, req *fuse.SetxattrRequest) error { 97 | return fuse.ToErrno(syscall.ENOSYS) 98 | } 99 | 100 | func (n *DatabaseNode) Removexattr(ctx context.Context, req *fuse.RemovexattrRequest) error { 101 | return fuse.ToErrno(syscall.ENOSYS) 102 | } 103 | 104 | func (n *DatabaseNode) Poll(ctx context.Context, req *fuse.PollRequest, resp *fuse.PollResponse) error { 105 | return fuse.Errno(syscall.ENOSYS) 106 | } 107 | 108 | var _ fs.Handle = (*DatabaseHandle)(nil) 109 | var _ fs.HandleReader = (*DatabaseHandle)(nil) 110 | var _ fs.HandleWriter = (*DatabaseHandle)(nil) 111 | var _ fs.HandlePOSIXLocker = (*DatabaseHandle)(nil) 112 | 113 | // DatabaseHandle represents a file handle to a SQLite database file. 114 | type DatabaseHandle struct { 115 | node *DatabaseNode 116 | file *os.File 117 | } 118 | 119 | func newDatabaseHandle(node *DatabaseNode, file *os.File) *DatabaseHandle { 120 | return &DatabaseHandle{ 121 | node: node, 122 | file: file, 123 | } 124 | } 125 | 126 | func (h *DatabaseHandle) Read(ctx context.Context, req *fuse.ReadRequest, resp *fuse.ReadResponse) error { 127 | n, err := h.node.db.ReadDatabaseAt(ctx, h.file, resp.Data[:req.Size], req.Offset, uint64(req.LockOwner)) 128 | if err == io.EOF { 129 | err = nil 130 | } 131 | resp.Data = resp.Data[:n] 132 | return err 133 | } 134 | 135 | func (h *DatabaseHandle) Write(ctx context.Context, req *fuse.WriteRequest, resp *fuse.WriteResponse) error { 136 | if err := h.node.db.WriteDatabaseAt(ctx, h.file, req.Data, req.Offset, uint64(req.LockOwner)); err != nil { 137 | log.Printf("fuse: write(): database error: %s", err) 138 | return err 139 | } 140 | resp.Size = len(req.Data) 141 | return nil 142 | } 143 | 144 | func (h *DatabaseHandle) Flush(ctx context.Context, req *fuse.FlushRequest) error { 145 | h.node.db.UnlockDatabase(ctx, uint64(req.LockOwner)) 146 | return nil 147 | } 148 | 149 | func (h *DatabaseHandle) Release(ctx context.Context, req *fuse.ReleaseRequest) error { 150 | return h.node.db.CloseDatabase(ctx, h.file, uint64(req.LockOwner)) 151 | } 152 | 153 | func (h *DatabaseHandle) Lock(ctx context.Context, req *fuse.LockRequest) error { 154 | lockTypes := litefs.ParseDatabaseLockRange(req.Lock.Start, req.Lock.End) 155 | return lock(ctx, req, h.node.db, lockTypes) 156 | } 157 | 158 | func (h *DatabaseHandle) LockWait(ctx context.Context, req *fuse.LockWaitRequest) (err error) { 159 | return syscall.ENOSYS 160 | } 161 | 162 | func (h *DatabaseHandle) Unlock(ctx context.Context, req *fuse.UnlockRequest) error { 163 | lockTypes := litefs.ParseDatabaseLockRange(req.Lock.Start, req.Lock.End) 164 | return h.node.db.Unlock(ctx, uint64(req.LockOwner), lockTypes) 165 | } 166 | 167 | func (h *DatabaseHandle) QueryLock(ctx context.Context, req *fuse.QueryLockRequest, resp *fuse.QueryLockResponse) error { 168 | lockTypes := litefs.ParseDatabaseLockRange(req.Lock.Start, req.Lock.End) 169 | queryLock(ctx, req, resp, h.node.db, lockTypes) 170 | return nil 171 | } 172 | 173 | func lock(ctx context.Context, req *fuse.LockRequest, db *litefs.DB, lockTypes []litefs.LockType) error { 174 | switch typ := req.Lock.Type; typ { 175 | case fuse.LockUnlock: 176 | return nil 177 | 178 | case fuse.LockWrite: 179 | if ok, err := db.TryLocks(ctx, uint64(req.LockOwner), lockTypes); err != nil { 180 | log.Printf("fuse lock error: %s", err) 181 | return err 182 | } else if !ok { 183 | return syscall.EAGAIN 184 | } 185 | return nil 186 | 187 | case fuse.LockRead: 188 | if !db.TryRLocks(ctx, uint64(req.LockOwner), lockTypes) { 189 | return syscall.EAGAIN 190 | } 191 | return nil 192 | 193 | default: 194 | panic("fuse.lock(): invalid POSIX lock type") 195 | } 196 | } 197 | 198 | func queryLock(ctx context.Context, req *fuse.QueryLockRequest, resp *fuse.QueryLockResponse, db *litefs.DB, lockTypes []litefs.LockType) { 199 | switch req.Lock.Type { 200 | case fuse.LockRead: 201 | if !db.CanRLock(ctx, uint64(req.LockOwner), lockTypes) { 202 | resp.Lock = fuse.FileLock{ 203 | Start: req.Lock.Start, 204 | End: req.Lock.End, 205 | Type: fuse.LockWrite, 206 | PID: -1, 207 | } 208 | } 209 | case fuse.LockWrite: 210 | if canLock, mutexState := db.CanLock(ctx, uint64(req.LockOwner), lockTypes); !canLock { 211 | resp.Lock = fuse.FileLock{ 212 | Start: req.Lock.Start, 213 | End: req.Lock.End, 214 | Type: fuse.LockRead, 215 | PID: -1, 216 | } 217 | if mutexState == litefs.RWMutexStateExclusive { 218 | resp.Lock.Type = fuse.LockWrite 219 | } 220 | } 221 | } 222 | } 223 | -------------------------------------------------------------------------------- /fuse/file_system.go: -------------------------------------------------------------------------------- 1 | package fuse 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "os" 7 | "syscall" 8 | 9 | "bazil.org/fuse" 10 | "bazil.org/fuse/fs" 11 | "github.com/superfly/litefs" 12 | ) 13 | 14 | var _ fs.FS = (*FileSystem)(nil) 15 | var _ fs.FSStatfser = (*FileSystem)(nil) 16 | var _ litefs.Invalidator = (*FileSystem)(nil) 17 | 18 | // FileSystem represents a raw interface to the FUSE file system. 19 | type FileSystem struct { 20 | path string // mount path 21 | store *litefs.Store 22 | 23 | conn *fuse.Conn 24 | server *fs.Server 25 | root *RootNode 26 | 27 | // If true, allows other users to access the FUSE mount. 28 | // Must set "user_allow_other" option in /etc/fuse.conf as well. 29 | AllowOther bool 30 | 31 | // User & Group ID for all files in the filesystem. 32 | Uid int 33 | Gid int 34 | 35 | // If true, enables debug logging. 36 | Debug bool 37 | } 38 | 39 | // NewFileSystem returns a new instance of FileSystem. 40 | func NewFileSystem(path string, store *litefs.Store) *FileSystem { 41 | fsys := &FileSystem{ 42 | path: path, 43 | store: store, 44 | 45 | Uid: os.Getuid(), 46 | Gid: os.Getgid(), 47 | } 48 | 49 | fsys.root = newRootNode(fsys) 50 | 51 | return fsys 52 | } 53 | 54 | // Path returns the path to the mount point. 55 | func (fsys *FileSystem) Path() string { return fsys.path } 56 | 57 | // Store returns the underlying store. 58 | func (fsys *FileSystem) Store() *litefs.Store { return fsys.store } 59 | 60 | // Mount mounts the file system to the mount point. 61 | func (fsys *FileSystem) Mount(skipUnmount bool) (err error) { 62 | if !skipUnmount { 63 | // Attempt to unmount if it did not close cleanly before. 64 | _ = fuse.Unmount(fsys.path) 65 | } 66 | 67 | // Ensure mount directory exists before trying to mount to it. 68 | if err := os.MkdirAll(fsys.path, 0777); err != nil { 69 | return err 70 | } 71 | 72 | options := []fuse.MountOption{ 73 | fuse.FSName("litefs"), 74 | fuse.LockingPOSIX(), 75 | fuse.ExplicitInvalidateData(), 76 | } 77 | if fsys.AllowOther { 78 | options = append(options, fuse.AllowOther()) 79 | } 80 | 81 | fsys.conn, err = fuse.Mount(fsys.path, options...) 82 | if err != nil { 83 | return err 84 | } 85 | 86 | var config fs.Config 87 | if fsys.Debug { 88 | config.Debug = fsys.debugFn 89 | } 90 | fsys.server = fs.New(fsys.conn, &config) 91 | 92 | go func() { 93 | if err := fsys.server.Serve(fsys); err != nil { 94 | log.Printf("fuse serve error: %s", err) 95 | } 96 | }() 97 | 98 | return nil 99 | } 100 | 101 | // Unmount unmounts the file system. 102 | func (fsys *FileSystem) Unmount() (err error) { 103 | if fsys.conn != nil { 104 | if e := fuse.Unmount(fsys.path); err == nil { 105 | err = e 106 | } 107 | fsys.conn = nil 108 | } 109 | return err 110 | } 111 | 112 | // Root returns the root directory in the file system. 113 | func (fsys *FileSystem) Root() (fs.Node, error) { 114 | return fsys.root, nil 115 | } 116 | 117 | // Statfs is a passthrough to the underlying file system. 118 | func (fsys *FileSystem) Statfs(ctx context.Context, req *fuse.StatfsRequest, resp *fuse.StatfsResponse) error { 119 | // Obtain statfs() call from underlying store path. 120 | var statfs syscall.Statfs_t 121 | if err := syscall.Statfs(fsys.store.Path(), &statfs); err != nil { 122 | return err 123 | } 124 | 125 | // Copy stats over from the underlying file system to the response. 126 | resp.Blocks = statfs.Blocks 127 | resp.Bfree = statfs.Bfree 128 | resp.Bavail = statfs.Bavail 129 | resp.Files = statfs.Files 130 | resp.Ffree = statfs.Ffree 131 | resp.Bsize = uint32(statfs.Bsize) 132 | resp.Namelen = uint32(statfs.Namelen) 133 | resp.Frsize = uint32(statfs.Frsize) 134 | 135 | return nil 136 | } 137 | 138 | // InvalidateDB invalidates the entire database from the kernel page cache. 139 | func (fsys *FileSystem) InvalidateDB(db *litefs.DB) error { 140 | node := fsys.root.Node(db.Name()) 141 | if node == nil { 142 | return nil 143 | } 144 | 145 | if err := fsys.server.InvalidateNodeData(node); err != nil && err != fuse.ErrNotCached { 146 | return err 147 | } 148 | return nil 149 | } 150 | 151 | // InvalidateDBRange invalidates a database in the kernel page cache. 152 | func (fsys *FileSystem) InvalidateDBRange(db *litefs.DB, offset, size int64) error { 153 | node := fsys.root.Node(db.Name()) 154 | if node == nil { 155 | return nil 156 | } 157 | 158 | if err := fsys.server.InvalidateNodeDataRange(node, offset, size); err != nil && err != fuse.ErrNotCached { 159 | return err 160 | } 161 | return nil 162 | } 163 | 164 | // InvalidateSHM invalidates the SHM file in the kernel page cache. 165 | func (fsys *FileSystem) InvalidateSHM(db *litefs.DB) error { 166 | node := fsys.root.Node(db.Name() + "-shm") 167 | if node == nil { 168 | return nil 169 | } 170 | 171 | if err := fsys.server.InvalidateNodeData(node); err != nil && err != fuse.ErrNotCached { 172 | return err 173 | } 174 | return nil 175 | } 176 | 177 | // InvalidatePos invalidates the position file in the kernel page cache. 178 | func (fsys *FileSystem) InvalidatePos(db *litefs.DB) error { 179 | node := fsys.root.Node(db.Name() + "-pos") 180 | if node == nil { 181 | return nil 182 | } 183 | 184 | if err := fsys.server.InvalidateNodeData(node); err != nil && err != fuse.ErrNotCached { 185 | return err 186 | } 187 | return nil 188 | } 189 | 190 | // InvalidateEntry removes the file from the cache. 191 | func (fsys *FileSystem) InvalidateEntry(name string) error { 192 | if err := fsys.server.InvalidateEntry(fsys.root, name); err != nil && err != fuse.ErrNotCached { 193 | return err 194 | } 195 | return nil 196 | } 197 | 198 | func (fsys *FileSystem) InvalidateLag() error { 199 | node := fsys.root.Node(LagFilename) 200 | if node == nil { 201 | return nil 202 | } 203 | 204 | if err := fsys.server.InvalidateNodeData(node); err != nil && err != fuse.ErrNotCached { 205 | return err 206 | } 207 | return nil 208 | } 209 | 210 | // debugFn is called by the underlying FUSE library when debug logging is enabled. 211 | func (fsys *FileSystem) debugFn(msg any) { 212 | status := "r" 213 | if fsys.store.IsPrimary() { 214 | status = "p" 215 | } 216 | log.Printf("%s [%s]: %s", litefs.FormatNodeID(fsys.store.ID()), status, msg) 217 | } 218 | -------------------------------------------------------------------------------- /fuse/fuse.go: -------------------------------------------------------------------------------- 1 | package fuse 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | "syscall" 8 | 9 | "bazil.org/fuse" 10 | "github.com/superfly/litefs" 11 | ) 12 | 13 | // FileTypeFilename returns the base name for the internal data file. 14 | func FileTypeFilename(t litefs.FileType) string { 15 | switch t { 16 | case litefs.FileTypeDatabase: 17 | return "database" 18 | case litefs.FileTypeJournal: 19 | return "journal" 20 | case litefs.FileTypeWAL: 21 | return "wal" 22 | case litefs.FileTypeSHM: 23 | return "shm" 24 | case litefs.FileTypePos: 25 | return "pos" 26 | case litefs.FileTypeLock: 27 | return "lock" 28 | default: 29 | panic(fmt.Sprintf("FileTypeFilename(): invalid file type: %d", t)) 30 | } 31 | } 32 | 33 | // ParseFilename parses a base name into database name & file type parts. 34 | func ParseFilename(name string) (dbName string, fileType litefs.FileType) { 35 | if strings.HasSuffix(name, "-journal") { 36 | return strings.TrimSuffix(name, "-journal"), litefs.FileTypeJournal 37 | } else if strings.HasSuffix(name, "-wal") { 38 | return strings.TrimSuffix(name, "-wal"), litefs.FileTypeWAL 39 | } else if strings.HasSuffix(name, "-shm") { 40 | return strings.TrimSuffix(name, "-shm"), litefs.FileTypeSHM 41 | } else if strings.HasSuffix(name, "-pos") { 42 | return strings.TrimSuffix(name, "-pos"), litefs.FileTypePos 43 | } else if strings.HasSuffix(name, "-lock") { 44 | return strings.TrimSuffix(name, "-lock"), litefs.FileTypeLock 45 | } 46 | return name, litefs.FileTypeDatabase 47 | } 48 | 49 | // ToError converts an error to a wrapped error with a FUSE status code. 50 | func ToError(err error) error { 51 | if os.IsNotExist(err) { 52 | return &Error{err: err, errno: fuse.ToErrno(syscall.ENOENT)} 53 | } else if err == litefs.ErrReadOnlyReplica { 54 | return &Error{err: err, errno: fuse.ToErrno(syscall.EACCES)} 55 | } 56 | return err 57 | } 58 | 59 | // Error wraps an error to return a "No Entry" FUSE error. 60 | type Error struct { 61 | err error 62 | errno fuse.Errno 63 | } 64 | 65 | func (e *Error) Errno() fuse.Errno { return e.errno } 66 | func (e *Error) Error() string { return e.err.Error() } 67 | -------------------------------------------------------------------------------- /fuse/fuse_test.go: -------------------------------------------------------------------------------- 1 | package fuse_test 2 | 3 | import ( 4 | "errors" 5 | "flag" 6 | "log" 7 | "os" 8 | "syscall" 9 | "testing" 10 | 11 | _ "github.com/mattn/go-sqlite3" 12 | "github.com/superfly/litefs" 13 | "github.com/superfly/litefs/fuse" 14 | ) 15 | 16 | var ( 17 | fuseDebug = flag.Bool("fuse.debug", false, "enable fuse debugging") 18 | tracing = flag.Bool("tracing", false, "enable trace logging") 19 | long = flag.Bool("long", false, "run long-running tests") 20 | ) 21 | 22 | func init() { 23 | log.SetFlags(0) 24 | } 25 | 26 | func TestMain(m *testing.M) { 27 | flag.Parse() 28 | if *tracing { 29 | litefs.TraceLog = log.New(os.Stdout, "", litefs.TraceLogFlags) 30 | } 31 | os.Exit(m.Run()) 32 | } 33 | 34 | func TestFileTypeFilename(t *testing.T) { 35 | t.Run("OK", func(t *testing.T) { 36 | for _, tt := range []struct { 37 | input litefs.FileType 38 | output string 39 | }{ 40 | {litefs.FileTypeDatabase, "database"}, 41 | {litefs.FileTypeJournal, "journal"}, 42 | {litefs.FileTypeWAL, "wal"}, 43 | {litefs.FileTypeSHM, "shm"}, 44 | } { 45 | if got, want := fuse.FileTypeFilename(tt.input), tt.output; got != want { 46 | t.Fatalf("got=%q, want %q", got, want) 47 | } 48 | } 49 | }) 50 | 51 | t.Run("Panic", func(t *testing.T) { 52 | var r string 53 | func() { 54 | defer func() { r = recover().(string) }() 55 | fuse.FileTypeFilename(litefs.FileType(1000)) 56 | }() 57 | 58 | if got, want := r, `FileTypeFilename(): invalid file type: 1000`; got != want { 59 | t.Fatalf("panic=%q, got %v", got, want) 60 | } 61 | }) 62 | } 63 | 64 | func TestToErrno(t *testing.T) { 65 | t.Run("ENOENT", func(t *testing.T) { 66 | err := fuse.ToError(os.ErrNotExist).(*fuse.Error) 67 | if got, want := err.Error(), `file does not exist`; got != want { 68 | t.Fatalf("Error()=%q, want %q", got, want) 69 | } else if got, want := syscall.Errno(err.Errno()), syscall.ENOENT; got != want { 70 | t.Fatalf("Errno()=%v, want %v", got, want) 71 | } 72 | }) 73 | 74 | t.Run("EACCES", func(t *testing.T) { 75 | err := fuse.ToError(litefs.ErrReadOnlyReplica).(*fuse.Error) 76 | if got, want := err.Error(), `read only replica`; got != want { 77 | t.Fatalf("Error()=%q, want %q", got, want) 78 | } else if got, want := syscall.Errno(err.Errno()), syscall.EACCES; got != want { 79 | t.Fatalf("Errno()=%v, want %v", got, want) 80 | } 81 | }) 82 | 83 | t.Run("Passthrough", func(t *testing.T) { 84 | if _, ok := fuse.ToError(errors.New("marker")).(*fuse.Error); ok { 85 | t.Fatal("expected original error") 86 | } 87 | }) 88 | } 89 | 90 | func TestParseFilename(t *testing.T) { 91 | for _, tt := range []struct { 92 | input string 93 | dbName string 94 | fileType litefs.FileType 95 | }{ 96 | {"db", "db", litefs.FileTypeDatabase}, 97 | {"db-journal", "db", litefs.FileTypeJournal}, 98 | {"db-wal", "db", litefs.FileTypeWAL}, 99 | {"db-shm", "db", litefs.FileTypeSHM}, 100 | } { 101 | dbName, fileType := fuse.ParseFilename(tt.input) 102 | if got, want := dbName, tt.dbName; got != want { 103 | t.Fatalf("dbName=%q, want %q", got, want) 104 | } 105 | if got, want := fileType, tt.fileType; got != want { 106 | t.Fatalf("fileType=%v, want %v", got, want) 107 | } 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /fuse/journal_node.go: -------------------------------------------------------------------------------- 1 | package fuse 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "log" 8 | "os" 9 | "syscall" 10 | 11 | "bazil.org/fuse" 12 | "bazil.org/fuse/fs" 13 | "github.com/superfly/litefs" 14 | ) 15 | 16 | var _ fs.Node = (*JournalNode)(nil) 17 | var _ fs.NodeForgetter = (*JournalNode)(nil) 18 | var _ fs.NodeListxattrer = (*JournalNode)(nil) 19 | var _ fs.NodeGetxattrer = (*JournalNode)(nil) 20 | var _ fs.NodeSetxattrer = (*JournalNode)(nil) 21 | var _ fs.NodeRemovexattrer = (*JournalNode)(nil) 22 | var _ fs.NodePoller = (*JournalNode)(nil) 23 | 24 | // JournalNode represents a SQLite rollback journal file. 25 | type JournalNode struct { 26 | fsys *FileSystem 27 | db *litefs.DB 28 | } 29 | 30 | func newJournalNode(fsys *FileSystem, db *litefs.DB) *JournalNode { 31 | return &JournalNode{fsys: fsys, db: db} 32 | } 33 | 34 | func (n *JournalNode) Attr(ctx context.Context, attr *fuse.Attr) error { 35 | fi, err := os.Stat(n.db.JournalPath()) 36 | if os.IsNotExist(err) { 37 | return syscall.ENOENT 38 | } else if err != nil { 39 | return err 40 | } 41 | 42 | attr.Mode = 0666 43 | attr.Size = uint64(fi.Size()) 44 | attr.Uid = uint32(n.fsys.Uid) 45 | attr.Gid = uint32(n.fsys.Gid) 46 | attr.Valid = 0 47 | return nil 48 | } 49 | 50 | func (n *JournalNode) Open(ctx context.Context, req *fuse.OpenRequest, resp *fuse.OpenResponse) (fs.Handle, error) { 51 | resp.Flags |= fuse.OpenKeepCache 52 | 53 | f, err := n.db.OpenJournal(ctx) 54 | if os.IsNotExist(err) { 55 | return nil, syscall.ENOENT 56 | } else if err != nil { 57 | return nil, err 58 | } 59 | return newJournalHandle(n, f), nil 60 | } 61 | 62 | // Fsync performs an fsync() on the underlying file. 63 | func (n *JournalNode) Fsync(ctx context.Context, req *fuse.FsyncRequest) error { 64 | return n.db.SyncJournal(ctx) 65 | } 66 | 67 | func (n *JournalNode) Setattr(ctx context.Context, req *fuse.SetattrRequest, resp *fuse.SetattrResponse) error { 68 | // Only allow size updates. 69 | if req.Valid.Size() { 70 | if req.Size != 0 { 71 | return syscall.EINVAL 72 | } 73 | if err := n.db.TruncateJournal(ctx); err != nil { 74 | return fmt.Errorf("truncate journal: %w", err) 75 | } 76 | } 77 | 78 | return n.Attr(ctx, &resp.Attr) 79 | } 80 | 81 | func (n *JournalNode) Forget() { n.fsys.root.ForgetNode(n) } 82 | 83 | // ENOSYS is a special return code for xattr requests that will be treated as a permanent failure for any such 84 | // requests in the future without being sent to the filesystem. 85 | // Source: https://github.com/libfuse/libfuse/blob/0b6d97cf5938f6b4885e487c3bd7b02144b1ea56/include/fuse_lowlevel.h#L811 86 | 87 | func (n *JournalNode) Listxattr(ctx context.Context, req *fuse.ListxattrRequest, resp *fuse.ListxattrResponse) error { 88 | return fuse.ToErrno(syscall.ENOSYS) 89 | } 90 | 91 | func (n *JournalNode) Getxattr(ctx context.Context, req *fuse.GetxattrRequest, resp *fuse.GetxattrResponse) error { 92 | return fuse.ToErrno(syscall.ENOSYS) 93 | } 94 | 95 | func (n *JournalNode) Setxattr(ctx context.Context, req *fuse.SetxattrRequest) error { 96 | return fuse.ToErrno(syscall.ENOSYS) 97 | } 98 | 99 | func (n *JournalNode) Removexattr(ctx context.Context, req *fuse.RemovexattrRequest) error { 100 | return fuse.ToErrno(syscall.ENOSYS) 101 | } 102 | 103 | func (n *JournalNode) Poll(ctx context.Context, req *fuse.PollRequest, resp *fuse.PollResponse) error { 104 | return fuse.Errno(syscall.ENOSYS) 105 | } 106 | 107 | var _ fs.Handle = (*JournalHandle)(nil) 108 | var _ fs.HandleReader = (*JournalHandle)(nil) 109 | var _ fs.HandleWriter = (*JournalHandle)(nil) 110 | 111 | // JournalHandle represents a file handle to a SQLite journal file. 112 | type JournalHandle struct { 113 | node *JournalNode 114 | file *os.File 115 | } 116 | 117 | func newJournalHandle(node *JournalNode, file *os.File) *JournalHandle { 118 | return &JournalHandle{node: node, file: file} 119 | } 120 | 121 | func (h *JournalHandle) Read(ctx context.Context, req *fuse.ReadRequest, resp *fuse.ReadResponse) error { 122 | n, err := h.node.db.ReadJournalAt(ctx, h.file, resp.Data[:req.Size], req.Offset, uint64(req.LockOwner)) 123 | if err == io.EOF { 124 | err = nil 125 | } 126 | resp.Data = resp.Data[:n] 127 | return err 128 | } 129 | 130 | func (h *JournalHandle) Write(ctx context.Context, req *fuse.WriteRequest, resp *fuse.WriteResponse) error { 131 | if err := h.node.db.WriteJournalAt(ctx, h.file, req.Data, req.Offset, uint64(req.LockOwner)); err != nil { 132 | log.Printf("fuse: write(): journal error: %s", err) 133 | return ToError(err) 134 | } 135 | resp.Size = len(req.Data) 136 | return nil 137 | } 138 | 139 | func (h *JournalHandle) Release(ctx context.Context, req *fuse.ReleaseRequest) error { 140 | _ = h.node.db.CloseJournal(ctx, h.file, uint64(req.LockOwner)) 141 | return nil 142 | } 143 | -------------------------------------------------------------------------------- /fuse/lag_node.go: -------------------------------------------------------------------------------- 1 | package fuse 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "math" 7 | "syscall" 8 | "time" 9 | 10 | "bazil.org/fuse" 11 | "bazil.org/fuse/fs" 12 | ) 13 | 14 | const LagFilename = ".lag" 15 | 16 | // The lag file is constantly changing, but we need to be able to 17 | // say how big it is. So, we add leading zeros and always include 18 | // the sign to give it a fixed size. 19 | // 20 | // var ( 21 | // maxLagDigits = len(fmt.Sprintf("%d", math.MaxInt32)) 22 | // lagFmt = fmt.Sprintf("%%+0%dd\n", maxLagDigits) 23 | // lagSize = len(fmt.Sprintf(lagFmt, 0)) 24 | // ) 25 | const ( 26 | lagFmt = "%+010d\n" 27 | lagSize = 11 28 | ) 29 | 30 | var _ fs.Node = (*LagNode)(nil) 31 | var _ fs.NodeForgetter = (*LagNode)(nil) 32 | var _ fs.HandleReadAller = (*LagNode)(nil) 33 | var _ fs.NodeListxattrer = (*PosNode)(nil) 34 | var _ fs.NodeGetxattrer = (*PosNode)(nil) 35 | var _ fs.NodeSetxattrer = (*PosNode)(nil) 36 | var _ fs.NodeRemovexattrer = (*PosNode)(nil) 37 | var _ fs.NodePoller = (*PosNode)(nil) 38 | 39 | type LagNode struct { 40 | fsys *FileSystem 41 | } 42 | 43 | func newLagNode(fsys *FileSystem) *LagNode { 44 | return &LagNode{fsys: fsys} 45 | } 46 | 47 | func (n *LagNode) Attr(ctx context.Context, attr *fuse.Attr) error { 48 | attr.Mode = 0o444 49 | attr.Uid = uint32(n.fsys.Uid) 50 | attr.Gid = uint32(n.fsys.Gid) 51 | attr.Valid = 0 52 | attr.Size = lagSize 53 | 54 | switch ts := n.fsys.store.PrimaryTimestamp(); ts { 55 | case 0: // we're the primary 56 | attr.Mtime = time.Now() 57 | case -1: // haven't finished initial replication 58 | attr.Mtime = time.UnixMilli(0) 59 | default: 60 | attr.Mtime = time.UnixMilli(ts) 61 | } 62 | 63 | return nil 64 | } 65 | 66 | func (n *LagNode) Forget() { n.fsys.root.ForgetNode(n) } 67 | 68 | func (n *LagNode) ReadAll(ctx context.Context) ([]byte, error) { 69 | switch ts := n.fsys.store.PrimaryTimestamp(); ts { 70 | case 0: 71 | return []byte(fmt.Sprintf(lagFmt, 0)), nil 72 | case -1: 73 | return []byte(fmt.Sprintf(lagFmt, math.MaxInt32)), nil 74 | default: 75 | return []byte(fmt.Sprintf(lagFmt, time.Now().UnixMilli()-ts)), nil 76 | } 77 | } 78 | 79 | // ENOSYS is a special return code for xattr requests that will be treated as a permanent failure for any such 80 | // requests in the future without being sent to the filesystem. 81 | // Source: https://github.com/libfuse/libfuse/blob/0b6d97cf5938f6b4885e487c3bd7b02144b1ea56/include/fuse_lowlevel.h#L811 82 | 83 | func (n *LagNode) Listxattr(ctx context.Context, req *fuse.ListxattrRequest, resp *fuse.ListxattrResponse) error { 84 | return fuse.ToErrno(syscall.ENOSYS) 85 | } 86 | 87 | func (n *LagNode) Getxattr(ctx context.Context, req *fuse.GetxattrRequest, resp *fuse.GetxattrResponse) error { 88 | return fuse.ToErrno(syscall.ENOSYS) 89 | } 90 | 91 | func (n *LagNode) Setxattr(ctx context.Context, req *fuse.SetxattrRequest) error { 92 | return fuse.ToErrno(syscall.ENOSYS) 93 | } 94 | 95 | func (n *LagNode) Removexattr(ctx context.Context, req *fuse.RemovexattrRequest) error { 96 | return fuse.ToErrno(syscall.ENOSYS) 97 | } 98 | 99 | func (n *LagNode) Poll(ctx context.Context, req *fuse.PollRequest, resp *fuse.PollResponse) error { 100 | return fuse.Errno(syscall.ENOSYS) 101 | } 102 | -------------------------------------------------------------------------------- /fuse/lock_node.go: -------------------------------------------------------------------------------- 1 | package fuse 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "log" 7 | "math/rand" 8 | "sync" 9 | "sync/atomic" 10 | "syscall" 11 | 12 | "bazil.org/fuse" 13 | "bazil.org/fuse/fs" 14 | "github.com/superfly/litefs" 15 | ) 16 | 17 | var _ fs.Node = (*LockNode)(nil) 18 | var _ fs.NodeOpener = (*LockNode)(nil) 19 | var _ fs.NodeForgetter = (*LockNode)(nil) 20 | var _ fs.NodeListxattrer = (*LockNode)(nil) 21 | var _ fs.NodeGetxattrer = (*LockNode)(nil) 22 | var _ fs.NodeSetxattrer = (*LockNode)(nil) 23 | var _ fs.NodeRemovexattrer = (*LockNode)(nil) 24 | var _ fs.NodePoller = (*LockNode)(nil) 25 | 26 | // LockNode represents a file used for non-standard SQLite locks. A separate 27 | // file is needed because SQLite does not use OFD locks so cloing a handle will 28 | // also release all locks. 29 | type LockNode struct { 30 | fsys *FileSystem 31 | db *litefs.DB 32 | } 33 | 34 | func newLockNode(fsys *FileSystem, db *litefs.DB) *LockNode { 35 | return &LockNode{ 36 | fsys: fsys, 37 | db: db, 38 | } 39 | } 40 | 41 | func (n *LockNode) Attr(ctx context.Context, attr *fuse.Attr) error { 42 | attr.Mode = 0444 43 | attr.Size = 0 44 | attr.Uid = uint32(n.fsys.Uid) 45 | attr.Gid = uint32(n.fsys.Gid) 46 | attr.Valid = 0 47 | return nil 48 | } 49 | 50 | func (n *LockNode) Open(ctx context.Context, req *fuse.OpenRequest, resp *fuse.OpenResponse) (fs.Handle, error) { 51 | return newLockHandle(n), nil 52 | } 53 | 54 | func (n *LockNode) Forget() { n.fsys.root.ForgetNode(n) } 55 | 56 | // ENOSYS is a special return code for xattr requests that will be treated as a permanent failure for any such 57 | // requests in the future without being sent to the filesystem. 58 | // Source: https://github.com/libfuse/libfuse/blob/0b6d97cf5938f6b4885e487c3bd7b02144b1ea56/include/fuse_lowlevel.h#L811 59 | 60 | func (n *LockNode) Listxattr(ctx context.Context, req *fuse.ListxattrRequest, resp *fuse.ListxattrResponse) error { 61 | return fuse.ToErrno(syscall.ENOSYS) 62 | } 63 | 64 | func (n *LockNode) Getxattr(ctx context.Context, req *fuse.GetxattrRequest, resp *fuse.GetxattrResponse) error { 65 | return fuse.ToErrno(syscall.ENOSYS) 66 | } 67 | 68 | func (n *LockNode) Setxattr(ctx context.Context, req *fuse.SetxattrRequest) error { 69 | return fuse.ToErrno(syscall.ENOSYS) 70 | } 71 | 72 | func (n *LockNode) Removexattr(ctx context.Context, req *fuse.RemovexattrRequest) error { 73 | return fuse.ToErrno(syscall.ENOSYS) 74 | } 75 | 76 | func (n *LockNode) Poll(ctx context.Context, req *fuse.PollRequest, resp *fuse.PollResponse) error { 77 | return fuse.Errno(syscall.ENOSYS) 78 | } 79 | 80 | var _ fs.Handle = (*LockHandle)(nil) 81 | var _ fs.HandleReader = (*LockHandle)(nil) 82 | var _ fs.HandleWriter = (*LockHandle)(nil) 83 | var _ fs.HandlePOSIXLocker = (*LockHandle)(nil) 84 | 85 | // LockHandle represents a file handle to a SQLite database file. 86 | type LockHandle struct { 87 | node *LockNode 88 | 89 | haltLockID int64 90 | haltLock *litefs.HaltLock 91 | haltLockMu sync.Mutex 92 | haltLockCancel atomic.Value 93 | } 94 | 95 | func newLockHandle(node *LockNode) *LockHandle { 96 | h := &LockHandle{ 97 | node: node, 98 | haltLockID: rand.Int63(), 99 | } 100 | h.haltLockCancel.Store(context.CancelFunc(func() {})) 101 | return h 102 | } 103 | 104 | func (h *LockHandle) Read(ctx context.Context, req *fuse.ReadRequest, resp *fuse.ReadResponse) error { 105 | resp.Data = resp.Data[:0] 106 | return nil 107 | } 108 | 109 | func (h *LockHandle) Write(ctx context.Context, req *fuse.WriteRequest, resp *fuse.WriteResponse) error { 110 | log.Printf("fuse write error: cannot write to lock file") 111 | return syscall.EIO 112 | } 113 | 114 | func (h *LockHandle) Flush(ctx context.Context, req *fuse.FlushRequest) error { 115 | return h.unlockHalt(ctx) 116 | } 117 | 118 | func (h *LockHandle) Release(ctx context.Context, req *fuse.ReleaseRequest) error { 119 | return nil 120 | } 121 | 122 | func (h *LockHandle) Lock(ctx context.Context, req *fuse.LockRequest) error { 123 | return syscall.ENOSYS 124 | } 125 | 126 | func (h *LockHandle) LockWait(ctx context.Context, req *fuse.LockWaitRequest) (err error) { 127 | if req.Lock.Start != req.Lock.End { 128 | log.Printf("fuse lock error: only one lock can be acquired on the lock file at a time (%d..%d)", req.Lock.Start, req.Lock.End) 129 | return syscall.EINVAL 130 | } 131 | 132 | switch req.Lock.Start { 133 | case uint64(litefs.LockTypeHalt): 134 | return h.lockWaitHalt(ctx, req) 135 | default: 136 | log.Printf("fuse lock error: invalid lock file byte: %d", req.Lock.Start) 137 | return syscall.EINVAL 138 | } 139 | } 140 | 141 | func (h *LockHandle) lockWaitHalt(ctx context.Context, req *fuse.LockWaitRequest) (err error) { 142 | // Return an error this handle is already waiting for a halt lock. 143 | if !h.haltLockMu.TryLock() { 144 | log.Printf("lock wait error: handle is already waiting for halt lock") 145 | return syscall.ENOLCK 146 | } 147 | defer h.haltLockMu.Unlock() 148 | 149 | // Return an error if this handle is already holding a halt lock. 150 | if h.haltLock != nil { 151 | log.Printf("lock wait error: handle already acquired halt lock") 152 | return syscall.ENOLCK 153 | } 154 | 155 | // Ensure request is cancelable in case this handle is closed while we're waiting. 156 | ctx, cancel := context.WithCancel(ctx) 157 | defer cancel() 158 | h.haltLockCancel.Store(cancel) 159 | 160 | switch typ := req.Lock.Type; typ { 161 | case fuse.LockWrite: 162 | // Attempt to acquire the remote lock. Return EAGAIN if we timeout and 163 | // return no error if this node is already the primary. 164 | h.haltLock, err = h.node.db.AcquireRemoteHaltLock(ctx, h.haltLockID) 165 | if errors.Is(err, context.Canceled) { 166 | if err := ctx.Err(); err != nil { 167 | return syscall.EINTR 168 | } 169 | return syscall.EAGAIN 170 | } else if err != nil && err != litefs.ErrNoHaltPrimary { 171 | return err 172 | } 173 | return nil 174 | 175 | case fuse.LockRead: 176 | return syscall.ENOSYS 177 | 178 | case fuse.LockUnlock: // Handled via Unlock() method 179 | return nil 180 | 181 | default: 182 | panic("fuse.lockWait(): invalid POSIX lock type") 183 | } 184 | } 185 | 186 | func (h *LockHandle) Unlock(ctx context.Context, req *fuse.UnlockRequest) error { 187 | if req.Lock.Start != req.Lock.End { 188 | log.Printf("fuse unlock error: only one lock can be released on the lock file at a time (%d..%d)", req.Lock.Start, req.Lock.End) 189 | return syscall.EINVAL 190 | } 191 | 192 | switch req.Lock.Start { 193 | case uint64(litefs.LockTypeHalt): 194 | return h.unlockHalt(ctx) 195 | default: 196 | log.Printf("fuse unlock error: invalid lock file byte: %d", req.Lock.Start) 197 | return syscall.EINVAL 198 | } 199 | } 200 | 201 | func (h *LockHandle) unlockHalt(ctx context.Context) error { 202 | if cancel := h.haltLockCancel.Load().(context.CancelFunc); cancel != nil { 203 | cancel() 204 | } 205 | 206 | h.haltLockMu.Lock() 207 | defer h.haltLockMu.Unlock() 208 | 209 | if h.haltLock == nil { 210 | return nil 211 | } 212 | 213 | err := h.node.db.ReleaseRemoteHaltLock(ctx, h.haltLockID) 214 | if errors.Is(err, context.Canceled) && ctx.Err() != nil { 215 | return syscall.EINTR 216 | } 217 | h.haltLock = nil 218 | return err 219 | } 220 | 221 | func (h *LockHandle) QueryLock(ctx context.Context, req *fuse.QueryLockRequest, resp *fuse.QueryLockResponse) error { 222 | if req.Lock.Start != req.Lock.End { 223 | log.Printf("fuse query lock error: only one lock can be queried on the lock file at a time (%d..%d)", req.Lock.Start, req.Lock.End) 224 | return syscall.EINVAL 225 | } 226 | 227 | switch req.Lock.Start { 228 | case uint64(litefs.LockTypeHalt): 229 | if h.node.db.HasRemoteHaltLock() { 230 | resp.Lock = fuse.FileLock{ 231 | Start: req.Lock.Start, 232 | End: req.Lock.End, 233 | Type: fuse.LockWrite, 234 | PID: -1, 235 | } 236 | } 237 | return nil 238 | default: 239 | log.Printf("fuse query lock error: invalid lock file byte: %d", req.Lock.Start) 240 | return syscall.EINVAL 241 | } 242 | } 243 | -------------------------------------------------------------------------------- /fuse/pos_node.go: -------------------------------------------------------------------------------- 1 | package fuse 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "syscall" 8 | 9 | "bazil.org/fuse" 10 | "bazil.org/fuse/fs" 11 | "github.com/superfly/litefs" 12 | ) 13 | 14 | // PosFileSize is the size, in bytes, of the "-pos" file. 15 | const PosFileSize = 34 16 | 17 | var ( 18 | _ fs.Node = (*PosNode)(nil) 19 | _ fs.NodeOpener = (*PosNode)(nil) 20 | _ fs.NodeForgetter = (*PosNode)(nil) 21 | _ fs.NodeListxattrer = (*PosNode)(nil) 22 | _ fs.NodeGetxattrer = (*PosNode)(nil) 23 | _ fs.NodeSetxattrer = (*PosNode)(nil) 24 | _ fs.NodeRemovexattrer = (*PosNode)(nil) 25 | _ fs.NodePoller = (*PosNode)(nil) 26 | _ fs.HandleReader = (*PosNode)(nil) 27 | ) 28 | 29 | // PosNode represents a file that returns the current position of the database. 30 | type PosNode struct { 31 | fsys *FileSystem 32 | db *litefs.DB 33 | } 34 | 35 | func newPosNode(fsys *FileSystem, db *litefs.DB) *PosNode { 36 | return &PosNode{fsys: fsys, db: db} 37 | } 38 | 39 | func (n *PosNode) Attr(ctx context.Context, attr *fuse.Attr) error { 40 | attr.Mode = 0o666 41 | attr.Size = uint64(PosFileSize) 42 | attr.Uid = uint32(n.fsys.Uid) 43 | attr.Gid = uint32(n.fsys.Gid) 44 | attr.Valid = 0 45 | attr.Mtime = n.db.Timestamp() 46 | return nil 47 | } 48 | 49 | func (n *PosNode) Open(ctx context.Context, req *fuse.OpenRequest, resp *fuse.OpenResponse) (fs.Handle, error) { 50 | return n, nil 51 | } 52 | 53 | func (n *PosNode) Read(ctx context.Context, req *fuse.ReadRequest, resp *fuse.ReadResponse) error { 54 | pos := n.db.Pos() 55 | 56 | data := fmt.Sprintf("%s/%s\n", pos.TXID, pos.PostApplyChecksum) 57 | if req.Offset >= int64(len(data)) { 58 | return io.EOF 59 | } 60 | 61 | // Size buffer to offset/size. 62 | data = data[req.Offset:] 63 | if len(data) > req.Size { 64 | data = data[:req.Size] 65 | } 66 | resp.Data = []byte(data) 67 | 68 | return nil 69 | } 70 | 71 | func (n *PosNode) Forget() { n.fsys.root.ForgetNode(n) } 72 | 73 | // ENOSYS is a special return code for xattr requests that will be treated as a permanent failure for any such 74 | // requests in the future without being sent to the filesystem. 75 | // Source: https://github.com/libfuse/libfuse/blob/0b6d97cf5938f6b4885e487c3bd7b02144b1ea56/include/fuse_lowlevel.h#L811 76 | 77 | func (n *PosNode) Listxattr(ctx context.Context, req *fuse.ListxattrRequest, resp *fuse.ListxattrResponse) error { 78 | return fuse.ToErrno(syscall.ENOSYS) 79 | } 80 | 81 | func (n *PosNode) Getxattr(ctx context.Context, req *fuse.GetxattrRequest, resp *fuse.GetxattrResponse) error { 82 | return fuse.ToErrno(syscall.ENOSYS) 83 | } 84 | 85 | func (n *PosNode) Setxattr(ctx context.Context, req *fuse.SetxattrRequest) error { 86 | return fuse.ToErrno(syscall.ENOSYS) 87 | } 88 | 89 | func (n *PosNode) Removexattr(ctx context.Context, req *fuse.RemovexattrRequest) error { 90 | return fuse.ToErrno(syscall.ENOSYS) 91 | } 92 | 93 | func (n *PosNode) Poll(ctx context.Context, req *fuse.PollRequest, resp *fuse.PollResponse) error { 94 | return fuse.Errno(syscall.ENOSYS) 95 | } 96 | -------------------------------------------------------------------------------- /fuse/primary_node.go: -------------------------------------------------------------------------------- 1 | package fuse 2 | 3 | import ( 4 | "context" 5 | "syscall" 6 | 7 | "bazil.org/fuse" 8 | "bazil.org/fuse/fs" 9 | ) 10 | 11 | // PrimaryFilename is the name of the file that holds the current primary. 12 | const PrimaryFilename = ".primary" 13 | 14 | var _ fs.Node = (*PrimaryNode)(nil) 15 | var _ fs.NodeForgetter = (*PrimaryNode)(nil) 16 | var _ fs.NodeListxattrer = (*PrimaryNode)(nil) 17 | var _ fs.NodeGetxattrer = (*PrimaryNode)(nil) 18 | var _ fs.NodeSetxattrer = (*PrimaryNode)(nil) 19 | var _ fs.NodeRemovexattrer = (*PrimaryNode)(nil) 20 | var _ fs.NodePoller = (*PrimaryNode)(nil) 21 | var _ fs.HandleReadAller = (*PrimaryNode)(nil) 22 | 23 | // PrimaryNode represents a file for returning the current primary node. 24 | type PrimaryNode struct { 25 | fsys *FileSystem 26 | } 27 | 28 | func newPrimaryNode(fsys *FileSystem) *PrimaryNode { 29 | return &PrimaryNode{fsys: fsys} 30 | } 31 | 32 | func (n *PrimaryNode) Attr(ctx context.Context, attr *fuse.Attr) error { 33 | _, info := n.fsys.store.PrimaryInfo() 34 | if info == nil { 35 | return fuse.Errno(syscall.ENOENT) 36 | } 37 | 38 | attr.Mode = 0444 39 | attr.Size = uint64(len(info.Hostname) + 1) 40 | attr.Uid = uint32(n.fsys.Uid) 41 | attr.Gid = uint32(n.fsys.Gid) 42 | attr.Valid = 0 43 | return nil 44 | } 45 | 46 | func (n *PrimaryNode) ReadAll(ctx context.Context) ([]byte, error) { 47 | _, info := n.fsys.store.PrimaryInfo() 48 | if info == nil { 49 | return nil, fuse.Errno(syscall.ENOENT) 50 | } 51 | return []byte(info.Hostname + "\n"), nil 52 | } 53 | 54 | func (n *PrimaryNode) Forget() { n.fsys.root.ForgetNode(n) } 55 | 56 | // ENOSYS is a special return code for xattr requests that will be treated as a permanent failure for any such 57 | // requests in the future without being sent to the filesystem. 58 | // Source: https://github.com/libfuse/libfuse/blob/0b6d97cf5938f6b4885e487c3bd7b02144b1ea56/include/fuse_lowlevel.h#L811 59 | 60 | func (n *PrimaryNode) Listxattr(ctx context.Context, req *fuse.ListxattrRequest, resp *fuse.ListxattrResponse) error { 61 | return fuse.ToErrno(syscall.ENOSYS) 62 | } 63 | 64 | func (n *PrimaryNode) Getxattr(ctx context.Context, req *fuse.GetxattrRequest, resp *fuse.GetxattrResponse) error { 65 | return fuse.ToErrno(syscall.ENOSYS) 66 | } 67 | 68 | func (n *PrimaryNode) Setxattr(ctx context.Context, req *fuse.SetxattrRequest) error { 69 | return fuse.ToErrno(syscall.ENOSYS) 70 | } 71 | 72 | func (n *PrimaryNode) Removexattr(ctx context.Context, req *fuse.RemovexattrRequest) error { 73 | return fuse.ToErrno(syscall.ENOSYS) 74 | } 75 | 76 | func (n *PrimaryNode) Poll(ctx context.Context, req *fuse.PollRequest, resp *fuse.PollResponse) error { 77 | return fuse.Errno(syscall.ENOSYS) 78 | } 79 | -------------------------------------------------------------------------------- /fuse/shm_node.go: -------------------------------------------------------------------------------- 1 | package fuse 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "log" 7 | "os" 8 | "syscall" 9 | 10 | "bazil.org/fuse" 11 | "bazil.org/fuse/fs" 12 | "github.com/superfly/litefs" 13 | ) 14 | 15 | var ( 16 | _ fs.Node = (*SHMNode)(nil) 17 | _ fs.NodeOpener = (*SHMNode)(nil) 18 | _ fs.NodeFsyncer = (*SHMNode)(nil) 19 | _ fs.NodeForgetter = (*SHMNode)(nil) 20 | _ fs.NodeListxattrer = (*SHMNode)(nil) 21 | _ fs.NodeGetxattrer = (*SHMNode)(nil) 22 | _ fs.NodeSetxattrer = (*SHMNode)(nil) 23 | _ fs.NodeRemovexattrer = (*SHMNode)(nil) 24 | _ fs.NodePoller = (*SHMNode)(nil) 25 | ) 26 | 27 | // SHMNode represents a SQLite database file. 28 | type SHMNode struct { 29 | fsys *FileSystem 30 | db *litefs.DB 31 | } 32 | 33 | func newSHMNode(fsys *FileSystem, db *litefs.DB) *SHMNode { 34 | return &SHMNode{ 35 | fsys: fsys, 36 | db: db, 37 | } 38 | } 39 | 40 | func (n *SHMNode) Attr(ctx context.Context, attr *fuse.Attr) error { 41 | fi, err := os.Stat(n.db.SHMPath()) 42 | if os.IsNotExist(err) { 43 | return syscall.ENOENT 44 | } else if err != nil { 45 | return err 46 | } 47 | 48 | attr.Mode = 0o666 49 | attr.Size = uint64(fi.Size()) 50 | attr.Uid = uint32(n.fsys.Uid) 51 | attr.Gid = uint32(n.fsys.Gid) 52 | attr.Valid = 0 53 | return nil 54 | } 55 | 56 | func (n *SHMNode) Setattr(ctx context.Context, req *fuse.SetattrRequest, resp *fuse.SetattrResponse) error { 57 | if req.Valid.Size() { 58 | if err := n.db.TruncateSHM(ctx, int64(req.Size)); err != nil { 59 | return err 60 | } 61 | } 62 | return n.Attr(ctx, &resp.Attr) 63 | } 64 | 65 | func (n *SHMNode) Open(ctx context.Context, req *fuse.OpenRequest, resp *fuse.OpenResponse) (fs.Handle, error) { 66 | resp.Flags |= fuse.OpenKeepCache 67 | 68 | f, err := n.db.OpenSHM(ctx) 69 | if err != nil { 70 | return nil, err 71 | } 72 | return newSHMHandle(n, f), nil 73 | } 74 | 75 | func (n *SHMNode) Fsync(ctx context.Context, req *fuse.FsyncRequest) error { 76 | return n.db.SyncSHM(ctx) 77 | } 78 | 79 | func (n *SHMNode) Forget() { n.fsys.root.ForgetNode(n) } 80 | 81 | // ENOSYS is a special return code for xattr requests that will be treated as a permanent failure for any such 82 | // requests in the future without being sent to the filesystem. 83 | // Source: https://github.com/libfuse/libfuse/blob/0b6d97cf5938f6b4885e487c3bd7b02144b1ea56/include/fuse_lowlevel.h#L811 84 | 85 | func (n *SHMNode) Listxattr(ctx context.Context, req *fuse.ListxattrRequest, resp *fuse.ListxattrResponse) error { 86 | return fuse.ToErrno(syscall.ENOSYS) 87 | } 88 | 89 | func (n *SHMNode) Getxattr(ctx context.Context, req *fuse.GetxattrRequest, resp *fuse.GetxattrResponse) error { 90 | return fuse.ToErrno(syscall.ENOSYS) 91 | } 92 | 93 | func (n *SHMNode) Setxattr(ctx context.Context, req *fuse.SetxattrRequest) error { 94 | return fuse.ToErrno(syscall.ENOSYS) 95 | } 96 | 97 | func (n *SHMNode) Removexattr(ctx context.Context, req *fuse.RemovexattrRequest) error { 98 | return fuse.ToErrno(syscall.ENOSYS) 99 | } 100 | 101 | func (n *SHMNode) Poll(ctx context.Context, req *fuse.PollRequest, resp *fuse.PollResponse) error { 102 | return fuse.Errno(syscall.ENOSYS) 103 | } 104 | 105 | var ( 106 | _ fs.Handle = (*SHMHandle)(nil) 107 | _ fs.HandleReader = (*SHMHandle)(nil) 108 | _ fs.HandleWriter = (*SHMHandle)(nil) 109 | _ fs.HandlePOSIXLocker = (*SHMHandle)(nil) 110 | _ fs.HandleFlusher = (*SHMHandle)(nil) 111 | _ fs.HandleReleaser = (*SHMHandle)(nil) 112 | ) 113 | 114 | // SHMHandle represents a file handle to a SQLite database file. 115 | type SHMHandle struct { 116 | node *SHMNode 117 | file *os.File 118 | } 119 | 120 | func newSHMHandle(node *SHMNode, file *os.File) *SHMHandle { 121 | return &SHMHandle{node: node, file: file} 122 | } 123 | 124 | func (h *SHMHandle) Read(ctx context.Context, req *fuse.ReadRequest, resp *fuse.ReadResponse) error { 125 | n, err := h.node.db.ReadSHMAt(ctx, h.file, resp.Data[:req.Size], req.Offset, uint64(req.LockOwner)) 126 | if err == io.EOF { 127 | err = nil 128 | } 129 | resp.Data = resp.Data[:n] 130 | return err 131 | } 132 | 133 | func (h *SHMHandle) Write(ctx context.Context, req *fuse.WriteRequest, resp *fuse.WriteResponse) error { 134 | n, err := h.node.db.WriteSHMAt(ctx, h.file, req.Data, req.Offset, uint64(req.LockOwner)) 135 | resp.Size = n 136 | if err != nil { 137 | log.Printf("fuse: write(): shm error: %s", err) 138 | return err 139 | } 140 | return nil 141 | } 142 | 143 | func (h *SHMHandle) Flush(ctx context.Context, req *fuse.FlushRequest) error { 144 | h.node.db.UnlockSHM(ctx, uint64(req.LockOwner)) 145 | return nil 146 | } 147 | 148 | func (h *SHMHandle) Release(ctx context.Context, req *fuse.ReleaseRequest) error { 149 | return h.node.db.CloseSHM(ctx, h.file, uint64(req.LockOwner)) 150 | } 151 | 152 | func (h *SHMHandle) Lock(ctx context.Context, req *fuse.LockRequest) error { 153 | lockTypes := litefs.ParseSHMLockRange(req.Lock.Start, req.Lock.End) 154 | return lock(ctx, req, h.node.db, lockTypes) 155 | } 156 | 157 | func (h *SHMHandle) LockWait(ctx context.Context, req *fuse.LockWaitRequest) error { 158 | return fuse.Errno(syscall.ENOSYS) 159 | } 160 | 161 | func (h *SHMHandle) Unlock(ctx context.Context, req *fuse.UnlockRequest) (err error) { 162 | lockTypes := litefs.ParseSHMLockRange(req.Lock.Start, req.Lock.End) 163 | return h.node.db.Unlock(ctx, uint64(req.LockOwner), lockTypes) 164 | } 165 | 166 | func (h *SHMHandle) QueryLock(ctx context.Context, req *fuse.QueryLockRequest, resp *fuse.QueryLockResponse) error { 167 | lockTypes := litefs.ParseSHMLockRange(req.Lock.Start, req.Lock.End) 168 | queryLock(ctx, req, resp, h.node.db, lockTypes) 169 | return nil 170 | } 171 | -------------------------------------------------------------------------------- /fuse/wal_node.go: -------------------------------------------------------------------------------- 1 | package fuse 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "log" 7 | "os" 8 | "syscall" 9 | 10 | "bazil.org/fuse" 11 | "bazil.org/fuse/fs" 12 | "github.com/superfly/litefs" 13 | ) 14 | 15 | var _ fs.Node = (*WALNode)(nil) 16 | var _ fs.NodeOpener = (*WALNode)(nil) 17 | var _ fs.NodeFsyncer = (*WALNode)(nil) 18 | var _ fs.NodeForgetter = (*WALNode)(nil) 19 | var _ fs.NodeListxattrer = (*WALNode)(nil) 20 | var _ fs.NodeGetxattrer = (*WALNode)(nil) 21 | var _ fs.NodeSetxattrer = (*WALNode)(nil) 22 | var _ fs.NodeRemovexattrer = (*WALNode)(nil) 23 | var _ fs.NodePoller = (*WALNode)(nil) 24 | 25 | // WALNode represents a SQLite WAL file. 26 | type WALNode struct { 27 | fsys *FileSystem 28 | db *litefs.DB 29 | } 30 | 31 | func newWALNode(fsys *FileSystem, db *litefs.DB) *WALNode { 32 | return &WALNode{ 33 | fsys: fsys, 34 | db: db, 35 | } 36 | } 37 | 38 | func (n *WALNode) Attr(ctx context.Context, attr *fuse.Attr) error { 39 | fi, err := os.Stat(n.db.WALPath()) 40 | if os.IsNotExist(err) { 41 | return syscall.ENOENT 42 | } else if err != nil { 43 | return err 44 | } 45 | 46 | attr.Mode = 0666 47 | attr.Size = uint64(fi.Size()) 48 | attr.Uid = uint32(n.fsys.Uid) 49 | attr.Gid = uint32(n.fsys.Gid) 50 | attr.Valid = 0 51 | return nil 52 | } 53 | 54 | func (n *WALNode) Setattr(ctx context.Context, req *fuse.SetattrRequest, resp *fuse.SetattrResponse) error { 55 | if req.Valid.Size() { 56 | if err := n.db.TruncateWAL(ctx, int64(req.Size)); err != nil { 57 | return err 58 | } 59 | } 60 | return n.Attr(ctx, &resp.Attr) 61 | } 62 | 63 | func (n *WALNode) Open(ctx context.Context, req *fuse.OpenRequest, resp *fuse.OpenResponse) (fs.Handle, error) { 64 | resp.Flags |= fuse.OpenKeepCache 65 | 66 | f, err := n.db.OpenWAL(ctx) 67 | if err != nil { 68 | return nil, err 69 | } 70 | return newWALHandle(n, f), nil 71 | } 72 | 73 | func (n *WALNode) Fsync(ctx context.Context, req *fuse.FsyncRequest) error { 74 | return n.db.SyncWAL(ctx) 75 | } 76 | 77 | func (n *WALNode) Forget() { n.fsys.root.ForgetNode(n) } 78 | 79 | // ENOSYS is a special return code for xattr requests that will be treated as a permanent failure for any such 80 | // requests in the future without being sent to the filesystem. 81 | // Source: https://github.com/libfuse/libfuse/blob/0b6d97cf5938f6b4885e487c3bd7b02144b1ea56/include/fuse_lowlevel.h#L811 82 | 83 | func (n *WALNode) Listxattr(ctx context.Context, req *fuse.ListxattrRequest, resp *fuse.ListxattrResponse) error { 84 | return fuse.ToErrno(syscall.ENOSYS) 85 | } 86 | 87 | func (n *WALNode) Getxattr(ctx context.Context, req *fuse.GetxattrRequest, resp *fuse.GetxattrResponse) error { 88 | return fuse.ToErrno(syscall.ENOSYS) 89 | } 90 | 91 | func (n *WALNode) Setxattr(ctx context.Context, req *fuse.SetxattrRequest) error { 92 | return fuse.ToErrno(syscall.ENOSYS) 93 | } 94 | 95 | func (n *WALNode) Removexattr(ctx context.Context, req *fuse.RemovexattrRequest) error { 96 | return fuse.ToErrno(syscall.ENOSYS) 97 | } 98 | 99 | func (n *WALNode) Poll(ctx context.Context, req *fuse.PollRequest, resp *fuse.PollResponse) error { 100 | return fuse.Errno(syscall.ENOSYS) 101 | } 102 | 103 | var _ fs.Handle = (*WALHandle)(nil) 104 | var _ fs.HandleReader = (*WALHandle)(nil) 105 | var _ fs.HandleWriter = (*WALHandle)(nil) 106 | 107 | // WALHandle represents a file handle to a SQLite WAL file. 108 | type WALHandle struct { 109 | node *WALNode 110 | file *os.File 111 | } 112 | 113 | func newWALHandle(node *WALNode, file *os.File) *WALHandle { 114 | return &WALHandle{node: node, file: file} 115 | } 116 | 117 | func (h *WALHandle) Read(ctx context.Context, req *fuse.ReadRequest, resp *fuse.ReadResponse) error { 118 | n, err := h.node.db.ReadWALAt(ctx, h.file, resp.Data[:req.Size], req.Offset, uint64(req.LockOwner)) 119 | if err == io.EOF { 120 | err = nil 121 | } 122 | resp.Data = resp.Data[:n] 123 | return err 124 | } 125 | 126 | func (h *WALHandle) Write(ctx context.Context, req *fuse.WriteRequest, resp *fuse.WriteResponse) error { 127 | // TODO(wal): Generate SQLITE_READONLY for WAL. 128 | if err := h.node.db.WriteWALAt(ctx, h.file, req.Data, req.Offset, uint64(req.LockOwner)); err != nil { 129 | log.Printf("fuse: write(): wal error: %s", err) 130 | return ToError(err) 131 | } 132 | resp.Size = len(req.Data) 133 | return nil 134 | } 135 | 136 | func (h *WALHandle) Release(ctx context.Context, req *fuse.ReleaseRequest) error { 137 | return h.node.db.CloseWAL(ctx, h.file, uint64(req.LockOwner)) 138 | } 139 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/superfly/litefs 2 | 3 | go 1.21 4 | 5 | require ( 6 | bazil.org/fuse v0.0.0-20230120002735-62a210ff1fd5 7 | github.com/hashicorp/consul/api v1.11.0 8 | github.com/mattn/go-shellwords v1.0.12 9 | github.com/mattn/go-sqlite3 v1.14.16-0.20220918133448-90900be5db1a 10 | github.com/prometheus/client_golang v1.13.0 11 | github.com/superfly/litefs-go v0.0.0-20230227231337-34ea5dcf1e0b 12 | github.com/superfly/ltx v0.3.14 13 | golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc 14 | golang.org/x/net v0.17.0 15 | golang.org/x/sync v0.4.0 16 | golang.org/x/sys v0.13.0 17 | gopkg.in/natefinch/lumberjack.v2 v2.0.0 18 | gopkg.in/yaml.v3 v3.0.1 19 | ) 20 | 21 | require ( 22 | github.com/armon/go-metrics v0.3.10 // indirect 23 | github.com/beorn7/perks v1.0.1 // indirect 24 | github.com/cespare/xxhash/v2 v2.1.2 // indirect 25 | github.com/fatih/color v1.9.0 // indirect 26 | github.com/golang/protobuf v1.5.2 // indirect 27 | github.com/hashicorp/go-cleanhttp v0.5.1 // indirect 28 | github.com/hashicorp/go-hclog v0.14.1 // indirect 29 | github.com/hashicorp/go-immutable-radix v1.3.0 // indirect 30 | github.com/hashicorp/go-msgpack v0.5.5 // indirect 31 | github.com/hashicorp/go-multierror v1.1.1 // indirect 32 | github.com/hashicorp/go-rootcerts v1.0.2 // indirect 33 | github.com/hashicorp/go-sockaddr v1.0.2 // indirect 34 | github.com/hashicorp/go-uuid v1.0.2 // indirect 35 | github.com/hashicorp/golang-lru v0.5.4 // indirect 36 | github.com/hashicorp/memberlist v0.3.1 // indirect 37 | github.com/hashicorp/serf v0.9.7 // indirect 38 | github.com/mattn/go-colorable v0.1.6 // indirect 39 | github.com/mattn/go-isatty v0.0.12 // indirect 40 | github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect 41 | github.com/mitchellh/go-homedir v1.1.0 // indirect 42 | github.com/mitchellh/go-testing-interface v1.14.0 // indirect 43 | github.com/mitchellh/mapstructure v1.4.1 // indirect 44 | github.com/pierrec/lz4/v4 v4.1.17 // indirect 45 | github.com/prometheus/client_model v0.2.0 // indirect 46 | github.com/prometheus/common v0.37.0 // indirect 47 | github.com/prometheus/procfs v0.8.0 // indirect 48 | github.com/stretchr/testify v1.7.0 // indirect 49 | golang.org/x/text v0.13.0 // indirect 50 | google.golang.org/protobuf v1.28.1 // indirect 51 | ) 52 | 53 | // replace github.com/superfly/litefs-go => ../litefs-go 54 | // replace github.com/mattn/go-sqlite3 => ../../mattn/go-sqlite3 55 | // replace github.com/pierrec/lz4/v4 => ../../pierrec/lz4 56 | // replace github.com/superfly/ltx => ../ltx 57 | -------------------------------------------------------------------------------- /http/http.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "encoding/binary" 5 | "fmt" 6 | "io" 7 | "math" 8 | "regexp" 9 | "sort" 10 | "strings" 11 | 12 | "github.com/superfly/ltx" 13 | ) 14 | 15 | func ReadPosMapFrom(r io.Reader) (map[string]ltx.Pos, error) { 16 | // Read entry count. 17 | var n uint32 18 | if err := binary.Read(r, binary.BigEndian, &n); err != nil { 19 | return nil, err 20 | } 21 | 22 | // Read entries and insert into map. 23 | m := make(map[string]ltx.Pos, n) 24 | for i := uint32(0); i < n; i++ { 25 | var nameN uint32 26 | if err := binary.Read(r, binary.BigEndian, &nameN); err != nil { 27 | return nil, err 28 | } 29 | name := make([]byte, nameN) 30 | if _, err := io.ReadFull(r, name); err != nil { 31 | return nil, err 32 | } 33 | 34 | var pos ltx.Pos 35 | if err := binary.Read(r, binary.BigEndian, &pos.TXID); err != nil { 36 | return nil, err 37 | } else if err := binary.Read(r, binary.BigEndian, &pos.PostApplyChecksum); err != nil { 38 | return nil, err 39 | } 40 | m[string(name)] = pos 41 | } 42 | 43 | return m, nil 44 | } 45 | 46 | func WritePosMapTo(w io.Writer, m map[string]ltx.Pos) error { 47 | // Sort keys for consistent output. 48 | names := make([]string, 0, len(m)) 49 | for name := range m { 50 | names = append(names, name) 51 | } 52 | sort.Strings(names) 53 | 54 | // Write entry count. 55 | if err := binary.Write(w, binary.BigEndian, uint32(len(m))); err != nil { 56 | return err 57 | } 58 | 59 | // Write all entries in sorted order. 60 | for _, name := range names { 61 | pos := m[name] 62 | 63 | // This shouldn't occur but ensure that we don't have a 2GB+ database name. 64 | if len(name) > math.MaxInt32 { 65 | return fmt.Errorf("database name too long") 66 | } 67 | 68 | if err := binary.Write(w, binary.BigEndian, uint32(len(name))); err != nil { 69 | return err 70 | } else if _, err := w.Write([]byte(name)); err != nil { 71 | return err 72 | } 73 | 74 | if err := binary.Write(w, binary.BigEndian, pos.TXID); err != nil { 75 | return err 76 | } else if err := binary.Write(w, binary.BigEndian, pos.PostApplyChecksum); err != nil { 77 | return err 78 | } 79 | } 80 | 81 | return nil 82 | } 83 | 84 | // CompileMatch returns a regular expression on a simple asterisk-only wildcard. 85 | func CompileMatch(s string) (*regexp.Regexp, error) { 86 | // Convert any special characters to literal matches. 87 | s = regexp.QuoteMeta(s) 88 | 89 | // Convert escaped asterisks to wildcard matches. 90 | s = strings.ReplaceAll(s, `\*`, ".*") 91 | 92 | // Match to beginning & end of path. 93 | s = "^" + s + "$" 94 | 95 | return regexp.Compile(s) 96 | } 97 | -------------------------------------------------------------------------------- /http/http_test.go: -------------------------------------------------------------------------------- 1 | package http_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/superfly/litefs/http" 7 | ) 8 | 9 | func TestCompileMatch(t *testing.T) { 10 | for _, tt := range []struct { 11 | expr string 12 | str string 13 | matches bool 14 | }{ 15 | {"/build/*", "/build", false}, 16 | {"/build/*", "/build/", true}, 17 | {"/build/*", "/build/foo", true}, 18 | {"/build/*", "/build/foo/bar", true}, 19 | {"*.png", "/images/pic.png", true}, 20 | {"*foo*", "/foo", true}, 21 | {"*foo*", "foo/bar", true}, 22 | {"*foo*", "/foo/bar", true}, 23 | {"*foo*", "/bar/baz", false}, 24 | } { 25 | t.Run("", func(t *testing.T) { 26 | re, err := http.CompileMatch(tt.expr) 27 | if err != nil { 28 | t.Fatal(err) 29 | } 30 | matched := re.MatchString(tt.str) 31 | if tt.matches && !matched { 32 | t.Fatalf("expected %q to match %q, but didn't", tt.expr, tt.str) 33 | } else if !tt.matches && matched { 34 | t.Fatalf("expected %q to not match %q, but did", tt.expr, tt.str) 35 | } 36 | }) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /internal/chunk/chunk.go: -------------------------------------------------------------------------------- 1 | package chunk 2 | 3 | import ( 4 | "encoding/binary" 5 | "io" 6 | "math" 7 | ) 8 | 9 | // EOF is the end-of-file marker value for the size. 10 | const EOF = uint16(0x0000) 11 | 12 | // MaxChunkSize is the largest allowable chunk size (64KB). 13 | const MaxChunkSize = math.MaxUint16 14 | 15 | var _ io.Reader = (*Reader)(nil) 16 | 17 | // Reader wraps a stream of chunks and converts it into an io.Reader. 18 | // This is useful for byte streams where the size is not known beforehand. 19 | type Reader struct { 20 | r io.Reader // underlying reader 21 | b [MaxChunkSize]byte // underlying buffer 22 | buf []byte // current buffer 23 | eof bool // true for last chunk 24 | } 25 | 26 | // NewReader implements an io.Reader from a chunked byte stream. 27 | func NewReader(r io.Reader) *Reader { 28 | return &Reader{r: r} 29 | } 30 | 31 | func (r *Reader) Read(p []byte) (n int, err error) { 32 | // If we have bytes in the buffer, then copy them to p. 33 | if len(r.buf) > 0 { 34 | n = copy(p, r.buf) 35 | r.buf = r.buf[n:] 36 | return n, nil 37 | } 38 | 39 | // If no bytes are buffered and we are on the last chunk then return EOF. 40 | if r.eof { 41 | return 0, io.EOF 42 | } 43 | 44 | // Otherwise read next chunk and refill the buffer. 45 | var size uint16 46 | if err := binary.Read(r.r, binary.BigEndian, &size); err == io.EOF { 47 | return 0, io.ErrUnexpectedEOF 48 | } else if err != nil { 49 | return 0, err 50 | } 51 | 52 | // Exit if this is the closing EOF chunk. 53 | r.eof = size == EOF 54 | if r.eof { 55 | return 0, io.EOF 56 | } 57 | 58 | // The remaining bits are used for the chunk size (up to 64KB). 59 | r.buf = r.b[:size] 60 | if _, err := io.ReadFull(r.r, r.buf); err != nil { 61 | return 0, err 62 | } 63 | 64 | // Copy out as much to the buffer as possible. 65 | n = copy(p, r.buf) 66 | r.buf = r.buf[n:] 67 | return n, nil 68 | } 69 | 70 | var _ io.WriteCloser = (*Writer)(nil) 71 | 72 | // Writer wraps an io.Writer to convert it to a chunked byte stream. 73 | // This is useful for byte streams where the size is not known beforehand. 74 | type Writer struct { 75 | w io.Writer 76 | closed bool 77 | } 78 | 79 | // NewWriter implements an io.Writer from a chunked byte stream. 80 | func NewWriter(w io.Writer) *Writer { 81 | return &Writer{w: w} 82 | } 83 | 84 | // Close closes the writer and writes out a closing EOF chunk. 85 | func (w *Writer) Close() error { 86 | if w.closed { 87 | return nil // no-op double close 88 | } 89 | 90 | err := binary.Write(w.w, binary.BigEndian, EOF) 91 | w.closed = true 92 | return err 93 | } 94 | 95 | // Write writes p to the underlying writer with a chunk header. 96 | func (w *Writer) Write(p []byte) (n int, err error) { 97 | // Ignore empty byte slices as zero size is reserved for EOF. 98 | if len(p) == 0 { 99 | return 0, nil 100 | } 101 | 102 | for len(p) > 0 { 103 | // Write up to the max chunk size for any given chunk. 104 | chunk := p 105 | if len(chunk) > MaxChunkSize { 106 | chunk = chunk[:MaxChunkSize] 107 | } 108 | p = p[len(chunk):] 109 | 110 | // Write two bytes for the chunk length. 111 | if err := binary.Write(w.w, binary.BigEndian, uint16(len(chunk))); err != nil { 112 | return n, err 113 | } 114 | 115 | // Write the chunk itself. 116 | nn, err := w.w.Write(chunk) 117 | if n += nn; err != nil { 118 | return n, err 119 | } 120 | } 121 | 122 | return n, nil 123 | } 124 | -------------------------------------------------------------------------------- /internal/chunk/chunk_test.go: -------------------------------------------------------------------------------- 1 | package chunk_test 2 | 3 | import ( 4 | "bytes" 5 | crand "crypto/rand" 6 | "fmt" 7 | "io" 8 | "math/rand" 9 | "testing" 10 | 11 | "github.com/superfly/litefs/internal/chunk" 12 | ) 13 | 14 | func TestCopy(t *testing.T) { 15 | rand := rand.New(rand.NewSource(0)) 16 | 17 | var input, buf bytes.Buffer 18 | w := chunk.NewWriter(&buf) 19 | for i := 0; i < 1000; i++ { 20 | data := make([]byte, rand.Intn(10000)) 21 | _, _ = rand.Read(data) 22 | 23 | // Save data to a simple buffer. 24 | _, _ = input.Write(data) 25 | 26 | // Write to a chunked buffer. 27 | if n, err := w.Write(data); err != nil { 28 | t.Fatal() 29 | } else if got, want := n, len(data); got != want { 30 | t.Fatalf("len=%d, want %d", got, want) 31 | } 32 | } 33 | 34 | if err := w.Close(); err != nil { 35 | t.Fatal(err) 36 | } 37 | 38 | // Read bytes from the chunked buffer. 39 | output, err := io.ReadAll(chunk.NewReader(&buf)) 40 | if err != nil { 41 | t.Fatal(err) 42 | } else if got, want := len(output), input.Len(); got != want { 43 | t.Fatalf("len(output)=%d, want %d", got, want) 44 | } else if got, want := output, input.Bytes(); !bytes.Equal(got, want) { 45 | t.Fatalf("output does not match input") 46 | } 47 | 48 | t.Logf("total bytes: %d", len(output)) 49 | } 50 | 51 | func TestWriter_Write(t *testing.T) { 52 | t.Run("MultiChunk", func(t *testing.T) { 53 | for _, size := range []int{chunk.MaxChunkSize - 1, chunk.MaxChunkSize, chunk.MaxChunkSize + 1, 1 << 20, 1<<20 + 1} { 54 | t.Run(fmt.Sprint(size), func(t *testing.T) { 55 | data := make([]byte, size) 56 | _, _ = crand.Read(data) 57 | 58 | var buf bytes.Buffer 59 | w := chunk.NewWriter(&buf) 60 | if n, err := w.Write(data); err != nil { 61 | t.Fatal(err) 62 | } else if got, want := n, size; got != want { 63 | t.Fatalf("n=%d, want %d", got, want) 64 | } 65 | if err := w.Close(); err != nil { 66 | t.Fatal(err) 67 | } 68 | 69 | // Read bytes from the chunked buffer. 70 | if b, err := io.ReadAll(chunk.NewReader(&buf)); err != nil { 71 | t.Fatal(err) 72 | } else if got, want := len(b), len(data); got != want { 73 | t.Fatalf("len=%d, want %d", got, want) 74 | } else if got, want := b, data; !bytes.Equal(got, want) { 75 | t.Fatalf("output mismatch") 76 | } 77 | }) 78 | } 79 | }) 80 | } 81 | -------------------------------------------------------------------------------- /internal/internal.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "io" 5 | "os" 6 | "strings" 7 | ) 8 | 9 | // Sync performs an fsync on the given path. Typically used for directories. 10 | func Sync(path string) error { 11 | f, err := os.Open(path) 12 | if err != nil { 13 | return err 14 | } 15 | defer func() { _ = f.Close() }() 16 | 17 | if err := f.Sync(); err != nil { 18 | return err 19 | } 20 | return f.Close() 21 | } 22 | 23 | // ReadFullAt is an implementation of io.ReadFull() but for io.ReaderAt. 24 | func ReadFullAt(r io.ReaderAt, buf []byte, off int64) (n int, err error) { 25 | for n < len(buf) && err == nil { 26 | var nn int 27 | nn, err = r.ReadAt(buf[n:], off+int64(n)) 28 | n += nn 29 | } 30 | if n >= len(buf) { 31 | return n, nil 32 | } else if n > 0 && err == io.EOF { 33 | return n, io.ErrUnexpectedEOF 34 | } 35 | return n, err 36 | } 37 | 38 | // Close closes closer but ignores select errors. 39 | func Close(closer io.Closer) (err error) { 40 | if closer == nil { 41 | return nil 42 | } else if err = closer.Close(); err == nil { 43 | return nil 44 | } 45 | 46 | if strings.Contains(err.Error(), `use of closed network connection`) { 47 | return nil 48 | } 49 | if strings.Contains(err.Error(), `http: Server closed`) { 50 | return nil 51 | } 52 | return err 53 | } 54 | -------------------------------------------------------------------------------- /internal/internal_test.go: -------------------------------------------------------------------------------- 1 | package internal_test 2 | 3 | import ( 4 | "errors" 5 | "io" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/superfly/litefs/internal" 10 | ) 11 | 12 | func TestReadFullAt(t *testing.T) { 13 | t.Run("OK", func(t *testing.T) { 14 | buf := make([]byte, 2) 15 | if n, err := internal.ReadFullAt(strings.NewReader("abcde"), buf, 2); err != nil { 16 | t.Fatal(err) 17 | } else if got, want := n, 2; got != want { 18 | t.Fatalf("n=%v, want %v", got, want) 19 | } else if got, want := string(buf), "cd"; got != want { 20 | t.Fatalf("buf=%q, want %q", got, want) 21 | } 22 | }) 23 | 24 | t.Run("ErrUnexpectedEOF", func(t *testing.T) { 25 | buf := make([]byte, 4) 26 | if n, err := internal.ReadFullAt(strings.NewReader("abcde"), buf, 2); err != io.ErrUnexpectedEOF { 27 | t.Fatalf("unexpected error: %#v", err) 28 | } else if got, want := n, 3; got != want { 29 | t.Fatalf("n=%v, want %v", got, want) 30 | } else if got, want := string(buf), "cde\x00"; got != want { 31 | t.Fatalf("buf=%q, want %q", got, want) 32 | } 33 | }) 34 | 35 | t.Run("EOF", func(t *testing.T) { 36 | buf := make([]byte, 2) 37 | if _, err := internal.ReadFullAt(strings.NewReader(""), buf, 2); err != io.EOF { 38 | t.Fatalf("unexpected error: %#v", err) 39 | } 40 | }) 41 | } 42 | 43 | func TestClose(t *testing.T) { 44 | t.Run("Nil", func(t *testing.T) { 45 | if err := internal.Close(nil); err != nil { 46 | t.Fatal("expected nil error") 47 | } 48 | }) 49 | t.Run("NilError", func(t *testing.T) { 50 | if err := internal.Close(&errCloser{}); err != nil { 51 | t.Fatal("expected nil error") 52 | } 53 | }) 54 | t.Run("Passthrough", func(t *testing.T) { 55 | errMarker := errors.New("marker") 56 | if err := internal.Close(&errCloser{err: errMarker}); err != errMarker { 57 | t.Fatalf("unexpected error: %s", err) 58 | } 59 | }) 60 | t.Run("Ignore", func(t *testing.T) { 61 | if err := internal.Close(&errCloser{err: errors.New("accept tcp [::]:45859: use of closed network connection")}); err != nil { 62 | t.Fatalf("unexpected error: %s", err) 63 | } 64 | }) 65 | } 66 | 67 | type errCloser struct { 68 | err error 69 | } 70 | 71 | func (c *errCloser) Close() error { return c.err } 72 | -------------------------------------------------------------------------------- /internal/system_os.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "os" 5 | ) 6 | 7 | // SystemOS represents an implementation of OS that simply calls the os package functions. 8 | type SystemOS struct{} 9 | 10 | func (*SystemOS) Create(op, name string) (*os.File, error) { 11 | return os.Create(name) 12 | } 13 | 14 | func (*SystemOS) Mkdir(op, path string, perm os.FileMode) error { 15 | return os.Mkdir(path, perm) 16 | } 17 | 18 | func (*SystemOS) MkdirAll(op, path string, perm os.FileMode) error { 19 | return os.MkdirAll(path, perm) 20 | } 21 | 22 | func (*SystemOS) Open(op, name string) (*os.File, error) { 23 | return os.Open(name) 24 | } 25 | 26 | func (*SystemOS) OpenFile(op, name string, flag int, perm os.FileMode) (*os.File, error) { 27 | return os.OpenFile(name, flag, perm) 28 | } 29 | 30 | func (*SystemOS) ReadDir(op, name string) ([]os.DirEntry, error) { 31 | return os.ReadDir(name) 32 | } 33 | 34 | func (*SystemOS) ReadFile(op, name string) ([]byte, error) { 35 | return os.ReadFile(name) 36 | } 37 | 38 | func (*SystemOS) Remove(op, name string) error { 39 | return os.Remove(name) 40 | } 41 | 42 | func (*SystemOS) RemoveAll(op, name string) error { 43 | return os.RemoveAll(name) 44 | } 45 | 46 | func (*SystemOS) Rename(op, oldpath, newpath string) error { 47 | return os.Rename(oldpath, newpath) 48 | } 49 | 50 | func (*SystemOS) Stat(op, name string) (os.FileInfo, error) { 51 | return os.Stat(name) 52 | } 53 | 54 | func (*SystemOS) Truncate(op, name string, size int64) error { 55 | return os.Truncate(name, size) 56 | } 57 | 58 | func (*SystemOS) WriteFile(op, name string, data []byte, perm os.FileMode) error { 59 | return os.WriteFile(name, data, perm) 60 | } 61 | -------------------------------------------------------------------------------- /internal/testingutil/testingutil.go: -------------------------------------------------------------------------------- 1 | package testingutil 2 | 3 | import ( 4 | "database/sql" 5 | "flag" 6 | "fmt" 7 | "io" 8 | "os" 9 | "path/filepath" 10 | "strings" 11 | "testing" 12 | "time" 13 | 14 | "github.com/mattn/go-sqlite3" 15 | ) 16 | 17 | func init() { 18 | // Register a test driver for persisting the WAL after DB.Close() 19 | sql.Register("sqlite3-persist-wal", &sqlite3.SQLiteDriver{ 20 | ConnectHook: func(conn *sqlite3.SQLiteConn) error { 21 | if err := conn.SetFileControlInt("main", sqlite3.SQLITE_FCNTL_PERSIST_WAL, 1); err != nil { 22 | return fmt.Errorf("cannot set file control: %w", err) 23 | } 24 | return nil 25 | }, 26 | }) 27 | } 28 | 29 | var ( 30 | journalMode = flag.String("journal-mode", "delete", "") 31 | pageSize = flag.Int("page-size", 0, "") 32 | noCompress = flag.Bool("no-compress", false, "disable ltx compression") 33 | ) 34 | 35 | // IsWALMode returns the true if -journal-mode is set to "wal". 36 | func IsWALMode() bool { 37 | return JournalMode() == "wal" 38 | } 39 | 40 | // JournalMode returns the value of -journal-mode. 41 | func JournalMode() string { 42 | return strings.ToLower(*journalMode) 43 | } 44 | 45 | // PageSize returns the value of -page-size flag 46 | func PageSize() int { 47 | if *pageSize == 0 { 48 | return 4096 49 | } 50 | return *pageSize 51 | } 52 | 53 | // Compress returns true if LTX compression is enabled. 54 | func Compress() bool { 55 | return !*noCompress 56 | } 57 | 58 | // OpenSQLDB opens a connection to a SQLite database. 59 | func OpenSQLDB(tb testing.TB, dsn string) *sql.DB { 60 | tb.Helper() 61 | 62 | db, err := sql.Open("sqlite3", dsn) 63 | if err != nil { 64 | tb.Fatal(err) 65 | } 66 | 67 | if *pageSize != 0 { 68 | if _, err := db.Exec(fmt.Sprintf(`PRAGMA page_size = %d`, *pageSize)); err != nil { 69 | tb.Fatal(err) 70 | } 71 | } 72 | 73 | if _, err := db.Exec(`PRAGMA busy_timeout = 5000`); err != nil { 74 | tb.Fatal(err) 75 | } 76 | if _, err := db.Exec(`PRAGMA journal_mode = ` + *journalMode); err != nil { 77 | tb.Fatal(err) 78 | } 79 | 80 | tb.Cleanup(func() { 81 | if err := db.Close(); err != nil { 82 | tb.Fatal(err) 83 | } 84 | }) 85 | 86 | return db 87 | } 88 | 89 | // ReopenSQLDB closes the existing database connection and reopens it with the DSN. 90 | func ReopenSQLDB(tb testing.TB, db **sql.DB, dsn string) { 91 | tb.Helper() 92 | 93 | if err := (*db).Close(); err != nil { 94 | tb.Fatal(err) 95 | } 96 | *db = OpenSQLDB(tb, dsn) 97 | } 98 | 99 | // WithTx executes fn in the context of a database transaction. 100 | // Transaction is committed automatically. 101 | func WithTx(tb testing.TB, driverName, dsn string, fn func(tx *sql.Tx)) { 102 | tb.Helper() 103 | 104 | db, err := sql.Open(driverName, dsn) 105 | if err != nil { 106 | tb.Fatal(err) 107 | } 108 | defer func() { _ = db.Close() }() 109 | 110 | if _, err := db.Exec(`PRAGMA busy_timeout = 5000`); err != nil { 111 | tb.Fatal(err) 112 | } else if _, err := db.Exec(`PRAGMA journal_mode = ` + *journalMode); err != nil { 113 | tb.Fatal(err) 114 | } 115 | 116 | tx, err := db.Begin() 117 | if err != nil { 118 | tb.Fatal(err) 119 | } 120 | defer func() { _ = tx.Rollback() }() 121 | 122 | fn(tx) 123 | 124 | if err := tx.Commit(); err != nil { 125 | tb.Fatal(err) 126 | } 127 | } 128 | 129 | // RetryUntil calls fn every interval until it returns nil or timeout elapses. 130 | func RetryUntil(tb testing.TB, interval, timeout time.Duration, fn func() error) { 131 | tb.Helper() 132 | 133 | ticker := time.NewTicker(interval) 134 | defer ticker.Stop() 135 | timer := time.NewTimer(timeout) 136 | defer timer.Stop() 137 | 138 | var err error 139 | for { 140 | select { 141 | case <-ticker.C: 142 | if err = fn(); err == nil { 143 | return 144 | } 145 | case <-timer.C: 146 | tb.Fatalf("timeout: %s", err) 147 | } 148 | } 149 | } 150 | 151 | // MustCopyDir recursively copies files from src directory to dst directory. 152 | func MustCopyDir(tb testing.TB, src, dst string) { 153 | if err := os.MkdirAll(dst, 0755); err != nil { 154 | tb.Fatal(err) 155 | } 156 | 157 | ents, err := os.ReadDir(src) 158 | if err != nil { 159 | tb.Fatal(err) 160 | } 161 | for _, ent := range ents { 162 | fi, err := os.Stat(filepath.Join(src, ent.Name())) 163 | if err != nil { 164 | tb.Fatal(err) 165 | } 166 | 167 | // If it's a directory, copy recursively. 168 | if fi.IsDir() { 169 | MustCopyDir(tb, filepath.Join(src, ent.Name()), filepath.Join(dst, ent.Name())) 170 | continue 171 | } 172 | 173 | // If it's a file, open the source file. 174 | r, err := os.Open(filepath.Join(src, ent.Name())) 175 | if err != nil { 176 | tb.Fatal(err) 177 | } 178 | defer func() { _ = r.Close() }() 179 | 180 | // Create destination file. 181 | w, err := os.Create(filepath.Join(dst, ent.Name())) 182 | if err != nil { 183 | tb.Fatal(err) 184 | } 185 | defer func() { _ = w.Close() }() 186 | 187 | // Copy contents of file to destination. 188 | if _, err := io.Copy(w, r); err != nil { 189 | tb.Fatal(err) 190 | } 191 | 192 | // Release file handles. 193 | if err := r.Close(); err != nil { 194 | tb.Fatal(err) 195 | } else if err := w.Close(); err != nil { 196 | tb.Fatal(err) 197 | } 198 | } 199 | } 200 | -------------------------------------------------------------------------------- /lease.go: -------------------------------------------------------------------------------- 1 | package litefs 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "time" 8 | ) 9 | 10 | // Leaser represents an API for obtaining a lease for leader election. 11 | type Leaser interface { 12 | io.Closer 13 | 14 | // Type returns the name of the leaser. 15 | Type() string 16 | 17 | Hostname() string 18 | AdvertiseURL() string 19 | 20 | // Acquire attempts to acquire the lease to become the primary. 21 | Acquire(ctx context.Context) (Lease, error) 22 | 23 | // AcquireExisting returns a lease from an existing lease ID. 24 | // This occurs when the primary is handed off to a replica node. 25 | AcquireExisting(ctx context.Context, leaseID string) (Lease, error) 26 | 27 | // PrimaryInfo attempts to read the current primary data. 28 | // Returns ErrNoPrimary if no primary currently has the lease. 29 | PrimaryInfo(ctx context.Context) (PrimaryInfo, error) 30 | 31 | // ClusterID returns the cluster ID set on the leaser. 32 | // This is used to ensure two clusters do not accidentally overlap. 33 | ClusterID(ctx context.Context) (string, error) 34 | 35 | // SetClusterID sets the cluster ID on the leaser. 36 | SetClusterID(ctx context.Context, clusterID string) error 37 | } 38 | 39 | // Lease represents an acquired lease from a Leaser. 40 | type Lease interface { 41 | ID() string 42 | RenewedAt() time.Time 43 | TTL() time.Duration 44 | 45 | // Renew attempts to reset the TTL on the lease. 46 | // Returns ErrLeaseExpired if the lease has expired or was deleted. 47 | Renew(ctx context.Context) error 48 | 49 | // Marks the lease as handed-off to another node. 50 | // This should send the nodeID to the channel returned by HandoffCh(). 51 | Handoff(ctx context.Context, nodeID uint64) error 52 | HandoffCh() <-chan uint64 53 | 54 | // Close attempts to remove the lease from the server. 55 | Close() error 56 | } 57 | 58 | // PrimaryInfo is the JSON object stored in the Consul lease value. 59 | type PrimaryInfo struct { 60 | Hostname string `json:"hostname"` 61 | AdvertiseURL string `json:"advertise-url"` 62 | } 63 | 64 | // Clone returns a copy of info. 65 | func (info *PrimaryInfo) Clone() *PrimaryInfo { 66 | if info == nil { 67 | return nil 68 | } 69 | other := *info 70 | return &other 71 | } 72 | 73 | // StaticLeaser always returns a lease to a static primary. 74 | type StaticLeaser struct { 75 | isPrimary bool 76 | hostname string 77 | advertiseURL string 78 | } 79 | 80 | // NewStaticLeaser returns a new instance of StaticLeaser. 81 | func NewStaticLeaser(isPrimary bool, hostname, advertiseURL string) *StaticLeaser { 82 | return &StaticLeaser{ 83 | isPrimary: isPrimary, 84 | hostname: hostname, 85 | advertiseURL: advertiseURL, 86 | } 87 | } 88 | 89 | // Close is a no-op. 90 | func (l *StaticLeaser) Close() (err error) { return nil } 91 | 92 | // Type returns "static". 93 | func (l *StaticLeaser) Type() string { return "static" } 94 | 95 | func (l *StaticLeaser) Hostname() string { 96 | return l.hostname 97 | } 98 | 99 | // AdvertiseURL returns the primary URL if this is the primary. 100 | // Otherwise returns blank. 101 | func (l *StaticLeaser) AdvertiseURL() string { 102 | if l.isPrimary { 103 | return l.advertiseURL 104 | } 105 | return "" 106 | } 107 | 108 | // Acquire returns a lease if this node is the static primary. 109 | // Otherwise returns ErrPrimaryExists. 110 | func (l *StaticLeaser) Acquire(ctx context.Context) (Lease, error) { 111 | if !l.isPrimary { 112 | return nil, ErrPrimaryExists 113 | } 114 | return &StaticLease{leaser: l}, nil 115 | } 116 | 117 | // AcquireExisting always returns an error. Static leasing does not support handoff. 118 | func (l *StaticLeaser) AcquireExisting(ctx context.Context, leaseID string) (Lease, error) { 119 | return nil, fmt.Errorf("static lease handoff not supported") 120 | } 121 | 122 | // PrimaryInfo returns the primary's info. 123 | // Returns ErrNoPrimary if the node is the primary. 124 | func (l *StaticLeaser) PrimaryInfo(ctx context.Context) (PrimaryInfo, error) { 125 | if l.isPrimary { 126 | return PrimaryInfo{}, ErrNoPrimary 127 | } 128 | return PrimaryInfo{ 129 | Hostname: l.hostname, 130 | AdvertiseURL: l.advertiseURL, 131 | }, nil 132 | } 133 | 134 | // IsPrimary returns true if the current node is the primary. 135 | func (l *StaticLeaser) IsPrimary() bool { 136 | return l.isPrimary 137 | } 138 | 139 | // ClusterID always returns a blank string for the static leaser. 140 | func (l *StaticLeaser) ClusterID(ctx context.Context) (string, error) { 141 | return "", nil 142 | } 143 | 144 | // SetClusterID is always a no-op for the static leaser. 145 | func (l *StaticLeaser) SetClusterID(ctx context.Context, clusterID string) error { 146 | return nil 147 | } 148 | 149 | var _ Lease = (*StaticLease)(nil) 150 | 151 | // StaticLease represents a lease for a fixed primary. 152 | type StaticLease struct { 153 | leaser *StaticLeaser 154 | } 155 | 156 | // ID always returns a blank string. 157 | func (l *StaticLease) ID() string { return "" } 158 | 159 | // RenewedAt returns the Unix epoch in UTC. 160 | func (l *StaticLease) RenewedAt() time.Time { return time.Unix(0, 0).UTC() } 161 | 162 | // TTL returns the duration until the lease expires which is a time well into the future. 163 | func (l *StaticLease) TTL() time.Duration { return staticLeaseExpiresAt.Sub(l.RenewedAt()) } 164 | 165 | // Renew is a no-op. 166 | func (l *StaticLease) Renew(ctx context.Context) error { return nil } 167 | 168 | // Handoff always returns an error. 169 | func (l *StaticLease) Handoff(ctx context.Context, nodeID uint64) error { 170 | return fmt.Errorf("static lease does not support handoff") 171 | } 172 | 173 | // HandoffCh always returns a nil channel. 174 | func (l *StaticLease) HandoffCh() <-chan uint64 { return nil } 175 | 176 | func (l *StaticLease) Close() error { return nil } 177 | 178 | var staticLeaseExpiresAt = time.Date(3000, time.January, 1, 0, 0, 0, 0, time.UTC) 179 | -------------------------------------------------------------------------------- /lease_test.go: -------------------------------------------------------------------------------- 1 | package litefs_test 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/superfly/litefs" 8 | ) 9 | 10 | func TestStaticLeaser(t *testing.T) { 11 | t.Run("Primary", func(t *testing.T) { 12 | l := litefs.NewStaticLeaser(true, "localhost", "http://localhost:20202") 13 | if got, want := l.AdvertiseURL(), "http://localhost:20202"; got != want { 14 | t.Fatalf("got %q, want %q", got, want) 15 | } 16 | 17 | if info, err := l.PrimaryInfo(context.Background()); err != litefs.ErrNoPrimary { 18 | t.Fatal(err) 19 | } else if got, want := info.Hostname, ""; got != want { 20 | t.Fatalf("Hostname=%q, want %q", got, want) 21 | } else if got, want := info.AdvertiseURL, ""; got != want { 22 | t.Fatalf("AdvertiseURL=%q, want %q", got, want) 23 | } 24 | 25 | if lease, err := l.Acquire(context.Background()); err != nil { 26 | t.Fatal(err) 27 | } else if lease == nil { 28 | t.Fatal("expected lease") 29 | } 30 | 31 | if err := l.Close(); err != nil { 32 | t.Fatal(err) 33 | } 34 | }) 35 | t.Run("Replica", func(t *testing.T) { 36 | l := litefs.NewStaticLeaser(false, "localhost", "http://localhost:20202") 37 | if got, want := l.AdvertiseURL(), ""; got != want { 38 | t.Fatalf("got %q, want %q", got, want) 39 | } 40 | 41 | if info, err := l.PrimaryInfo(context.Background()); err != nil { 42 | t.Fatal(err) 43 | } else if got, want := info.Hostname, "localhost"; got != want { 44 | t.Fatalf("Hostname=%q, want %q", got, want) 45 | } else if got, want := info.AdvertiseURL, "http://localhost:20202"; got != want { 46 | t.Fatalf("AdvertiseURL=%q, want %q", got, want) 47 | } 48 | 49 | if lease, err := l.Acquire(context.Background()); err != litefs.ErrPrimaryExists { 50 | t.Fatalf("unexpected error: %v", err) 51 | } else if lease != nil { 52 | t.Fatal("expected no lease") 53 | } 54 | }) 55 | } 56 | 57 | func TestStaticLease(t *testing.T) { 58 | leaser := litefs.NewStaticLeaser(true, "localhost", "http://localhost:20202") 59 | lease, err := leaser.Acquire(context.Background()) 60 | if err != nil { 61 | t.Fatal(err) 62 | } 63 | 64 | if got, want := lease.RenewedAt().String(), `1970-01-01 00:00:00 +0000 UTC`; got != want { 65 | t.Fatalf("RenewedAt()=%q, want %q", got, want) 66 | } 67 | if lease.TTL() <= 0 { 68 | t.Fatal("expected TTL") 69 | } 70 | 71 | if err := lease.Renew(context.Background()); err != nil { 72 | t.Fatal(err) 73 | } 74 | if err := lease.Close(); err != nil { 75 | t.Fatal(err) 76 | } 77 | } 78 | 79 | // newPrimaryStaticLeaser returns a new instance of StaticLeaser for primary node testing. 80 | func newPrimaryStaticLeaser() *litefs.StaticLeaser { 81 | return litefs.NewStaticLeaser(true, "localhost", "http://localhost:20202") 82 | } 83 | -------------------------------------------------------------------------------- /lfsc/backup_client.go: -------------------------------------------------------------------------------- 1 | package lfsc 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "net/url" 10 | 11 | "github.com/superfly/litefs" 12 | "github.com/superfly/ltx" 13 | ) 14 | 15 | var _ litefs.BackupClient = (*BackupClient)(nil) 16 | 17 | // BackupClient implements a backup client for LiteFS Cloud. 18 | type BackupClient struct { 19 | store *litefs.Store // store, used for cluster ID 20 | baseURL url.URL // remote LiteFS Cloud URL 21 | 22 | // Name of cluster to replicate as. 23 | // This is typically set on the auth token and does not need to be set manually. 24 | Cluster string 25 | 26 | // Authentication field passed in via the "Authorization" HTTP header. 27 | AuthToken string 28 | 29 | HTTPClient *http.Client 30 | 31 | // ID of the LFSC instance that handles this cluster. 32 | lfscInstanceID string 33 | } 34 | 35 | // NewBackupClient returns a new instance of BackupClient. 36 | func NewBackupClient(store *litefs.Store, u url.URL) *BackupClient { 37 | return &BackupClient{ 38 | store: store, 39 | baseURL: url.URL{ 40 | Scheme: u.Scheme, 41 | Host: u.Host, 42 | }, 43 | 44 | HTTPClient: &http.Client{}, 45 | } 46 | } 47 | 48 | // Open validates the URL the client was initialized with. 49 | func (c *BackupClient) Open() (err error) { 50 | if c.baseURL.Scheme != "http" && c.baseURL.Scheme != "https" { 51 | return fmt.Errorf("invalid litefs cloud URL scheme: %q", c.baseURL.Scheme) 52 | } else if c.baseURL.Host == "" { 53 | return fmt.Errorf("litefs cloud URL host required: %q", c.baseURL.String()) 54 | } 55 | 56 | return nil 57 | } 58 | 59 | // URL of the backup service. 60 | func (c *BackupClient) URL() string { 61 | return c.baseURL.String() 62 | } 63 | 64 | // PosMap returns the replication position for all databases on the backup service. 65 | func (c *BackupClient) PosMap(ctx context.Context) (map[string]ltx.Pos, error) { 66 | q := url.Values{} 67 | if c.Cluster != "" { 68 | q.Set("cluster", c.Cluster) 69 | } 70 | 71 | req, err := c.newRequest(http.MethodGet, "/pos", q, nil) 72 | if err != nil { 73 | return nil, err 74 | } 75 | 76 | resp, err := c.doRequest(ctx, req) 77 | if err != nil { 78 | return nil, err 79 | } 80 | defer func() { _ = resp.Body.Close() }() 81 | 82 | m := make(map[string]ltx.Pos) 83 | if err := json.NewDecoder(resp.Body).Decode(&m); err != nil { 84 | return nil, err 85 | } 86 | return m, nil 87 | } 88 | 89 | // WriteTx writes an LTX file to the backup service. The file must be 90 | // contiguous with the latest LTX file on the backup service or else it 91 | // will return an ltx.PosMismatchError. 92 | func (c *BackupClient) WriteTx(ctx context.Context, name string, r io.Reader) (hwm ltx.TXID, err error) { 93 | q := url.Values{} 94 | if c.Cluster != "" { 95 | q.Set("cluster", c.Cluster) 96 | } 97 | q.Set("db", name) 98 | 99 | req, err := c.newRequest(http.MethodPost, "/db/tx", q, r) 100 | if err != nil { 101 | return 0, err 102 | } 103 | 104 | resp, err := c.doRequest(ctx, req) 105 | if err != nil { 106 | return 0, err 107 | } else if err := resp.Body.Close(); err != nil { 108 | return 0, err 109 | } 110 | 111 | // Parse high-water mark returned from server. 112 | hwmStr := resp.Header.Get("Litefs-Hwm") 113 | if hwm, err = ltx.ParseTXID(hwmStr); err != nil { 114 | return 0, fmt.Errorf("cannot parse high-water mark: %q", hwmStr) 115 | } 116 | 117 | return hwm, nil 118 | } 119 | 120 | // FetchSnapshot requests a full snapshot of the database as it exists on 121 | // the backup service. This should be used if the LiteFS node has become 122 | // out of sync with the backup service. 123 | func (c *BackupClient) FetchSnapshot(ctx context.Context, name string) (io.ReadCloser, error) { 124 | q := url.Values{} 125 | if c.Cluster != "" { 126 | q.Set("cluster", c.Cluster) 127 | } 128 | q.Set("db", name) 129 | 130 | req, err := c.newRequest(http.MethodGet, "/db/snapshot", q, nil) 131 | if err != nil { 132 | return nil, err 133 | } 134 | 135 | resp, err := c.doRequest(ctx, req) 136 | if err != nil { 137 | return nil, err 138 | } 139 | return resp.Body, nil 140 | } 141 | 142 | // newRequest returns a new HTTP request with the given context & auth parameters. 143 | func (c *BackupClient) newRequest(method, path string, q url.Values, body io.Reader) (*http.Request, error) { 144 | u := c.baseURL 145 | u.Path = path 146 | u.RawQuery = q.Encode() 147 | 148 | req, err := http.NewRequest(method, u.String(), body) 149 | if err != nil { 150 | return nil, err 151 | } 152 | 153 | // Send the cluster ID with every request. 154 | if clusterID := c.store.ClusterID(); clusterID != "" { 155 | req.Header.Set("Litefs-Cluster-Id", clusterID) 156 | } 157 | 158 | // Set the auth header if scheme & token are provided. Otherwise send without auth. 159 | if c.AuthToken != "" { 160 | req.Header.Set("Authorization", c.AuthToken) 161 | } 162 | 163 | // If we know which LFSC instance handles this cluster, ask fly-proxy to route directly to it. 164 | if c.lfscInstanceID != "" { 165 | req.Header.Set("fly-force-instance-id", c.lfscInstanceID) 166 | } 167 | 168 | return req, nil 169 | } 170 | 171 | // doRequest executes the request and returns an error if the response is not a 2XX. 172 | func (c *BackupClient) doRequest(ctx context.Context, req *http.Request) (*http.Response, error) { 173 | resp, err := c.HTTPClient.Do(req.WithContext(ctx)) 174 | if err != nil { 175 | return nil, err 176 | } 177 | 178 | // If this is not a 2XX code then read the body as an error message. 179 | if !isSuccessfulStatusCode(resp.StatusCode) { 180 | // The instance may have been deleted by fly-proxy is still 181 | // trying to route to it. 182 | if resp.StatusCode == http.StatusServiceUnavailable { 183 | c.lfscInstanceID = "" 184 | } 185 | return nil, readResponseError(resp) 186 | } 187 | 188 | if id := resp.Header.Get("Lfsc-Instance-Id"); id != "" { 189 | c.lfscInstanceID = id 190 | } 191 | 192 | return resp, nil 193 | } 194 | 195 | // readResponseError reads the response body as an error message & closes the body. 196 | func readResponseError(resp *http.Response) error { 197 | defer func() { _ = resp.Body.Close() }() 198 | 199 | // Read up to 64KB of data from the body for the error message. 200 | buf, err := io.ReadAll(io.LimitReader(resp.Body, 1<<16)) 201 | if err != nil { 202 | return err 203 | } 204 | 205 | // Attempt to decode as a JSON error. 206 | var e errorResponse 207 | if err := json.Unmarshal(buf, &e); err != nil { 208 | return fmt.Errorf("backup client error (%d): %s", resp.StatusCode, string(buf)) 209 | } 210 | 211 | // Match specific types of errors. 212 | switch e.Code { 213 | case "EPOSMISMATCH": 214 | return ltx.NewPosMismatchError(e.Pos) 215 | default: 216 | return fmt.Errorf("backup client error (%d): %s", resp.StatusCode, e.Error) 217 | } 218 | } 219 | 220 | type errorResponse struct { 221 | Code string `json:"code"` 222 | Error string `json:"error"` 223 | Pos ltx.Pos `json:"pos"` 224 | } 225 | 226 | func isSuccessfulStatusCode(code int) bool { 227 | return code >= 200 && code < 300 228 | } 229 | -------------------------------------------------------------------------------- /litefs_test.go: -------------------------------------------------------------------------------- 1 | package litefs_test 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "os" 8 | "testing" 9 | 10 | "github.com/superfly/litefs" 11 | ) 12 | 13 | func TestFileType_IsValid(t *testing.T) { 14 | t.Run("Valid", func(t *testing.T) { 15 | for _, typ := range []litefs.FileType{ 16 | litefs.FileTypeDatabase, 17 | litefs.FileTypeJournal, 18 | litefs.FileTypeWAL, 19 | litefs.FileTypeSHM, 20 | } { 21 | if !typ.IsValid() { 22 | t.Fatalf("expected valid for %d", typ) 23 | } 24 | } 25 | }) 26 | t.Run("Invalid", func(t *testing.T) { 27 | if litefs.FileType(100).IsValid() { 28 | t.Fatalf("expected invalid") 29 | } 30 | }) 31 | t.Run("None", func(t *testing.T) { 32 | if litefs.FileTypeNone.IsValid() { 33 | t.Fatalf("expected invalid") 34 | } 35 | }) 36 | } 37 | 38 | func TestWALReader(t *testing.T) { 39 | t.Run("OK", func(t *testing.T) { 40 | buf := make([]byte, 4096) 41 | b, err := os.ReadFile("testdata/wal-reader/ok/wal") 42 | if err != nil { 43 | t.Fatal(err) 44 | } 45 | 46 | // Initialize reader with header info. 47 | r := litefs.NewWALReader(bytes.NewReader(b)) 48 | if err := r.ReadHeader(); err != nil { 49 | t.Fatal(err) 50 | } else if got, want := r.PageSize(), uint32(4096); got != want { 51 | t.Fatalf("PageSize()=%d, want %d", got, want) 52 | } else if got, want := r.Offset(), int64(0); got != want { 53 | t.Fatalf("Offset()=%d, want %d", got, want) 54 | } 55 | 56 | // Read first frame. 57 | if pgno, commit, err := r.ReadFrame(buf); err != nil { 58 | t.Fatal(err) 59 | } else if got, want := pgno, uint32(1); got != want { 60 | t.Fatalf("pgno=%d, want %d", got, want) 61 | } else if got, want := commit, uint32(0); got != want { 62 | t.Fatalf("commit=%d, want %d", got, want) 63 | } else if !bytes.Equal(buf, b[56:4152]) { 64 | t.Fatal("page data mismatch") 65 | } else if got, want := r.Offset(), int64(32); got != want { 66 | t.Fatalf("Offset()=%d, want %d", got, want) 67 | } 68 | 69 | // Read second frame. End of transaction. 70 | if pgno, commit, err := r.ReadFrame(buf); err != nil { 71 | t.Fatal(err) 72 | } else if got, want := pgno, uint32(2); got != want { 73 | t.Fatalf("pgno=%d, want %d", got, want) 74 | } else if got, want := commit, uint32(2); got != want { 75 | t.Fatalf("commit=%d, want %d", got, want) 76 | } else if !bytes.Equal(buf, b[4176:8272]) { 77 | t.Fatal("page data mismatch") 78 | } else if got, want := r.Offset(), int64(4152); got != want { 79 | t.Fatalf("Offset()=%d, want %d", got, want) 80 | } 81 | 82 | // Read third frame. 83 | if pgno, commit, err := r.ReadFrame(buf); err != nil { 84 | t.Fatal(err) 85 | } else if got, want := pgno, uint32(2); got != want { 86 | t.Fatalf("pgno=%d, want %d", got, want) 87 | } else if got, want := commit, uint32(2); got != want { 88 | t.Fatalf("commit=%d, want %d", got, want) 89 | } else if !bytes.Equal(buf, b[8296:12392]) { 90 | t.Fatal("page data mismatch") 91 | } else if got, want := r.Offset(), int64(8272); got != want { 92 | t.Fatalf("Offset()=%d, want %d", got, want) 93 | } 94 | 95 | if _, _, err := r.ReadFrame(buf); err != io.EOF { 96 | t.Fatalf("unexpected error: %s", err) 97 | } 98 | }) 99 | 100 | t.Run("SaltMismatch", func(t *testing.T) { 101 | buf := make([]byte, 4096) 102 | b, err := os.ReadFile("testdata/wal-reader/salt-mismatch/wal") 103 | if err != nil { 104 | t.Fatal(err) 105 | } 106 | 107 | // Initialize reader with header info. 108 | r := litefs.NewWALReader(bytes.NewReader(b)) 109 | if err := r.ReadHeader(); err != nil { 110 | t.Fatal(err) 111 | } else if got, want := r.PageSize(), uint32(4096); got != want { 112 | t.Fatalf("PageSize()=%d, want %d", got, want) 113 | } else if got, want := r.Offset(), int64(0); got != want { 114 | t.Fatalf("Offset()=%d, want %d", got, want) 115 | } 116 | 117 | // Read first frame. 118 | if pgno, commit, err := r.ReadFrame(buf); err != nil { 119 | t.Fatal(err) 120 | } else if got, want := pgno, uint32(1); got != want { 121 | t.Fatalf("pgno=%d, want %d", got, want) 122 | } else if got, want := commit, uint32(0); got != want { 123 | t.Fatalf("commit=%d, want %d", got, want) 124 | } else if !bytes.Equal(buf, b[56:4152]) { 125 | t.Fatal("page data mismatch") 126 | } 127 | 128 | // Read second frame. Salt has been altered so it doesn't match header. 129 | if _, _, err := r.ReadFrame(buf); err != io.EOF { 130 | t.Fatalf("unexpected error: %s", err) 131 | } 132 | }) 133 | 134 | t.Run("FrameChecksumMismatch", func(t *testing.T) { 135 | buf := make([]byte, 4096) 136 | b, err := os.ReadFile("testdata/wal-reader/frame-checksum-mismatch/wal") 137 | if err != nil { 138 | t.Fatal(err) 139 | } 140 | 141 | // Initialize reader with header info. 142 | r := litefs.NewWALReader(bytes.NewReader(b)) 143 | if err := r.ReadHeader(); err != nil { 144 | t.Fatal(err) 145 | } else if got, want := r.PageSize(), uint32(4096); got != want { 146 | t.Fatalf("PageSize()=%d, want %d", got, want) 147 | } else if got, want := r.Offset(), int64(0); got != want { 148 | t.Fatalf("Offset()=%d, want %d", got, want) 149 | } 150 | 151 | // Read first frame. 152 | if pgno, commit, err := r.ReadFrame(buf); err != nil { 153 | t.Fatal(err) 154 | } else if got, want := pgno, uint32(1); got != want { 155 | t.Fatalf("pgno=%d, want %d", got, want) 156 | } else if got, want := commit, uint32(0); got != want { 157 | t.Fatalf("commit=%d, want %d", got, want) 158 | } else if !bytes.Equal(buf, b[56:4152]) { 159 | t.Fatal("page data mismatch") 160 | } 161 | 162 | // Read second frame. Checksum has been altered so it doesn't match. 163 | if _, _, err := r.ReadFrame(buf); err != io.EOF { 164 | t.Fatalf("unexpected error: %s", err) 165 | } 166 | }) 167 | 168 | t.Run("ZeroLength", func(t *testing.T) { 169 | r := litefs.NewWALReader(bytes.NewReader(nil)) 170 | if err := r.ReadHeader(); err != io.EOF { 171 | t.Fatalf("unexpected error: %#v", err) 172 | } 173 | }) 174 | 175 | t.Run("PartialHeader", func(t *testing.T) { 176 | r := litefs.NewWALReader(bytes.NewReader(make([]byte, 10))) 177 | if err := r.ReadHeader(); err != io.EOF { 178 | t.Fatalf("unexpected error: %#v", err) 179 | } 180 | }) 181 | 182 | t.Run("BadMagic", func(t *testing.T) { 183 | r := litefs.NewWALReader(bytes.NewReader(make([]byte, 32))) 184 | if err := r.ReadHeader(); err == nil || err.Error() != `invalid wal header magic: 0` { 185 | t.Fatalf("unexpected error: %#v", err) 186 | } 187 | }) 188 | 189 | t.Run("BadHeaderChecksum", func(t *testing.T) { 190 | data := []byte{ 191 | 0x37, 0x7f, 0x06, 0x83, 0x00, 0x00, 0x00, 0x00, 192 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 193 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 194 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00} 195 | r := litefs.NewWALReader(bytes.NewReader(data)) 196 | if err := r.ReadHeader(); err != io.EOF { 197 | t.Fatalf("unexpected error: %#v", err) 198 | } 199 | }) 200 | 201 | t.Run("BadHeaderVersion", func(t *testing.T) { 202 | data := []byte{ 203 | 0x37, 0x7f, 0x06, 0x83, 0x00, 0x00, 0x00, 0x01, 204 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 205 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 206 | 0x15, 0x7b, 0x20, 0x92, 0xbb, 0xf8, 0x34, 0x1d} 207 | r := litefs.NewWALReader(bytes.NewReader(data)) 208 | if err := r.ReadHeader(); err == nil || err.Error() != `unsupported wal version: 1` { 209 | t.Fatalf("unexpected error: %#v", err) 210 | } 211 | }) 212 | 213 | t.Run("ErrBufferSize", func(t *testing.T) { 214 | b, err := os.ReadFile("testdata/wal-reader/ok/wal") 215 | if err != nil { 216 | t.Fatal(err) 217 | } 218 | 219 | // Initialize reader with header info. 220 | r := litefs.NewWALReader(bytes.NewReader(b)) 221 | if err := r.ReadHeader(); err != nil { 222 | t.Fatal(err) 223 | } 224 | if _, _, err := r.ReadFrame(make([]byte, 512)); err == nil || err.Error() != `WALReader.ReadFrame(): buffer size (512) must match page size (4096)` { 225 | t.Fatalf("unexpected error: %#v", err) 226 | } 227 | }) 228 | 229 | t.Run("ErrPartialFrameHeader", func(t *testing.T) { 230 | b, err := os.ReadFile("testdata/wal-reader/ok/wal") 231 | if err != nil { 232 | t.Fatal(err) 233 | } 234 | 235 | r := litefs.NewWALReader(bytes.NewReader(b[:40])) 236 | if err := r.ReadHeader(); err != nil { 237 | t.Fatal(err) 238 | } else if _, _, err := r.ReadFrame(make([]byte, 4096)); err != io.EOF { 239 | t.Fatalf("unexpected error: %#v", err) 240 | } 241 | }) 242 | 243 | t.Run("ErrFrameHeaderOnly", func(t *testing.T) { 244 | b, err := os.ReadFile("testdata/wal-reader/ok/wal") 245 | if err != nil { 246 | t.Fatal(err) 247 | } 248 | 249 | r := litefs.NewWALReader(bytes.NewReader(b[:56])) 250 | if err := r.ReadHeader(); err != nil { 251 | t.Fatal(err) 252 | } else if _, _, err := r.ReadFrame(make([]byte, 4096)); err != io.EOF { 253 | t.Fatalf("unexpected error: %#v", err) 254 | } 255 | }) 256 | 257 | t.Run("ErrPartialFrameData", func(t *testing.T) { 258 | b, err := os.ReadFile("testdata/wal-reader/ok/wal") 259 | if err != nil { 260 | t.Fatal(err) 261 | } 262 | 263 | r := litefs.NewWALReader(bytes.NewReader(b[:1000])) 264 | if err := r.ReadHeader(); err != nil { 265 | t.Fatal(err) 266 | } else if _, _, err := r.ReadFrame(make([]byte, 4096)); err != io.EOF { 267 | t.Fatalf("unexpected error: %#v", err) 268 | } 269 | }) 270 | } 271 | 272 | type errWriter struct{ afterN int } 273 | 274 | func (w *errWriter) Write(p []byte) (int, error) { 275 | if w.afterN -= len(p); w.afterN <= 0 { 276 | return 0, fmt.Errorf("write error occurred") 277 | } 278 | return len(p), nil 279 | } 280 | -------------------------------------------------------------------------------- /mock/client.go: -------------------------------------------------------------------------------- 1 | package mock 2 | 3 | import ( 4 | "context" 5 | "io" 6 | 7 | "github.com/superfly/litefs" 8 | "github.com/superfly/ltx" 9 | ) 10 | 11 | var _ litefs.Client = (*Client)(nil) 12 | 13 | type Client struct { 14 | AcquireHaltLockFunc func(ctx context.Context, primaryURL string, nodeID uint64, name string, lockID int64) (*litefs.HaltLock, error) 15 | ReleaseHaltLockFunc func(ctx context.Context, primaryURL string, nodeID uint64, name string, lockID int64) error 16 | CommitFunc func(ctx context.Context, primaryURL string, nodeID uint64, name string, lockID int64, r io.Reader) error 17 | StreamFunc func(ctx context.Context, primaryURL string, nodeID uint64, posMap map[string]ltx.Pos, filter []string) (litefs.Stream, error) 18 | } 19 | 20 | func (c *Client) AcquireHaltLock(ctx context.Context, primaryURL string, nodeID uint64, name string, lockID int64) (*litefs.HaltLock, error) { 21 | return c.AcquireHaltLockFunc(ctx, primaryURL, nodeID, name, lockID) 22 | } 23 | 24 | func (c *Client) ReleaseHaltLock(ctx context.Context, primaryURL string, nodeID uint64, name string, lockID int64) error { 25 | return c.ReleaseHaltLockFunc(ctx, primaryURL, nodeID, name, lockID) 26 | } 27 | 28 | func (c *Client) Commit(ctx context.Context, primaryURL string, nodeID uint64, name string, lockID int64, r io.Reader) error { 29 | return c.CommitFunc(ctx, primaryURL, nodeID, name, lockID, r) 30 | } 31 | 32 | func (c *Client) Stream(ctx context.Context, primaryURL string, nodeID uint64, posMap map[string]ltx.Pos, filter []string) (litefs.Stream, error) { 33 | return c.StreamFunc(ctx, primaryURL, nodeID, posMap, filter) 34 | } 35 | 36 | type Stream struct { 37 | io.ReadCloser 38 | ClusterIDFunc func() string 39 | } 40 | 41 | func (s *Stream) ClusterID() string { return s.ClusterIDFunc() } 42 | -------------------------------------------------------------------------------- /mock/lease.go: -------------------------------------------------------------------------------- 1 | package mock 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/superfly/litefs" 8 | ) 9 | 10 | var _ litefs.Leaser = (*Leaser)(nil) 11 | 12 | type Leaser struct { 13 | CloseFunc func() error 14 | HostnameFunc func() string 15 | AdvertiseURLFunc func() string 16 | AcquireFunc func(ctx context.Context) (litefs.Lease, error) 17 | AcquireExistingFunc func(ctx context.Context, leaseID string) (litefs.Lease, error) 18 | PrimaryInfoFunc func(ctx context.Context) (litefs.PrimaryInfo, error) 19 | ClusterIDFunc func(ctx context.Context) (string, error) 20 | SetClusterIDFunc func(ctx context.Context, clusterID string) error 21 | } 22 | 23 | func (l *Leaser) Close() error { 24 | return l.CloseFunc() 25 | } 26 | 27 | func (l *Leaser) Type() string { return "mock" } 28 | 29 | func (l *Leaser) Hostname() string { 30 | return l.HostnameFunc() 31 | } 32 | 33 | func (l *Leaser) AdvertiseURL() string { 34 | return l.AdvertiseURLFunc() 35 | } 36 | 37 | func (l *Leaser) Acquire(ctx context.Context) (litefs.Lease, error) { 38 | return l.AcquireFunc(ctx) 39 | } 40 | 41 | func (l *Leaser) AcquireExisting(ctx context.Context, leaseID string) (litefs.Lease, error) { 42 | return l.AcquireExistingFunc(ctx, leaseID) 43 | } 44 | 45 | func (l *Leaser) PrimaryInfo(ctx context.Context) (litefs.PrimaryInfo, error) { 46 | return l.PrimaryInfoFunc(ctx) 47 | } 48 | 49 | func (l *Leaser) ClusterID(ctx context.Context) (string, error) { 50 | return l.ClusterIDFunc(ctx) 51 | } 52 | 53 | func (l *Leaser) SetClusterID(ctx context.Context, clusterID string) error { 54 | return l.SetClusterIDFunc(ctx, clusterID) 55 | } 56 | 57 | var _ litefs.Lease = (*Lease)(nil) 58 | 59 | type Lease struct { 60 | IDFunc func() string 61 | RenewedAtFunc func() time.Time 62 | TTLFunc func() time.Duration 63 | RenewFunc func(ctx context.Context) error 64 | HandoffFunc func(ctx context.Context, nodeID uint64) error 65 | HandoffChFunc func() <-chan uint64 66 | CloseFunc func() error 67 | } 68 | 69 | func (l *Lease) ID() string { 70 | return l.IDFunc() 71 | } 72 | 73 | func (l *Lease) RenewedAt() time.Time { 74 | return l.RenewedAtFunc() 75 | } 76 | 77 | func (l *Lease) TTL() time.Duration { 78 | return l.TTLFunc() 79 | } 80 | 81 | func (l *Lease) Renew(ctx context.Context) error { 82 | return l.RenewFunc(ctx) 83 | } 84 | 85 | func (l *Lease) Handoff(ctx context.Context, nodeID uint64) error { 86 | return l.HandoffFunc(ctx, nodeID) 87 | } 88 | 89 | func (l *Lease) HandoffCh() <-chan uint64 { 90 | return l.HandoffChFunc() 91 | } 92 | 93 | func (l *Lease) Close() error { 94 | return l.CloseFunc() 95 | } 96 | -------------------------------------------------------------------------------- /mock/os.go: -------------------------------------------------------------------------------- 1 | package mock 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/superfly/litefs" 7 | "github.com/superfly/litefs/internal" 8 | ) 9 | 10 | var _ litefs.OS = (*OS)(nil) 11 | 12 | type OS struct { 13 | Underlying litefs.OS 14 | 15 | CreateFunc func(op, name string) (*os.File, error) 16 | MkdirFunc func(op, path string, perm os.FileMode) error 17 | MkdirAllFunc func(op, path string, perm os.FileMode) error 18 | OpenFunc func(op, name string) (*os.File, error) 19 | OpenFileFunc func(op, name string, flag int, perm os.FileMode) (*os.File, error) 20 | ReadDirFunc func(op, name string) ([]os.DirEntry, error) 21 | ReadFileFunc func(op, name string) ([]byte, error) 22 | RemoveFunc func(op, name string) error 23 | RemoveAllFunc func(op, name string) error 24 | RenameFunc func(op, oldpath, newpath string) error 25 | StatFunc func(op, name string) (os.FileInfo, error) 26 | TruncateFunc func(op, name string, size int64) error 27 | WriteFileFunc func(op, name string, data []byte, perm os.FileMode) error 28 | } 29 | 30 | // NewOS returns a mock OS that defaults to using an underlying system OS. 31 | func NewOS() *OS { 32 | return &OS{ 33 | Underlying: &internal.SystemOS{}, 34 | } 35 | } 36 | 37 | func (m *OS) Create(op, name string) (*os.File, error) { 38 | if m.CreateFunc == nil { 39 | return m.Underlying.Create(op, name) 40 | } 41 | return m.CreateFunc(op, name) 42 | } 43 | 44 | func (m *OS) Mkdir(op, path string, perm os.FileMode) error { 45 | if m.MkdirFunc == nil { 46 | return m.Underlying.Mkdir(op, path, perm) 47 | } 48 | return m.MkdirFunc(op, path, perm) 49 | } 50 | 51 | func (m *OS) MkdirAll(op, path string, perm os.FileMode) error { 52 | if m.MkdirAllFunc == nil { 53 | return m.Underlying.MkdirAll(op, path, perm) 54 | } 55 | return m.MkdirAllFunc(op, path, perm) 56 | } 57 | 58 | func (m *OS) Open(op, name string) (*os.File, error) { 59 | if m.OpenFunc == nil { 60 | return m.Underlying.Open(op, name) 61 | } 62 | return m.OpenFunc(op, name) 63 | } 64 | 65 | func (m *OS) OpenFile(op, name string, flag int, perm os.FileMode) (*os.File, error) { 66 | if m.OpenFileFunc == nil { 67 | return m.Underlying.OpenFile(op, name, flag, perm) 68 | } 69 | return m.OpenFileFunc(op, name, flag, perm) 70 | } 71 | 72 | func (m *OS) ReadDir(op, name string) ([]os.DirEntry, error) { 73 | if m.ReadDirFunc == nil { 74 | return m.Underlying.ReadDir(op, name) 75 | } 76 | return m.ReadDirFunc(op, name) 77 | } 78 | 79 | func (m *OS) ReadFile(op, name string) ([]byte, error) { 80 | if m.ReadFileFunc == nil { 81 | return m.Underlying.ReadFile(op, name) 82 | } 83 | return m.ReadFileFunc(op, name) 84 | } 85 | 86 | func (m *OS) Remove(op, name string) error { 87 | if m.RemoveFunc == nil { 88 | return m.Underlying.Remove(op, name) 89 | } 90 | return m.RemoveFunc(op, name) 91 | } 92 | 93 | func (m *OS) RemoveAll(op, name string) error { 94 | if m.RemoveAllFunc == nil { 95 | return m.Underlying.RemoveAll(op, name) 96 | } 97 | return m.RemoveAllFunc(op, name) 98 | } 99 | 100 | func (m *OS) Rename(op, oldpath, newpath string) error { 101 | if m.RenameFunc == nil { 102 | return m.Underlying.Rename(op, oldpath, newpath) 103 | } 104 | return m.RenameFunc(op, oldpath, newpath) 105 | } 106 | 107 | func (m *OS) Stat(op, name string) (os.FileInfo, error) { 108 | if m.StatFunc == nil { 109 | return m.Underlying.Stat(op, name) 110 | } 111 | return m.StatFunc(op, name) 112 | } 113 | 114 | func (m *OS) Truncate(op, name string, size int64) error { 115 | if m.TruncateFunc == nil { 116 | return m.Underlying.Truncate(op, name, size) 117 | } 118 | return m.TruncateFunc(op, name, size) 119 | } 120 | 121 | func (m *OS) WriteFile(op, name string, data []byte, perm os.FileMode) error { 122 | if m.WriteFileFunc == nil { 123 | return m.Underlying.WriteFile(op, name, data, perm) 124 | } 125 | return m.WriteFileFunc(op, name, data, perm) 126 | } 127 | -------------------------------------------------------------------------------- /rwmutex.go: -------------------------------------------------------------------------------- 1 | package litefs 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "sync" 7 | "time" 8 | ) 9 | 10 | // RWMutexInterval is the time between reattempting lock acquisition. 11 | const RWMutexInterval = 10 * time.Microsecond 12 | 13 | // RWMutex is a reader/writer mutual exclusion lock. It wraps the sync package 14 | // to provide additional capabilities such as lock upgrades & downgrades. It 15 | // only supports TryLock() & TryRLock() as that is what's supported by our 16 | // FUSE file system. 17 | type RWMutex struct { 18 | mu sync.Mutex 19 | sharedN int // number of readers 20 | excl *RWMutexGuard // exclusive lock holder 21 | 22 | // If set, this function is called when the state transitions. 23 | // Must be set before use of the mutex or its guards. 24 | OnLockStateChange func(prevState, newState RWMutexState) 25 | } 26 | 27 | // Guard returns an unlocked guard for the mutex. 28 | func (rw *RWMutex) Guard() RWMutexGuard { 29 | return RWMutexGuard{rw: rw, state: RWMutexStateUnlocked} 30 | } 31 | 32 | // State returns whether the mutex has a exclusive lock, one or more shared 33 | // locks, or if the mutex is unlocked. 34 | func (rw *RWMutex) State() RWMutexState { 35 | rw.mu.Lock() 36 | defer rw.mu.Unlock() 37 | return rw.state() 38 | } 39 | 40 | func (rw *RWMutex) state() RWMutexState { 41 | if rw.excl != nil { 42 | return RWMutexStateExclusive 43 | } else if rw.sharedN > 0 { 44 | return RWMutexStateShared 45 | } 46 | return RWMutexStateUnlocked 47 | } 48 | 49 | // RWMutexGuard is a reference to a mutex. Locking, unlocking, upgrading, & 50 | // downgrading operations are all performed via the guard instead of directly 51 | // on the RWMutex itself as this works similarly to how POSIX locks work. 52 | type RWMutexGuard struct { 53 | rw *RWMutex 54 | state RWMutexState 55 | } 56 | 57 | // State returns the current state of the guard. 58 | func (g *RWMutexGuard) State() RWMutexState { 59 | g.rw.mu.Lock() 60 | defer g.rw.mu.Unlock() 61 | return g.state 62 | } 63 | 64 | // Lock attempts to obtain a exclusive lock for the guard. Returns an error if ctx is done. 65 | func (g *RWMutexGuard) Lock(ctx context.Context) error { 66 | if g.TryLock() { 67 | return nil 68 | } 69 | 70 | ticker := time.NewTicker(RWMutexInterval) 71 | defer ticker.Stop() 72 | 73 | for { 74 | select { 75 | case <-ctx.Done(): 76 | return context.Cause(ctx) 77 | case <-ticker.C: 78 | if g.TryLock() { 79 | return nil 80 | } 81 | } 82 | } 83 | } 84 | 85 | // TryLock upgrades the lock from a shared lock to an exclusive lock. 86 | // This is a no-op if the lock is already an exclusive lock. This function will 87 | // trigger OnLockStateChange on the mutex, if set, and if state changes. 88 | func (g *RWMutexGuard) TryLock() bool { 89 | g.rw.mu.Lock() 90 | prevState := g.rw.state() 91 | v := g.tryLock() 92 | fn, newState := g.rw.OnLockStateChange, g.rw.state() 93 | g.rw.mu.Unlock() 94 | 95 | if fn != nil && prevState != newState { 96 | fn(prevState, newState) 97 | } 98 | return v 99 | } 100 | 101 | func (g *RWMutexGuard) tryLock() bool { 102 | switch g.state { 103 | case RWMutexStateUnlocked: 104 | if g.rw.sharedN != 0 || g.rw.excl != nil { 105 | return false 106 | } 107 | g.rw.sharedN, g.rw.excl = 0, g 108 | g.state = RWMutexStateExclusive 109 | return true 110 | 111 | case RWMutexStateShared: 112 | assert(g.rw.excl == nil, "exclusive lock already held while upgrading shared lock") 113 | if g.rw.sharedN > 1 { 114 | return false // another shared lock is being held 115 | } 116 | 117 | assert(g.rw.sharedN == 1, "invalid shared lock count on guard upgrade") 118 | g.rw.sharedN, g.rw.excl = 0, g 119 | g.state = RWMutexStateExclusive 120 | return true 121 | 122 | case RWMutexStateExclusive: 123 | return true // no-op 124 | 125 | default: 126 | panic("RWMutexGuard.TryLock(): unreachable") 127 | } 128 | } 129 | 130 | // CanLock returns true if the guard can become an exclusive lock. 131 | // Also returns the current state of the underlying mutex to determine if the 132 | // lock is blocked by a shared or exclusive lock. 133 | func (g *RWMutexGuard) CanLock() (canLock bool, mutexState RWMutexState) { 134 | g.rw.mu.Lock() 135 | defer g.rw.mu.Unlock() 136 | 137 | switch g.state { 138 | case RWMutexStateUnlocked: 139 | return g.rw.sharedN == 0 && g.rw.excl == nil, g.rw.state() 140 | case RWMutexStateShared: 141 | return g.rw.sharedN == 1, g.rw.state() 142 | case RWMutexStateExclusive: 143 | return true, g.rw.state() 144 | default: 145 | panic("RWMutexGuard.CanLock(): unreachable") 146 | } 147 | } 148 | 149 | // RLock attempts to obtain a shared lock for the guard. Returns an error if ctx is done. 150 | func (g *RWMutexGuard) RLock(ctx context.Context) error { 151 | if g.TryRLock() { 152 | return nil 153 | } 154 | 155 | ticker := time.NewTicker(RWMutexInterval) 156 | defer ticker.Stop() 157 | 158 | for { 159 | select { 160 | case <-ctx.Done(): 161 | return context.Cause(ctx) 162 | case <-ticker.C: 163 | if g.TryRLock() { 164 | return nil 165 | } 166 | } 167 | } 168 | } 169 | 170 | // TryRLock attempts to obtain a shared lock on the mutex for the guard. This will upgrade 171 | // an unlocked guard and downgrade an exclusive guard. Shared guards are a no-op. 172 | func (g *RWMutexGuard) TryRLock() bool { 173 | g.rw.mu.Lock() 174 | prevState := g.rw.state() 175 | v := g.tryRLock() 176 | fn, newState := g.rw.OnLockStateChange, g.rw.state() 177 | g.rw.mu.Unlock() 178 | 179 | if fn != nil && prevState != newState { 180 | fn(prevState, newState) 181 | } 182 | return v 183 | } 184 | 185 | func (g *RWMutexGuard) tryRLock() bool { 186 | switch g.state { 187 | case RWMutexStateUnlocked: 188 | if g.rw.excl != nil { 189 | return false 190 | } 191 | g.rw.sharedN++ 192 | g.state = RWMutexStateShared 193 | return true 194 | 195 | case RWMutexStateShared: 196 | return true // no-op 197 | 198 | case RWMutexStateExclusive: 199 | assert(g.rw.excl == g, "attempted downgrade of non-exclusive guard") 200 | g.rw.sharedN, g.rw.excl = 1, nil 201 | g.state = RWMutexStateShared 202 | return true 203 | 204 | default: 205 | panic("RWMutexGuard.TryRLock(): unreachable") 206 | } 207 | } 208 | 209 | // CanRLock returns true if the guard can become a shared lock. 210 | func (g *RWMutexGuard) CanRLock() bool { 211 | g.rw.mu.Lock() 212 | defer g.rw.mu.Unlock() 213 | 214 | switch g.state { 215 | case RWMutexStateUnlocked: 216 | return g.rw.excl == nil 217 | case RWMutexStateShared, RWMutexStateExclusive: 218 | return true 219 | default: 220 | panic("RWMutexGuard.CanRLock(): unreachable") 221 | } 222 | } 223 | 224 | // Unlock unlocks the underlying mutex. 225 | func (g *RWMutexGuard) Unlock() { 226 | g.rw.mu.Lock() 227 | prevState := g.rw.state() 228 | g.unlock() 229 | fn, newState := g.rw.OnLockStateChange, g.rw.state() 230 | g.rw.mu.Unlock() 231 | 232 | if fn != nil && prevState != newState { 233 | fn(prevState, newState) 234 | } 235 | } 236 | 237 | func (g *RWMutexGuard) unlock() { 238 | switch g.state { 239 | case RWMutexStateUnlocked: 240 | return // already unlocked, skip 241 | case RWMutexStateShared: 242 | assert(g.rw.sharedN > 0, "invalid shared lock state on unlock") 243 | g.rw.sharedN-- 244 | g.state = RWMutexStateUnlocked 245 | case RWMutexStateExclusive: 246 | assert(g.rw.excl == g, "attempted unlock of non-exclusive guard") 247 | g.rw.sharedN, g.rw.excl = 0, nil 248 | g.state = RWMutexStateUnlocked 249 | default: 250 | panic("RWMutexGuard.Unlock(): unreachable") 251 | } 252 | } 253 | 254 | // RWMutexState represents the lock state of an RWMutex or RWMutexGuard. 255 | type RWMutexState int 256 | 257 | // String returns the string representation of the state. 258 | func (s RWMutexState) String() string { 259 | switch s { 260 | case RWMutexStateUnlocked: 261 | return "unlocked" 262 | case RWMutexStateShared: 263 | return "shared" 264 | case RWMutexStateExclusive: 265 | return "exclusive" 266 | default: 267 | return fmt.Sprintf("", s) 268 | } 269 | } 270 | 271 | const ( 272 | RWMutexStateUnlocked = RWMutexState(iota) 273 | RWMutexStateShared 274 | RWMutexStateExclusive 275 | ) 276 | -------------------------------------------------------------------------------- /rwmutex_test.go: -------------------------------------------------------------------------------- 1 | package litefs_test 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "testing" 7 | "time" 8 | 9 | "github.com/superfly/litefs" 10 | "golang.org/x/sync/errgroup" 11 | ) 12 | 13 | func TestRWMutexGuard_TryLock(t *testing.T) { 14 | t.Run("OK", func(t *testing.T) { 15 | var mu litefs.RWMutex 16 | g0, g1 := mu.Guard(), mu.Guard() 17 | if !g0.TryLock() { 18 | t.Fatal("expected lock") 19 | } else if g1.TryLock() { 20 | t.Fatal("expected lock failure") 21 | } 22 | g0.Unlock() 23 | }) 24 | 25 | t.Run("Relock", func(t *testing.T) { 26 | var mu litefs.RWMutex 27 | g0 := mu.Guard() 28 | if !g0.TryLock() { 29 | t.Fatal("expected lock") 30 | } 31 | g0.Unlock() 32 | 33 | g1 := mu.Guard() 34 | if !g1.TryLock() { 35 | t.Fatal("expected lock after unlock") 36 | } 37 | g1.Unlock() 38 | }) 39 | 40 | t.Run("BlockedBySharedLock", func(t *testing.T) { 41 | var mu litefs.RWMutex 42 | g0 := mu.Guard() 43 | if !g0.TryRLock() { 44 | t.Fatal("expected lock") 45 | } 46 | g0.Unlock() 47 | 48 | g1 := mu.Guard() 49 | if !g1.TryLock() { 50 | t.Fatal("expected lock after shared unlock") 51 | } 52 | g1.Unlock() 53 | }) 54 | } 55 | 56 | func TestRWMutexGuard_Lock(t *testing.T) { 57 | t.Run("OK", func(t *testing.T) { 58 | var mu litefs.RWMutex 59 | g0 := mu.Guard() 60 | if err := g0.Lock(context.Background()); err != nil { 61 | t.Fatal(err) 62 | } 63 | 64 | ch := make(chan int) 65 | var g errgroup.Group 66 | g.Go(func() error { 67 | g1 := mu.Guard() 68 | if err := g1.Lock(context.Background()); err != nil { 69 | return err 70 | } 71 | close(ch) 72 | return nil 73 | }) 74 | 75 | select { 76 | case <-ch: 77 | t.Fatal("lock obtained too soon") 78 | case <-time.After(100 * time.Millisecond): 79 | } 80 | 81 | g0.Unlock() 82 | 83 | select { 84 | case <-ch: 85 | case <-time.After(100 * time.Millisecond): 86 | t.Fatal("timeout waiting for lock") 87 | } 88 | 89 | if err := g.Wait(); err != nil { 90 | t.Fatalf("goroutine failed: %s", err) 91 | } 92 | }) 93 | 94 | t.Run("ContextCanceled", func(t *testing.T) { 95 | var mu litefs.RWMutex 96 | g0 := mu.Guard() 97 | if err := g0.Lock(context.Background()); err != nil { 98 | t.Fatal(err) 99 | } 100 | defer g0.Unlock() 101 | 102 | ctx, cancel := context.WithCancel(context.Background()) 103 | var g errgroup.Group 104 | g.Go(func() error { 105 | g1 := mu.Guard() 106 | if err := g1.Lock(ctx); err != context.Canceled { 107 | return err 108 | } 109 | return nil 110 | }) 111 | 112 | time.Sleep(100 * time.Millisecond) 113 | cancel() 114 | time.Sleep(100 * time.Millisecond) 115 | 116 | if err := g.Wait(); err != nil { 117 | t.Fatalf("goroutine failed: %s", err) 118 | } 119 | }) 120 | } 121 | 122 | func TestRWMutexGuard_CanLock(t *testing.T) { 123 | t.Run("WithExclusiveLock", func(t *testing.T) { 124 | var mu litefs.RWMutex 125 | guard0 := mu.Guard() 126 | if canLock, _ := guard0.CanLock(); !canLock { 127 | t.Fatal("expected to be able to lock") 128 | } 129 | g := mu.Guard() 130 | g.TryLock() 131 | guard1 := mu.Guard() 132 | if canLock, mutexState := guard1.CanLock(); canLock { 133 | t.Fatal("expected to not be able to lock") 134 | } else if got, want := mutexState, litefs.RWMutexStateExclusive; got != want { 135 | t.Fatalf("mutex=%s, expected %s", got, want) 136 | } 137 | g.Unlock() 138 | 139 | guard2 := mu.Guard() 140 | if canLock, _ := guard2.CanLock(); !canLock { 141 | t.Fatal("expected to be able to lock again") 142 | } 143 | }) 144 | 145 | t.Run("WithSharedLock", func(t *testing.T) { 146 | var mu litefs.RWMutex 147 | guard0 := mu.Guard() 148 | if canLock, _ := guard0.CanLock(); !canLock { 149 | t.Fatal("expected to be able to lock") 150 | } 151 | g := mu.Guard() 152 | g.TryRLock() 153 | guard1 := mu.Guard() 154 | if canLock, mutexState := guard1.CanLock(); canLock { 155 | t.Fatal("expected to not be able to lock") 156 | } else if got, want := mutexState, litefs.RWMutexStateShared; got != want { 157 | t.Fatalf("mutex=%s, want %s", got, want) 158 | } 159 | g.Unlock() 160 | 161 | guard2 := mu.Guard() 162 | if canLock, _ := guard2.CanLock(); !canLock { 163 | t.Fatal("expected to be able to lock again") 164 | } 165 | }) 166 | } 167 | 168 | func TestRWMutexGuard_TryRLock(t *testing.T) { 169 | t.Run("OK", func(t *testing.T) { 170 | var mu litefs.RWMutex 171 | g0 := mu.Guard() 172 | if !g0.TryRLock() { 173 | t.Fatal("expected lock") 174 | } 175 | g1 := mu.Guard() 176 | if !g1.TryRLock() { 177 | t.Fatal("expected another lock") 178 | } 179 | if guard := mu.Guard(); guard.TryLock() { 180 | t.Fatal("expected lock failure") 181 | } 182 | g0.Unlock() 183 | g1.Unlock() 184 | 185 | g2 := mu.Guard() 186 | if !g2.TryLock() { 187 | t.Fatal("expected lock after unlock") 188 | } 189 | g2.Unlock() 190 | }) 191 | 192 | t.Run("BlockedByExclusiveLock", func(t *testing.T) { 193 | var mu litefs.RWMutex 194 | g0 := mu.Guard() 195 | if !g0.TryLock() { 196 | t.Fatal("expected lock") 197 | } 198 | if guard := mu.Guard(); guard.TryRLock() { 199 | t.Fatalf("expected lock failure") 200 | } 201 | g0.Unlock() 202 | 203 | g1 := mu.Guard() 204 | if !g1.TryLock() { 205 | t.Fatal("expected lock after unlock") 206 | } 207 | g1.Unlock() 208 | }) 209 | 210 | t.Run("AfterDowngrade", func(t *testing.T) { 211 | var mu litefs.RWMutex 212 | g0 := mu.Guard() 213 | if !g0.TryLock() { 214 | t.Fatal("expected lock") 215 | } 216 | if guard := mu.Guard(); guard.TryRLock() { 217 | t.Fatalf("expected lock failure") 218 | } 219 | if !g0.TryRLock() { 220 | t.Fatal("expected downgrade") 221 | } 222 | 223 | g1 := mu.Guard() 224 | if !g1.TryRLock() { 225 | t.Fatal("expected lock after downgrade") 226 | } 227 | g0.Unlock() 228 | g1.Unlock() 229 | }) 230 | 231 | t.Run("AfterUpgrade", func(t *testing.T) { 232 | var mu litefs.RWMutex 233 | g0 := mu.Guard() 234 | if !g0.TryRLock() { 235 | t.Fatal("expected lock") 236 | } 237 | if !g0.TryLock() { 238 | t.Fatal("expected upgrade") 239 | } 240 | if guard := mu.Guard(); guard.TryRLock() { 241 | t.Fatalf("expected lock failure") 242 | } 243 | if !g0.TryRLock() { // downgrade 244 | t.Fatal("expected downgrade") 245 | } 246 | 247 | g1 := mu.Guard() 248 | if !g1.TryRLock() { 249 | t.Fatal("expected lock after downgrade") 250 | } 251 | g0.Unlock() 252 | g1.Unlock() 253 | }) 254 | } 255 | 256 | func TestRWMutexGuard_RLock(t *testing.T) { 257 | t.Run("MultipleSharedLocks", func(t *testing.T) { 258 | var mu litefs.RWMutex 259 | g0 := mu.Guard() 260 | if err := g0.RLock(context.Background()); err != nil { 261 | t.Fatal(err) 262 | } 263 | 264 | g1 := mu.Guard() 265 | if err := g1.RLock(context.Background()); err != nil { 266 | t.Fatal(err) 267 | } 268 | 269 | g0.Unlock() 270 | g1.Unlock() 271 | }) 272 | 273 | t.Run("Blocked", func(t *testing.T) { 274 | var mu litefs.RWMutex 275 | g0 := mu.Guard() 276 | if err := g0.Lock(context.Background()); err != nil { 277 | t.Fatal(err) 278 | } 279 | 280 | var g errgroup.Group 281 | g.Go(func() error { 282 | g1 := mu.Guard() 283 | if err := g1.RLock(context.Background()); err != nil { 284 | return err 285 | } 286 | g1.Unlock() 287 | return nil 288 | }) 289 | 290 | time.Sleep(100 * time.Millisecond) 291 | g0.Unlock() 292 | time.Sleep(100 * time.Millisecond) 293 | 294 | if err := g.Wait(); err != nil { 295 | t.Fatalf("goroutine failed: %s", err) 296 | } 297 | }) 298 | 299 | t.Run("ContextCanceled", func(t *testing.T) { 300 | var mu litefs.RWMutex 301 | g0 := mu.Guard() 302 | if err := g0.Lock(context.Background()); err != nil { 303 | t.Fatal(err) 304 | } 305 | defer g0.Unlock() 306 | 307 | ch := make(chan int) 308 | ctx, cancel := context.WithCancel(context.Background()) 309 | var g errgroup.Group 310 | g.Go(func() error { 311 | g1 := mu.Guard() 312 | if err := g1.RLock(ctx); err != context.Canceled { 313 | return fmt.Errorf("unexpected error: %v", err) 314 | } 315 | close(ch) 316 | return nil 317 | }) 318 | 319 | time.Sleep(100 * time.Millisecond) 320 | cancel() 321 | <-ch 322 | 323 | if err := g.Wait(); err != nil { 324 | t.Fatalf("goroutine failed: %s", err) 325 | } 326 | }) 327 | } 328 | 329 | func TestRWMutexGuard_CanRLock(t *testing.T) { 330 | t.Run("WithExclusiveLock", func(t *testing.T) { 331 | var mu litefs.RWMutex 332 | if guard := mu.Guard(); !guard.CanRLock() { 333 | t.Fatal("expected to be able to lock") 334 | } 335 | g := mu.Guard() 336 | g.TryLock() 337 | if guard := mu.Guard(); guard.CanRLock() { 338 | t.Fatal("expected to not be able to lock") 339 | } 340 | g.Unlock() 341 | 342 | if guard := mu.Guard(); !guard.CanRLock() { 343 | t.Fatal("expected to be able to lock again") 344 | } 345 | }) 346 | 347 | t.Run("WithSharedLock", func(t *testing.T) { 348 | var mu litefs.RWMutex 349 | if guard := mu.Guard(); !guard.CanRLock() { 350 | t.Fatal("expected to be able to lock") 351 | } 352 | g := mu.Guard() 353 | g.TryRLock() 354 | if guard := mu.Guard(); !guard.CanRLock() { 355 | t.Fatal("expected to be able to lock") 356 | } 357 | g.Unlock() 358 | 359 | if guard := mu.Guard(); !guard.CanRLock() { 360 | t.Fatal("expected to be able to lock again") 361 | } 362 | }) 363 | } 364 | -------------------------------------------------------------------------------- /testdata/db/enforce-retention/database: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/superfly/litefs/a51e72d84eeb96ad210ef407230161192120ab96/testdata/db/enforce-retention/database -------------------------------------------------------------------------------- /testdata/db/write-snapshot-to/database: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/superfly/litefs/a51e72d84eeb96ad210ef407230161192120ab96/testdata/db/write-snapshot-to/database -------------------------------------------------------------------------------- /testdata/store/open-and-write-snapshot/dbs/sqlite.db/database: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/superfly/litefs/a51e72d84eeb96ad210ef407230161192120ab96/testdata/store/open-and-write-snapshot/dbs/sqlite.db/database -------------------------------------------------------------------------------- /testdata/store/open-and-write-snapshot/dbs/sqlite.db/ltx/000000000000000d-000000000000000d.ltx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/superfly/litefs/a51e72d84eeb96ad210ef407230161192120ab96/testdata/store/open-and-write-snapshot/dbs/sqlite.db/ltx/000000000000000d-000000000000000d.ltx -------------------------------------------------------------------------------- /testdata/store/open-invalid-database-header/dbs/test.db/database: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /testdata/store/open-name-only/dbs/test.db/database: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/superfly/litefs/a51e72d84eeb96ad210ef407230161192120ab96/testdata/store/open-name-only/dbs/test.db/database -------------------------------------------------------------------------------- /testdata/store/open-short-database/dbs/test.db/database: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /testdata/wal-reader/frame-checksum-mismatch/wal: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/superfly/litefs/a51e72d84eeb96ad210ef407230161192120ab96/testdata/wal-reader/frame-checksum-mismatch/wal -------------------------------------------------------------------------------- /testdata/wal-reader/ok/wal: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/superfly/litefs/a51e72d84eeb96ad210ef407230161192120ab96/testdata/wal-reader/ok/wal -------------------------------------------------------------------------------- /testdata/wal-reader/salt-mismatch/wal: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/superfly/litefs/a51e72d84eeb96ad210ef407230161192120ab96/testdata/wal-reader/salt-mismatch/wal -------------------------------------------------------------------------------- /tests/litefs.yml: -------------------------------------------------------------------------------- 1 | # The FUSE section handles settings on the FUSE file system. FUSE 2 | # provides a layer for intercepting SQLite transactions on the 3 | # primary node so they can be shipped to replica nodes transparently. 4 | fuse: 5 | # Required. This is the mount directory that applications will 6 | # use to access their SQLite databases. 7 | # Use non-root path as sqlite tests can't run as root 8 | dir: "/home/build/litefs" 9 | 10 | # Set this flag to true to allow non-root users to access mount. 11 | # You must set the "user_allow_other" option in /etc/fuse.conf first. 12 | allow-other: false 13 | 14 | # The debug flag enables debug logging of all FUSE API calls. 15 | # This will produce a lot of logging. Not for general use. 16 | debug: true 17 | 18 | # The data section specifies where internal LiteFS data is stored 19 | # and how long to retain the transaction files. 20 | # 21 | # Transaction files are used to ship changes to replica nodes so 22 | # they should persist long enough for replicas to retrieve them, 23 | # even in the face of a short network interruption or a redeploy. 24 | # Under high load, these files can grow large so it's not advised 25 | # to extend retention too long. 26 | data: 27 | # Path to internal data storage. 28 | # Use non-root path as sqlite tests can't run as root 29 | dir: "/home/build/lib/litefs" 30 | 31 | # Duration to keep LTX files. Latest LTX file is always kept. 32 | retention: "10m" 33 | 34 | # Frequency with which to check for LTX files to delete. 35 | retention-monitor-interval: "1m" 36 | 37 | # If true, then LiteFS will not wait until the node becomes the 38 | # primary or connects to the primary before starting the subprocess. 39 | skip-sync: false 40 | 41 | # If true, then LiteFS will not exit if there is a validation 42 | # issue on startup. This can be useful for debugging issues as 43 | # it avoids constantly restarting the node on ephemeral hosting. 44 | exit-on-error: false 45 | 46 | # This section defines settings for the LiteFS HTTP API server. 47 | # This API server is how noes communicate with each other. 48 | http: 49 | # Specifies the bind address of the HTTP API server. 50 | addr: ":20202" 51 | 52 | # The lease section defines how LiteFS creates a cluster and 53 | # implements leader election. For dynamic clusters, use the 54 | # "consul". This allows the primary to change automatically when 55 | # the current primary goes down. For a simpler setup, use 56 | # "static" which assigns a single node to be the primary and does 57 | # not failover. 58 | lease: 59 | # Required. Must be either "consul" or "static". 60 | type: "static" 61 | 62 | # Required. The URL for this node's LiteFS API. 63 | # Should match HTTP port. 64 | advertise-url: "http://myhost:20202" 65 | 66 | # Sets the hostname that other nodes will use to reference this 67 | # node. Automatically assigned based on hostname(1) if not set. 68 | hostname: "myhost" 69 | 70 | # Specifies whether the node can become the primary. If using 71 | # "static" leasing, this should be set to true on the primary 72 | # and false on the replicas. 73 | candidate: true 74 | 75 | # The tracing section enables a rolling, on-disk tracing log. 76 | # This records every operation to the database so it can be 77 | # verbose and it can degrade performance. This is for debugging 78 | # only and should not typically be enabled in production. 79 | tracing: 80 | # Output path on disk. 81 | # Use non-root path as sqlite tests can't run as root 82 | path: "/home/build/log/lifefs/trace.log" 83 | 84 | # Maximum size of a single trace log before rolling. 85 | # Specified in megabytes. 86 | max-size: 64 87 | 88 | # Maximum number of trace logs to retain. 89 | max-count: 10 90 | 91 | # If true, historical logs will be compressed using gzip. 92 | compress: true 93 | -------------------------------------------------------------------------------- /tests/test.sh: -------------------------------------------------------------------------------- 1 | #! /bin/sh 2 | 3 | litefs mount -- /home/build/sqlite/testfixture /home/build/sqlite/test/$1 --testdir=/home/build/litefs 4 | --------------------------------------------------------------------------------