├── .gitignore ├── go.test.sh ├── .codecov.yml ├── go.coverage.sh ├── .github ├── workflows │ ├── pkg.yml │ ├── x.yml │ └── ci.yml ├── dependabot.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── stale.yml ├── Makefile ├── CONTRIBUTING.md ├── .editorconfig ├── go.mod ├── LICENSE ├── README.md ├── go.sum ├── watcher_test.go ├── watcher.go ├── tracker.go ├── tail_test.go └── tail.go /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | _bin/* 3 | ./examples 4 | 5 | *-fuzz.zip 6 | 7 | *.out 8 | -------------------------------------------------------------------------------- /go.test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | # test with -race 6 | go test --timeout 5m -race ./... 7 | -------------------------------------------------------------------------------- /.codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | patch: false 4 | project: 5 | default: 6 | threshold: 0.5% 7 | -------------------------------------------------------------------------------- /go.coverage.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | go test -v -coverpkg=./... -coverprofile=profile.out ./... 6 | go tool cover -func profile.out 7 | -------------------------------------------------------------------------------- /.github/workflows/pkg.yml: -------------------------------------------------------------------------------- 1 | name: pkg 2 | 3 | on: 4 | push: 5 | tags: 6 | - v* 7 | 8 | jobs: 9 | run: 10 | uses: go-faster/x/.github/workflows/release.yml@main 11 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | test: 2 | @./go.test.sh 3 | .PHONY: test 4 | 5 | coverage: 6 | @./go.coverage.sh 7 | .PHONY: coverage 8 | 9 | test_fast: 10 | go test ./... 11 | 12 | tidy: 13 | go mod tidy 14 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: gomod 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | - package-ecosystem: github-actions 8 | directory: "/" 9 | schedule: 10 | interval: daily 11 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | Comments (at Discussions), Issues and PRs are always welcome. In the case of issues, 3 | code examples make it easier to reproduce the problem. In the case of PRs add tests 4 | if applicable so we make sure nothing breaks for people using the library on different 5 | OSes. 6 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org/ 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | insert_final_newline = true 8 | trim_trailing_whitespace = true 9 | end_of_line = lf 10 | 11 | [{*.go, go.mod}] 12 | indent_style = tab 13 | indent_size = 4 14 | 15 | [{*.yml,*.yaml}] 16 | indent_style = space 17 | indent_size = 2 18 | 19 | [*.py] 20 | indent_style = space 21 | indent_size = 4 22 | 23 | # Makefiles always use tabs for indentation 24 | [Makefile] 25 | indent_style = tab 26 | -------------------------------------------------------------------------------- /.github/workflows/x.yml: -------------------------------------------------------------------------------- 1 | name: x 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | workflow_dispatch: 8 | 9 | # Common Go workflows from go faster 10 | # See https://github.com/go-faster/x 11 | jobs: 12 | # test workflow is customized 13 | cover: 14 | uses: go-faster/x/.github/workflows/cover.yml@main 15 | lint: 16 | uses: go-faster/x/.github/workflows/lint.yml@main 17 | commit: 18 | uses: go-faster/x/.github/workflows/commit.yml@main 19 | codeql: 20 | uses: go-faster/x/.github/workflows/codeql.yml@main 21 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/go-faster/tail 2 | 3 | go 1.21 4 | 5 | require ( 6 | github.com/fsnotify/fsnotify v1.7.0 7 | github.com/go-faster/errors v0.6.1 8 | github.com/stretchr/testify v1.8.4 9 | go.uber.org/atomic v1.11.0 10 | go.uber.org/zap v1.26.0 11 | golang.org/x/sync v0.4.0 12 | ) 13 | 14 | require ( 15 | github.com/davecgh/go-spew v1.1.1 // indirect 16 | github.com/pmezard/go-difflib v1.0.0 // indirect 17 | go.uber.org/multierr v1.10.0 // indirect 18 | golang.org/x/sys v0.4.0 // indirect 19 | gopkg.in/yaml.v3 v3.0.1 // indirect 20 | ) 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **Expected behaviour** 14 | A clear and concise description of what you expected to happen. 15 | 16 | **To Reproduce** 17 | Steps to reproduce the behaviour:, preferably with a code sample. 18 | 19 | **System information** 20 | - tail version [e.g. 1.4.6] 21 | - OS: [e.g. Ubuntu 20.04] 22 | - Arch: [e.g. amd64] 23 | 24 | **Additional context** 25 | Add any other context about the problem here. 26 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an issue becomes stale 2 | daysUntilStale: 366 3 | 4 | # Number of days of inactivity before a stale issue is closed 5 | daysUntilClose: 30 6 | 7 | # Issues with these labels will never be considered stale 8 | exemptLabels: 9 | - pinned 10 | - security 11 | - bug 12 | - blocked 13 | - protected 14 | - triaged 15 | 16 | # Label to use when marking an issue as stale 17 | staleLabel: stale 18 | 19 | # Comment to post when marking an issue as stale. Set to `false` to disable 20 | markComment: > 21 | This issue has been automatically marked as stale because it has not had 22 | recent activity. It will be closed if no further activity occurs. Thank you 23 | for your contributions. 24 | 25 | # Comment to post when closing a stale issue. Set to `false` to disable 26 | closeComment: false -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2021 VK 4 | Copyright (c) 2019 FOSS contributors of https://github.com/nxadm/tail 5 | Copyright (c) 2015 Hewlett Packard Enterprise Development LP 6 | Copyright (c) 2014 ActiveState 7 | 8 | Permission is hereby granted, free of charge, to any person obtaining a copy 9 | of this software and associated documentation files (the "Software"), to deal 10 | in the Software without restriction, including without limitation the rights 11 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 12 | copies of the Software, and to permit persons to whom the Software is 13 | furnished to do so, subject to the following conditions: 14 | The above copyright notice and this permission notice shall be included in all 15 | copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 | SOFTWARE. 24 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | on: 3 | push: 4 | tags: 5 | - v* 6 | branches: 7 | - main 8 | pull_request: 9 | workflow_dispatch: 10 | 11 | jobs: 12 | test: 13 | runs-on: ${{ matrix.runner }} 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | flags: [""] 18 | arch: 19 | - amd64 20 | runner: 21 | - ubuntu-latest 22 | go: 23 | - 1.21.x 24 | include: 25 | - arch: 386 26 | runner: ubuntu-latest 27 | go: 1.21.x 28 | - arch: amd64 29 | runner: ubuntu-latest 30 | flags: "-race" 31 | go: 1.21.x 32 | steps: 33 | - name: Checkout code 34 | uses: actions/checkout@v4 35 | 36 | - name: Install Go 37 | uses: actions/setup-go@v4 38 | with: 39 | go-version: ${{ matrix.go }} 40 | 41 | - name: Get Go environment 42 | id: go-env 43 | run: | 44 | echo "::set-output name=cache::$(go env GOCACHE)" 45 | echo "::set-output name=modcache::$(go env GOMODCACHE)" 46 | 47 | - name: Set up cache 48 | uses: actions/cache@v3 49 | with: 50 | path: | 51 | ${{ steps.go-env.outputs.cache }} 52 | ${{ steps.go-env.outputs.modcache }} 53 | key: test-${{ runner.os }}-${{ matrix.arch }}-go-${{ matrix.go }}-${{ hashFiles('**/go.sum') }} 54 | restore-keys: | 55 | test-${{ runner.os }}-${{ matrix.arch }}-go-${{ matrix.go }}- 56 | 57 | - name: Run tests 58 | env: 59 | GOARCH: ${{ matrix.arch }} 60 | GOFLAGS: ${{ matrix.flags }} 61 | run: go test --timeout 5m ./... 62 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # tail [![Go Reference](https://img.shields.io/badge/go-pkg-00ADD8)](https://pkg.go.dev/github.com/go-faster/tail#section-documentation) [![codecov](https://img.shields.io/codecov/c/github/go-faster/tail?label=cover)](https://codecov.io/gh/go-faster/tail) [![experimental](https://img.shields.io/badge/-experimental-blueviolet)](https://go-faster.org/docs/projects/status#experimental) 2 | 3 | Package tail implements file tailing with [fsnotify](https://github.com/fsnotify/fsnotify). 4 | 5 | Fork of [nxadm/tail](https://github.com/nxadm/tail), simplified, reworked and optimized. 6 | Currently, supports only Linux and Darwin. 7 | 8 | ```console 9 | go get github.com/go-faster/tail 10 | ``` 11 | 12 | ```go 13 | package main 14 | 15 | import ( 16 | "context" 17 | "fmt" 18 | "io" 19 | "os" 20 | "time" 21 | 22 | "github.com/go-faster/tail" 23 | ) 24 | 25 | func main() { 26 | t := tail.File("/var/log/application.txt", tail.Config{ 27 | Follow: true, // tail -f 28 | BufferSize: 1024 * 128, // 128 kb for internal reader buffer 29 | 30 | // Force polling if zero events are observed for longer than a minute. 31 | // Optional, just a safeguard to be sure that we are not stuck forever 32 | // if we miss inotify event. 33 | NotifyTimeout: time.Minute, 34 | 35 | // You can specify position to start tailing, same as Seek arguments. 36 | // For example, you can use the latest processed Line.Location() value. 37 | Location: &tail.Location{Whence: io.SeekStart, Offset: 0}, 38 | }) 39 | ctx := context.Background() 40 | // Enjoy zero allocation fast tailing with context support. 41 | if err := t.Tail(ctx, func(ctx context.Context, l *tail.Line) error { 42 | _, _ = fmt.Fprintln(os.Stdout, string(l.Data)) 43 | return nil 44 | }); err != nil { 45 | panic(err) 46 | } 47 | } 48 | ``` 49 | 50 | ## TODO 51 | - [ ] Tests for removing, tailing and creating events 52 | - [ ] Decide on Windows support 53 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= 4 | github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= 5 | github.com/go-faster/errors v0.6.1 h1:nNIPOBkprlKzkThvS/0YaX8Zs9KewLCOSFQS5BU06FI= 6 | github.com/go-faster/errors v0.6.1/go.mod h1:5MGV2/2T9yvlrbhe9pD9LO5Z/2zCSq2T8j+Jpi2LAyY= 7 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 8 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 9 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= 10 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 11 | go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= 12 | go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= 13 | go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk= 14 | go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo= 15 | go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= 16 | go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= 17 | go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= 18 | go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= 19 | golang.org/x/sync v0.4.0 h1:zxkM55ReGkDlKSM+Fu41A+zmbZuaPVbGMzvvdUPznYQ= 20 | golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= 21 | golang.org/x/sys v0.4.0 h1:Zr2JFtRQNX3BCZ8YtxRE9hNJYC8J6I1MVbMg6owUp18= 22 | golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 23 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 24 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 25 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 26 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 27 | -------------------------------------------------------------------------------- /watcher_test.go: -------------------------------------------------------------------------------- 1 | package tail 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "testing" 9 | 10 | "github.com/fsnotify/fsnotify" 11 | "github.com/go-faster/errors" 12 | "github.com/stretchr/testify/assert" 13 | "github.com/stretchr/testify/require" 14 | "go.uber.org/zap/zaptest" 15 | "golang.org/x/sync/errgroup" 16 | ) 17 | 18 | type wrapTracker struct { 19 | file func(name string) 20 | create func(name string) 21 | t Tracker 22 | } 23 | 24 | func (w wrapTracker) watchFile(name string) error { 25 | if w.file != nil { 26 | defer w.file(name) 27 | } 28 | return w.t.watchFile(name) 29 | } 30 | 31 | func (w wrapTracker) watchCreate(name string) error { 32 | if w.create != nil { 33 | defer w.create(name) 34 | } 35 | return w.t.watchCreate(name) 36 | } 37 | 38 | func (w wrapTracker) removeWatchName(name string) error { 39 | return w.t.removeWatchName(name) 40 | } 41 | 42 | func (w wrapTracker) removeWatchCreate(name string) error { 43 | return w.t.removeWatchCreate(name) 44 | } 45 | 46 | func (w wrapTracker) listenEvents(name string) <-chan fsnotify.Event { 47 | return w.t.listenEvents(name) 48 | } 49 | 50 | func TestCreateAfterWatch(t *testing.T) { 51 | lg := zaptest.NewLogger(t) 52 | g, ctx := errgroup.WithContext(context.Background()) 53 | name := filepath.Join(t.TempDir(), "foo.txt") 54 | 55 | const lines = 10 56 | 57 | started := make(chan struct{}) 58 | g.Go(func() error { 59 | select { 60 | case <-started: 61 | case <-ctx.Done(): 62 | return ctx.Err() 63 | } 64 | 65 | f, err := os.Create(name) 66 | if err != nil { 67 | return err 68 | } 69 | for i := 0; i < lines; i++ { 70 | if _, err := fmt.Fprintln(f, line); err != nil { 71 | return err 72 | } 73 | } 74 | return f.Close() 75 | }) 76 | 77 | tailer := File(name, Config{ 78 | NotifyTimeout: notifyTimeout, 79 | Follow: true, 80 | Logger: lg, 81 | Tracker: wrapTracker{ 82 | t: NewTracker(lg), 83 | create: func(name string) { 84 | close(started) 85 | }, 86 | }, 87 | }) 88 | 89 | read := make(chan struct{}) 90 | g.Go(func() error { 91 | var gotLines int 92 | // Ensure that each tailer got all lines. 93 | h := func(ctx context.Context, l *Line) error { 94 | assert.Equal(t, line, string(l.Data)) 95 | gotLines++ 96 | if gotLines == lines { 97 | close(read) 98 | } 99 | return nil 100 | } 101 | 102 | ctx, cancel := context.WithTimeout(ctx, timeout) 103 | defer cancel() 104 | 105 | if err := tailer.Tail(ctx, h); !errors.Is(err, errStop) { 106 | return err 107 | } 108 | 109 | return nil 110 | }) 111 | 112 | // Read lines. 113 | g.Go(func() error { 114 | select { 115 | case <-ctx.Done(): 116 | return ctx.Err() 117 | case <-read: // ok 118 | } 119 | return os.Remove(name) 120 | }) 121 | 122 | require.NoError(t, g.Wait()) 123 | } 124 | -------------------------------------------------------------------------------- /watcher.go: -------------------------------------------------------------------------------- 1 | package tail 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "path/filepath" 7 | 8 | "github.com/fsnotify/fsnotify" 9 | "github.com/go-faster/errors" 10 | "go.uber.org/zap" 11 | ) 12 | 13 | // event that happened with file. 14 | type event int 15 | 16 | // Possible file events. 17 | const ( 18 | evModified event = iota 19 | evTruncated 20 | evDeleted 21 | ) 22 | 23 | func (c event) String() string { 24 | switch c { 25 | case evDeleted: 26 | return "deleted" 27 | case evTruncated: 28 | return "truncated" 29 | default: 30 | return "modified" 31 | } 32 | } 33 | 34 | // watchHandler is called on event to file. 35 | type watchHandler func(ctx context.Context, e event) error 36 | 37 | // watcher uses newWatcher to monitor file changes. 38 | type watcher struct { 39 | t Tracker 40 | lg *zap.Logger 41 | name string 42 | size int64 43 | } 44 | 45 | func newWatcher(lg *zap.Logger, t Tracker, filename string) *watcher { 46 | return &watcher{ 47 | t: t, 48 | name: filepath.Clean(filename), 49 | size: 0, 50 | lg: lg, 51 | } 52 | } 53 | 54 | func (w *watcher) WaitExists(ctx context.Context) error { 55 | if err := w.t.watchCreate(w.name); err != nil { 56 | return errors.Wrap(err, "create") 57 | } 58 | defer func() { 59 | if err := w.t.removeWatchCreate(w.name); err != nil { 60 | w.lg.Debug("Failed to remove create event handler", zap.Error(err)) 61 | } 62 | }() 63 | 64 | // Check that file is already exists. 65 | if _, err := os.Stat(w.name); !os.IsNotExist(err) { 66 | // File exists, or stat returned an error. 67 | return err 68 | } 69 | 70 | events := w.t.listenEvents(w.name) 71 | 72 | for { 73 | select { 74 | case evt, ok := <-events: 75 | if !ok { 76 | return errors.New("newWatcher watcher has been closed") 77 | } 78 | evtName, err := filepath.Abs(evt.Name) 79 | if err != nil { 80 | return errors.Wrap(err, "abs") 81 | } 82 | fwFilename, err := filepath.Abs(w.name) 83 | if err != nil { 84 | return errors.Wrap(err, "abs") 85 | } 86 | if evtName == fwFilename { 87 | return nil 88 | } 89 | case <-ctx.Done(): 90 | return ctx.Err() 91 | } 92 | } 93 | } 94 | 95 | func (w *watcher) WatchEvents(ctx context.Context, offset int64, fn watchHandler) error { 96 | if err := w.t.watchFile(w.name); err != nil { 97 | return errors.Wrap(err, "watch") 98 | } 99 | 100 | w.size = offset 101 | events := w.t.listenEvents(w.name) 102 | defer func() { 103 | if err := w.t.removeWatchName(w.name); err != nil { 104 | w.lg.Debug("Failed to remove event handler", zap.Error(err)) 105 | } 106 | }() 107 | 108 | for { 109 | prevSize := w.size 110 | 111 | var ( 112 | evt fsnotify.Event 113 | ok bool 114 | ) 115 | select { 116 | case evt, ok = <-events: 117 | if !ok { 118 | return nil 119 | } 120 | case <-ctx.Done(): 121 | return ctx.Err() 122 | } 123 | 124 | switch { 125 | case evt.Op&fsnotify.Remove == fsnotify.Remove: 126 | fallthrough 127 | 128 | case evt.Op&fsnotify.Rename == fsnotify.Rename: 129 | return fn(ctx, evDeleted) 130 | 131 | // With an open fd, unlink(fd) - newWatcher returns IN_ATTRIB (==fsnotify.Chmod) 132 | case evt.Op&fsnotify.Chmod == fsnotify.Chmod: 133 | fallthrough 134 | 135 | case evt.Op&fsnotify.Write == fsnotify.Write: 136 | fi, err := os.Stat(w.name) 137 | if err != nil { 138 | if os.IsNotExist(err) { 139 | return fn(ctx, evDeleted) 140 | } 141 | return errors.Wrap(err, "stat") 142 | } 143 | w.size = fi.Size() 144 | if prevSize > 0 && prevSize > w.size { 145 | return fn(ctx, evTruncated) 146 | } 147 | return fn(ctx, evModified) 148 | } 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /tracker.go: -------------------------------------------------------------------------------- 1 | package tail 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "sync" 7 | "syscall" 8 | 9 | "github.com/fsnotify/fsnotify" 10 | "github.com/go-faster/errors" 11 | "go.uber.org/zap" 12 | ) 13 | 14 | // tracker multiplexes fsnotify events. 15 | type tracker struct { 16 | init sync.Once 17 | mux sync.Mutex 18 | watcher *fsnotify.Watcher 19 | chans map[string]chan fsnotify.Event 20 | done map[string]chan bool 21 | watchNums map[string]int 22 | watch chan *watchInfo 23 | remove chan *watchInfo 24 | error chan error 25 | log *zap.Logger 26 | } 27 | 28 | type watchInfo struct { 29 | op fsnotify.Op 30 | name string 31 | } 32 | 33 | func (i *watchInfo) isCreate() bool { 34 | return i.op == fsnotify.Create 35 | } 36 | 37 | // NewTracker creates new custom Tracker with provided logger. 38 | // 39 | // It is recommended to use it as singleton and create only once. 40 | func NewTracker(log *zap.Logger) Tracker { 41 | return &tracker{ 42 | chans: make(map[string]chan fsnotify.Event), 43 | done: make(map[string]chan bool), 44 | watchNums: make(map[string]int), 45 | watch: make(chan *watchInfo), 46 | remove: make(chan *watchInfo), 47 | error: make(chan error), 48 | log: log, 49 | } 50 | } 51 | 52 | var defaultTracker = NewTracker(zap.NewNop()) 53 | 54 | // watchFile signals the run goroutine to begin watching the input filename 55 | func (t *tracker) watchFile(name string) error { 56 | return t.watchInfo(&watchInfo{ 57 | name: name, 58 | }) 59 | } 60 | 61 | // watchCreate watches create signals the run goroutine to begin watching the input filename 62 | // if call the watchCreate function, don't call the Cleanup, call the removeWatchCreate 63 | func (t *tracker) watchCreate(name string) error { 64 | return t.watchInfo(&watchInfo{ 65 | op: fsnotify.Create, 66 | name: name, 67 | }) 68 | } 69 | 70 | func (t *tracker) watchInfo(winfo *watchInfo) error { 71 | if err := t.ensure(); err != nil { 72 | return err 73 | } 74 | 75 | winfo.name = filepath.Clean(winfo.name) 76 | t.watch <- winfo 77 | return <-t.error 78 | } 79 | 80 | // removeWatchInfo signals the run goroutine to remove the watch for the input filename 81 | func (t *tracker) removeWatchName(name string) error { 82 | return t.removeInfo(&watchInfo{ 83 | name: name, 84 | }) 85 | } 86 | 87 | // removeWatchCreate signals the run goroutine to remove the 88 | // watch for the input filename. 89 | func (t *tracker) removeWatchCreate(name string) error { 90 | return t.removeInfo(&watchInfo{ 91 | op: fsnotify.Create, 92 | name: name, 93 | }) 94 | } 95 | 96 | func (t *tracker) ensure() (err error) { 97 | if t == nil { 98 | return errors.New("tracker: invalid call (nil)") 99 | } 100 | 101 | t.init.Do(func() { 102 | w, wErr := fsnotify.NewWatcher() 103 | if wErr != nil { 104 | err = wErr 105 | return 106 | } 107 | 108 | t.watcher = w 109 | go t.run() 110 | }) 111 | return err 112 | } 113 | 114 | func (t *tracker) removeInfo(winfo *watchInfo) error { 115 | if err := t.ensure(); err != nil { 116 | return err 117 | } 118 | 119 | winfo.name = filepath.Clean(winfo.name) 120 | t.mux.Lock() 121 | done := t.done[winfo.name] 122 | if done != nil { 123 | delete(t.done, winfo.name) 124 | close(done) 125 | } 126 | t.mux.Unlock() 127 | 128 | t.remove <- winfo 129 | return <-t.error 130 | } 131 | 132 | // listenEvents returns a channel to which FileEvents corresponding to the input filename 133 | // will be sent. This channel will be closed when removeWatchInfo is called on this 134 | // filename. 135 | func (t *tracker) listenEvents(name string) <-chan fsnotify.Event { 136 | t.mux.Lock() 137 | defer t.mux.Unlock() 138 | 139 | return t.chans[name] 140 | } 141 | 142 | // watchFlags calls fsnotify.WatchFlags for the input filename and flags, creating 143 | // a new watcher if the previous watcher was closed. 144 | func (t *tracker) addWatchInfo(winfo *watchInfo) error { 145 | t.mux.Lock() 146 | defer t.mux.Unlock() 147 | 148 | if t.chans[winfo.name] == nil { 149 | t.chans[winfo.name] = make(chan fsnotify.Event) 150 | } 151 | if t.done[winfo.name] == nil { 152 | t.done[winfo.name] = make(chan bool) 153 | } 154 | 155 | name := winfo.name 156 | if winfo.isCreate() { 157 | // watchFile for new files to be created in the parent directory. 158 | name = filepath.Dir(name) 159 | } 160 | 161 | if t.watchNums[name] > 0 { 162 | // Already watching. 163 | return nil 164 | } 165 | if err := t.watcher.Add(name); err != nil { 166 | return errors.Wrap(err, "add") 167 | } 168 | 169 | t.watchNums[name]++ 170 | return nil 171 | } 172 | 173 | // removeWatchInfo calls fsnotify.Remove for the input filename and closes the 174 | // corresponding events channel. 175 | func (t *tracker) removeWatchInfo(winfo *watchInfo) error { 176 | t.mux.Lock() 177 | 178 | ch := t.chans[winfo.name] 179 | if ch != nil { 180 | delete(t.chans, winfo.name) 181 | close(ch) 182 | } 183 | 184 | name := winfo.name 185 | if winfo.isCreate() { 186 | // watchFile for new files to be created in the parent directory. 187 | name = filepath.Dir(name) 188 | } 189 | t.watchNums[name]-- 190 | watchNum := t.watchNums[name] 191 | if watchNum == 0 { 192 | delete(t.watchNums, name) 193 | } 194 | t.mux.Unlock() 195 | 196 | var err error 197 | // If we were the last ones to watch this file, unsubscribe from newWatcher. 198 | // This needs to happen after releasing the lock because fsnotify waits 199 | // synchronously for the kernel to acknowledge the removal of the watch 200 | // for this file, which causes us to deadlock if we still held the lock. 201 | if watchNum == 0 { 202 | err = t.watcher.Remove(name) 203 | } 204 | 205 | return err 206 | } 207 | 208 | // sendEvent sends the input event to the appropriate Tail. 209 | func (t *tracker) sendEvent(event fsnotify.Event) { 210 | name := filepath.Clean(event.Name) 211 | 212 | t.mux.Lock() 213 | ch := t.chans[name] 214 | done := t.done[name] 215 | t.mux.Unlock() 216 | 217 | if ch != nil && done != nil { 218 | select { 219 | case ch <- event: 220 | case <-done: 221 | } 222 | } 223 | } 224 | 225 | // run starts reading from inotify events. 226 | func (t *tracker) run() { 227 | for { 228 | select { 229 | case winfo := <-t.watch: 230 | t.error <- t.addWatchInfo(winfo) 231 | 232 | case winfo := <-t.remove: 233 | t.error <- t.removeWatchInfo(winfo) 234 | 235 | case event, ok := <-t.watcher.Events: 236 | if !ok { 237 | return 238 | } 239 | t.sendEvent(event) 240 | 241 | case err, ok := <-t.watcher.Errors: 242 | if !ok { 243 | return 244 | } 245 | if err != nil { 246 | sysErr, ok := err.(*os.SyscallError) 247 | if !ok || sysErr.Err != syscall.EINTR { 248 | t.log.Error("Watcher error", zap.Error(err)) 249 | } 250 | } 251 | } 252 | } 253 | } 254 | -------------------------------------------------------------------------------- /tail_test.go: -------------------------------------------------------------------------------- 1 | package tail 2 | 3 | import ( 4 | "context" 5 | "crypto/rand" 6 | "fmt" 7 | "io" 8 | "math/big" 9 | mathRand "math/rand" 10 | "os" 11 | "testing" 12 | "time" 13 | 14 | "github.com/go-faster/errors" 15 | "github.com/stretchr/testify/assert" 16 | "github.com/stretchr/testify/require" 17 | "go.uber.org/zap/zaptest" 18 | "golang.org/x/sync/errgroup" 19 | ) 20 | 21 | const ( 22 | lines = 1024 23 | notifyTimeout = time.Millisecond * 500 24 | timeout = time.Second * 5 25 | line = `[foo.go:1261] INFO: Some test log entry {"user_id": 410}` 26 | ) 27 | 28 | func file(t testing.TB) *os.File { 29 | t.Helper() 30 | 31 | f, err := os.CreateTemp(t.TempDir(), "*.txt") 32 | require.NoError(t, err) 33 | t.Cleanup(func() { 34 | _ = f.Close() 35 | }) 36 | 37 | return f 38 | } 39 | 40 | func TestTail_Run(t *testing.T) { 41 | t.Run("Follow", func(t *testing.T) { 42 | f := file(t) 43 | g, ctx := errgroup.WithContext(context.Background()) 44 | 45 | var gotLines int 46 | h := func(ctx context.Context, l *Line) error { 47 | assert.Equal(t, line, string(l.Data)) 48 | gotLines++ 49 | t.Log("Got line", gotLines) 50 | 51 | if gotLines == lines { 52 | return errStop 53 | } 54 | 55 | return nil 56 | } 57 | 58 | g.Go(func() error { 59 | if err := File(f.Name(), Config{ 60 | Follow: true, 61 | Logger: zaptest.NewLogger(t), 62 | NotifyTimeout: notifyTimeout, 63 | }).Tail(ctx, h); !errors.Is(err, errStop) { 64 | return errors.Wrap(err, "run") 65 | } 66 | 67 | return nil 68 | }) 69 | g.Go(func() error { 70 | t.Log("Writing") 71 | 72 | for i := 0; i < lines; i++ { 73 | if _, err := fmt.Fprintln(f, line); err != nil { 74 | return errors.Wrap(err, "write") 75 | } 76 | if i%(lines/5) == 0 { 77 | if err := f.Sync(); err != nil { 78 | return errors.Wrap(err, "sync") 79 | } 80 | } 81 | } 82 | if err := f.Sync(); err != nil { 83 | return errors.Wrap(err, "sync") 84 | } 85 | if err := f.Close(); err != nil { 86 | return errors.Wrap(err, "close") 87 | } 88 | t.Log("Wrote") 89 | return nil 90 | }) 91 | require.NoError(t, g.Wait()) 92 | require.Equal(t, lines, gotLines) 93 | }) 94 | t.Run("NoFollow", func(t *testing.T) { 95 | f := file(t) 96 | for i := 0; i < lines; i++ { 97 | _, err := fmt.Fprintln(f, line) 98 | require.NoError(t, err) 99 | } 100 | require.NoError(t, f.Close()) 101 | 102 | // Perform full file read. 103 | ctx := context.Background() 104 | var gotLines int 105 | h := func(ctx context.Context, l *Line) error { 106 | assert.Equal(t, line, string(l.Data)) 107 | gotLines++ 108 | return nil 109 | } 110 | 111 | // Verify result. 112 | require.NoError(t, File(f.Name(), Config{}).Tail(ctx, h)) 113 | require.Equal(t, lines, gotLines) 114 | }) 115 | t.Run("Position", func(t *testing.T) { 116 | f := file(t) 117 | g, ctx := errgroup.WithContext(context.Background()) 118 | 119 | var ( 120 | gotLines int 121 | offset int64 122 | ) 123 | h := func(ctx context.Context, l *Line) error { 124 | assert.Equal(t, line, string(l.Data)) 125 | gotLines++ 126 | offset = l.Offset 127 | if gotLines == lines { 128 | return errStop 129 | } 130 | return nil 131 | } 132 | 133 | g.Go(func() error { 134 | if err := File(f.Name(), Config{ 135 | Follow: true, 136 | Logger: zaptest.NewLogger(t), 137 | NotifyTimeout: notifyTimeout, 138 | }).Tail(ctx, h); !errors.Is(err, errStop) { 139 | return errors.Wrap(err, "run") 140 | } 141 | return nil 142 | }) 143 | writeLines := func() error { 144 | for i := 0; i < lines; i++ { 145 | if _, err := fmt.Fprintln(f, line); err != nil { 146 | return errors.Wrap(err, "write") 147 | } 148 | } 149 | if err := f.Sync(); err != nil { 150 | return errors.Wrap(err, "sync") 151 | } 152 | return nil 153 | } 154 | g.Go(writeLines) 155 | require.NoError(t, g.Wait()) 156 | require.Equal(t, lines, gotLines) 157 | 158 | require.NoError(t, writeLines()) 159 | 160 | gotLines = 0 161 | require.ErrorIs(t, File(f.Name(), Config{ 162 | Logger: zaptest.NewLogger(t), 163 | Location: &Location{Offset: offset}, 164 | }).Tail(context.Background(), h), errStop) 165 | }) 166 | } 167 | 168 | func TestMultipleTails(t *testing.T) { 169 | f := file(t) 170 | 171 | lg := zaptest.NewLogger(t) 172 | tr := NewTracker(lg) 173 | g, ctx := errgroup.WithContext(context.Background()) 174 | 175 | const ( 176 | tailers = 3 177 | lines = 10 178 | ) 179 | 180 | // Prepare multiple tailers and start them. 181 | for i := 0; i < tailers; i++ { 182 | tailer := File(f.Name(), Config{ 183 | NotifyTimeout: notifyTimeout, 184 | Follow: true, 185 | Logger: lg.Named(fmt.Sprintf("t%d", i)), 186 | Tracker: tr, 187 | }) 188 | g.Go(func() error { 189 | var gotLines int 190 | // Ensure that each tailer got all lines. 191 | h := func(ctx context.Context, l *Line) error { 192 | assert.Equal(t, line, string(l.Data)) 193 | gotLines++ 194 | if gotLines == lines { 195 | return errStop 196 | } 197 | return nil 198 | } 199 | 200 | ctx, cancel := context.WithTimeout(ctx, timeout) 201 | defer cancel() 202 | 203 | if err := tailer.Tail(ctx, h); !errors.Is(err, errStop) { 204 | return err 205 | } 206 | 207 | return nil 208 | }) 209 | } 210 | // Write lines. 211 | g.Go(func() error { 212 | for i := 0; i < lines; i++ { 213 | if _, err := fmt.Fprintln(f, line); err != nil { 214 | return err 215 | } 216 | } 217 | return f.Close() 218 | }) 219 | 220 | require.NoError(t, g.Wait()) 221 | } 222 | 223 | func TestDelete(t *testing.T) { 224 | f := file(t) 225 | 226 | lg := zaptest.NewLogger(t) 227 | tr := NewTracker(lg) 228 | g, ctx := errgroup.WithContext(context.Background()) 229 | 230 | const lines = 10 231 | 232 | for i := 0; i < lines; i++ { 233 | if _, err := fmt.Fprintln(f, line); err != nil { 234 | t.Fatal(err) 235 | } 236 | } 237 | require.NoError(t, f.Close()) 238 | 239 | tailer := File(f.Name(), Config{ 240 | NotifyTimeout: notifyTimeout, 241 | Follow: true, 242 | Logger: lg, 243 | Tracker: tr, 244 | }) 245 | 246 | read := make(chan struct{}) 247 | g.Go(func() error { 248 | var gotLines int 249 | // Ensure that each tailer got all lines. 250 | h := func(ctx context.Context, l *Line) error { 251 | assert.Equal(t, line, string(l.Data)) 252 | gotLines++ 253 | if gotLines == lines { 254 | close(read) 255 | } 256 | return nil 257 | } 258 | 259 | ctx, cancel := context.WithTimeout(ctx, timeout) 260 | defer cancel() 261 | 262 | if err := tailer.Tail(ctx, h); !errors.Is(err, errStop) { 263 | return err 264 | } 265 | 266 | return nil 267 | }) 268 | // Write lines. 269 | g.Go(func() error { 270 | select { 271 | case <-ctx.Done(): 272 | return ctx.Err() 273 | case <-read: // ok 274 | } 275 | return os.Remove(f.Name()) 276 | }) 277 | 278 | require.NoError(t, g.Wait()) 279 | } 280 | 281 | func randString(reader io.Reader, n int) (string, error) { 282 | const letters = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" 283 | ret := make([]byte, n) 284 | for i := 0; i < n; i++ { 285 | num, err := rand.Int(reader, big.NewInt(int64(len(letters)))) 286 | if err != nil { 287 | return "", err 288 | } 289 | ret[i] = letters[num.Int64()] 290 | } 291 | 292 | return string(ret), nil 293 | } 294 | 295 | func BenchmarkTailer_Tail(b *testing.B) { 296 | const lines = 1024 * 50 297 | 298 | for _, lineLen := range []int{ 299 | 32, 300 | 128, 301 | 512, 302 | 1024, 303 | 1024 * 4, 304 | } { 305 | b.Run(fmt.Sprintf("%d", lineLen), func(b *testing.B) { 306 | f := file(b) 307 | 308 | s := mathRand.NewSource(1) 309 | r := mathRand.New(s) 310 | randLine, err := randString(r, lineLen) 311 | require.NoError(b, err) 312 | 313 | var totalBytes int64 314 | for i := 0; i < lines; i++ { 315 | n, err := fmt.Fprintln(f, randLine) 316 | require.NoError(b, err) 317 | totalBytes += int64(n) 318 | } 319 | require.NoError(b, f.Close()) 320 | 321 | ctx := context.Background() 322 | h := func(ctx context.Context, l *Line) error { 323 | return nil 324 | } 325 | 326 | b.ReportAllocs() 327 | b.SetBytes(totalBytes) 328 | b.ResetTimer() 329 | 330 | for i := 0; i < b.N; i++ { 331 | t := File(f.Name(), Config{ 332 | Follow: false, 333 | }) 334 | if err := t.Tail(ctx, h); err != nil { 335 | b.Fatal(err) 336 | } 337 | } 338 | }) 339 | } 340 | } 341 | -------------------------------------------------------------------------------- /tail.go: -------------------------------------------------------------------------------- 1 | // Package tail implements file tailing with fsnotify. 2 | package tail 3 | 4 | import ( 5 | "bufio" 6 | "context" 7 | "io" 8 | "os" 9 | "syscall" 10 | "time" 11 | 12 | "github.com/fsnotify/fsnotify" 13 | "github.com/go-faster/errors" 14 | "go.uber.org/atomic" 15 | "go.uber.org/zap" 16 | "go.uber.org/zap/zapcore" 17 | ) 18 | 19 | // errStop is returned when the tail of a file has been marked to be stopped. 20 | var errStop = errors.New("tail should now stop") 21 | 22 | // Line of file. 23 | type Line struct { 24 | Data []byte // do not retain, reused while reading file 25 | Offset int64 // is always the offset from start 26 | } 27 | 28 | // isBlank reports whether line is blank. 29 | func (l *Line) isBlank() bool { 30 | if l == nil { 31 | return true 32 | } 33 | return len(l.Data) == 0 34 | } 35 | 36 | func (l *Line) final() bool { 37 | if l.isBlank() { 38 | return false 39 | } 40 | return l.Data[len(l.Data)-1] == '\n' 41 | } 42 | 43 | // Location returns corresponding Location for Offset. 44 | // 45 | // Mostly convenience helper for using as Config.Location. 46 | func (l *Line) Location() Location { 47 | if l == nil { 48 | return Location{} 49 | } 50 | return Location{ 51 | Offset: l.Offset, 52 | Whence: io.SeekStart, 53 | } 54 | } 55 | 56 | // Location represents arguments to io.Seek. 57 | // 58 | // See https://golang.org/pkg/io/#SectionReader.Seek 59 | type Location struct { 60 | Offset int64 61 | Whence int 62 | } 63 | 64 | // Config is used to specify how a file must be tailed. 65 | type Config struct { 66 | // Location sets starting file location. 67 | Location *Location 68 | // NotifyTimeout enables additional timeout for file changes waiting. 69 | // Can be used to ensure that we never miss event even if newWatcher fails to 70 | // deliver event. 71 | // Optional. 72 | NotifyTimeout time.Duration 73 | // Follow file after reaching io.EOF, waiting for new lines. 74 | Follow bool 75 | // Initial internal buffer size, optional. 76 | BufferSize int 77 | // Logger to use, optional. 78 | Logger *zap.Logger 79 | // Tracker is optional custom *Tracker. 80 | Tracker Tracker 81 | } 82 | 83 | // Handler is called on each log line. 84 | // 85 | // Implementation should not retain Line or Line.Data. 86 | type Handler func(ctx context.Context, l *Line) error 87 | 88 | // Tracker tracks file changes. 89 | type Tracker interface { 90 | watchFile(name string) error 91 | watchCreate(name string) error 92 | removeWatchName(name string) error 93 | removeWatchCreate(name string) error 94 | listenEvents(name string) <-chan fsnotify.Event 95 | } 96 | 97 | // Tailer implements file tailing. 98 | // 99 | // Use Tail() to start. 100 | type Tailer struct { 101 | cfg Config 102 | name string 103 | file *os.File 104 | reader *bufio.Reader 105 | proxy *offsetProxy 106 | watcher *watcher 107 | lg *zap.Logger 108 | } 109 | 110 | const ( 111 | minBufSize = 128 // 128 bytes 112 | defaultBufSize = 1024 * 50 // 50kb 113 | ) 114 | 115 | // File configures and creates new unstarted *Tailer. 116 | // 117 | // Use Tailer.Tail() to start tailing file. 118 | func File(filename string, cfg Config) *Tailer { 119 | if cfg.Logger == nil { 120 | cfg.Logger = zap.NewNop() 121 | } 122 | if cfg.BufferSize <= minBufSize { 123 | cfg.BufferSize = defaultBufSize 124 | } 125 | if cfg.Tracker == nil { 126 | cfg.Tracker = defaultTracker 127 | } 128 | 129 | return &Tailer{ 130 | cfg: cfg, 131 | name: filename, 132 | lg: cfg.Logger, 133 | watcher: newWatcher(cfg.Logger.Named("watch"), cfg.Tracker, filename), 134 | } 135 | } 136 | 137 | type offsetProxy struct { 138 | Reader io.Reader 139 | Offset int64 140 | } 141 | 142 | func (o *offsetProxy) Read(p []byte) (n int, err error) { 143 | n, err = o.Reader.Read(p) 144 | o.Offset += int64(n) 145 | return n, err 146 | } 147 | 148 | // offset returns the file's current offset. 149 | func (t *Tailer) offset() int64 { 150 | return t.proxy.Offset - int64(t.reader.Buffered()) 151 | } 152 | 153 | func (t *Tailer) closeFile() { 154 | if t.file == nil { 155 | return 156 | } 157 | 158 | _ = t.file.Close() 159 | t.file = nil 160 | } 161 | 162 | func (t *Tailer) openFile(ctx context.Context, loc Location) error { 163 | t.closeFile() 164 | for { 165 | var err error 166 | if t.file, err = os.Open(t.name); err != nil { 167 | if os.IsNotExist(err) { 168 | if e := t.lg.Check(zapcore.DebugLevel, "File does not exists"); e != nil { 169 | e.Write( 170 | zap.Error(err), 171 | zap.String("tail.file", t.name), 172 | ) 173 | } 174 | if err := t.watcher.WaitExists(ctx); err != nil { 175 | return errors.Wrap(err, "wait exists") 176 | } 177 | 178 | continue 179 | } 180 | return errors.Wrap(err, "open") 181 | } 182 | offset, err := t.file.Seek(loc.Offset, loc.Whence) 183 | if err != nil { 184 | return errors.Wrap(err, "seek") 185 | } 186 | t.proxy = &offsetProxy{ 187 | Reader: t.file, 188 | Offset: offset, 189 | } 190 | return nil 191 | } 192 | } 193 | 194 | func (t *Tailer) readLine(buf []byte) ([]byte, error) { 195 | for { 196 | line, isPrefix, err := t.reader.ReadLine() 197 | buf = append(buf, line...) 198 | if isPrefix { 199 | continue 200 | } 201 | if err != nil { 202 | return nil, err 203 | } 204 | return buf, nil 205 | } 206 | } 207 | 208 | // Tail opens file and starts tailing it, reporting observed lines to Handler. 209 | // 210 | // Tail is blocking while calling Handler to reuse internal buffer and 211 | // reduce allocations. 212 | // Tail will call Handler in same sequence as lines are observed. 213 | // See Handler for more info. 214 | // 215 | // Can be called multiple times, but not concurrently. 216 | func (t *Tailer) Tail(ctx context.Context, h Handler) error { 217 | if t == nil { 218 | return errors.New("incorrect Tailer call: Tailer is nil") 219 | } 220 | 221 | defer t.closeFile() 222 | { 223 | loc := Location{ 224 | Offset: 0, 225 | Whence: io.SeekCurrent, 226 | } 227 | if t.cfg.Location != nil { 228 | loc = *t.cfg.Location 229 | } 230 | 231 | if err := t.openFile(ctx, loc); err != nil { 232 | return errors.Wrap(err, "openFile") 233 | } 234 | } 235 | 236 | if loc := t.cfg.Location; loc != nil { 237 | // Seek requested. 238 | if _, err := t.file.Seek(loc.Offset, loc.Whence); err != nil { 239 | return errors.Wrap(err, "seek") 240 | } 241 | } 242 | 243 | t.resetReader() 244 | t.lg.Debug("Opened") 245 | defer t.lg.Debug("Done") 246 | 247 | // Reading line-by-line. 248 | line := &Line{ 249 | // Pre-allocate some buffer. 250 | // TODO(ernado): Limit buffer growth to prevent OOM 251 | Data: make([]byte, 0, t.cfg.BufferSize), 252 | } 253 | 254 | // Reduce lock contention. 255 | var done atomic.Bool 256 | go func() { 257 | <-ctx.Done() 258 | done.Store(true) 259 | }() 260 | 261 | debugEnabled := t.lg.Core().Enabled(zapcore.DebugLevel) 262 | for { 263 | if done.Load() { 264 | return ctx.Err() 265 | } 266 | 267 | // Grab the offset in case we need to back up in the event of a half-line. 268 | offset := t.offset() 269 | line.Offset = offset 270 | var readErr error 271 | if debugEnabled { 272 | t.lg.Debug("Reading line", zap.Int64("offset", offset)) 273 | } 274 | 275 | line.Data, readErr = t.readLine(line.Data) 276 | 277 | switch readErr { 278 | case io.EOF: 279 | if debugEnabled { 280 | t.lg.Debug("Got EOF") 281 | } 282 | if line.final() { 283 | // Reporting only final lines, i.e. those ending with newline. 284 | // Line can become final later. 285 | if err := h(ctx, line); err != nil { 286 | return errors.Wrap(err, "handle") 287 | } 288 | line.Data = line.Data[:0] // reset buffer 289 | } 290 | if !t.cfg.Follow { 291 | // End of file reached, but not following. 292 | // Stopping. 293 | if !line.isBlank() && !line.final() { 294 | // Reporting non-final line because we are not following 295 | // and there are no chances for it to become final. 296 | if err := h(ctx, line); err != nil { 297 | return errors.Wrap(err, "handle") 298 | } 299 | } 300 | return nil 301 | } 302 | if debugEnabled { 303 | t.lg.Debug("Waiting for changes") 304 | } 305 | if err := t.waitForChanges(ctx, offset); err != nil { 306 | if errors.Is(err, errStop) { 307 | return nil 308 | } 309 | if errors.Is(err, context.DeadlineExceeded) { 310 | continue 311 | } 312 | return errors.Wrap(err, "wait") 313 | } 314 | case nil: 315 | if err := h(ctx, line); err != nil { 316 | return errors.Wrap(err, "handle") 317 | } 318 | line.Data = line.Data[:0] // reset buffer 319 | default: 320 | return errors.Wrap(readErr, "read") 321 | } 322 | } 323 | } 324 | 325 | // waitForChanges waits until the file has been appended, deleted, 326 | // moved or truncated. 327 | // 328 | // evTruncated files are always reopened. 329 | func (t *Tailer) waitForChanges(ctx context.Context, pos int64) error { 330 | if t.cfg.NotifyTimeout != 0 { 331 | // Additional safeguard to ensure that we don't hang forever. 332 | var cancel context.CancelFunc 333 | ctx, cancel = context.WithTimeout(ctx, t.cfg.NotifyTimeout) 334 | defer cancel() 335 | } 336 | 337 | if err := t.watcher.WatchEvents(ctx, pos, func(ctx context.Context, e event) error { 338 | switch e { 339 | case evModified: 340 | t.lg.Debug("Modified") 341 | return nil 342 | case evDeleted: 343 | t.lg.Debug("Stopping: deleted") 344 | return errStop 345 | case evTruncated: 346 | t.lg.Info("Re-opening truncated file") 347 | if err := t.openFile(ctx, Location{ 348 | Offset: 0, 349 | Whence: io.SeekStart, 350 | }); err != nil { 351 | return errors.Wrap(err, "open file") 352 | } 353 | t.resetReader() 354 | return nil 355 | default: 356 | return errors.Errorf("invalid event %v", e) 357 | } 358 | }); err != nil { 359 | if os.IsNotExist(err) || errors.Is(err, syscall.ENOENT) { 360 | return errStop 361 | } 362 | return errors.Wrap(err, "watch") 363 | } 364 | 365 | return nil 366 | } 367 | 368 | func (t *Tailer) resetReader() { 369 | t.reader = bufio.NewReaderSize(t.proxy, t.cfg.BufferSize) 370 | } 371 | --------------------------------------------------------------------------------