github.com/benz9527/xboot@v0.0.0-20240504061247-c23f15593274/xlog/rotate_log.go (about)

     1  package xlog
     2  
     3  import (
     4  	// "archive/zip"
     5  	"context"
     6  	"fmt"
     7  	"io"
     8  	"io/fs"
     9  	"os"
    10  	"path/filepath"
    11  	"regexp"
    12  	"sort"
    13  	"strconv"
    14  	"strings"
    15  	"sync"
    16  	"sync/atomic"
    17  	"time"
    18  
    19  	"github.com/fsnotify/fsnotify"
    20  	"github.com/google/safearchive/zip"
    21  	"github.com/google/safeopen"
    22  	"go.uber.org/multierr"
    23  
    24  	"github.com/benz9527/xboot/lib/infra"
    25  )
    26  
    27  type fileSizeUnit uint64
    28  
    29  const (
    30  	B fileSizeUnit = 1 << (10 * iota)
    31  	KB
    32  	MB
    33  	_maxSize = 1024 * MB
    34  )
    35  
    36  type fileAgeUnit int64
    37  
    38  const (
    39  	backupDateTimeFormat             = "2006_01_02T15_04_05.999999999_Z07_00"
    40  	Second               fileAgeUnit = fileAgeUnit(time.Duration(1 * time.Second))
    41  	Minute               fileAgeUnit = fileAgeUnit(time.Duration(1 * time.Minute))
    42  	Hour                 fileAgeUnit = fileAgeUnit(time.Duration(1 * time.Hour))
    43  	Day                  fileAgeUnit = fileAgeUnit(time.Duration(1 * time.Hour * 24))
    44  	_maxFileAge                      = 2 * 7 * Day
    45  )
    46  
    47  var (
    48  	fileSizeRegexp = regexp.MustCompile(`^(\d+)(([kK]|[mM])?[bB])$`)
    49  	fileAgeRegexp  = regexp.MustCompile(`^(\d+)(s|[sS]ec|[mM]in|[hH](our[s]?)?|[dD](ay[s]?)?)$`)
    50  )
    51  
    52  func parseFileSize(size string) (uint64, error) {
    53  	res := fileSizeRegexp.FindAllStringSubmatch(size, -1)
    54  	if res == nil || len(res) <= 0 || len(res[0]) < 3 || res[0][0] != size {
    55  		return 0, infra.NewErrorStack("invalid file size unit")
    56  	}
    57  	var unit fileSizeUnit
    58  	switch strings.ToUpper(res[0][2]) {
    59  	case "B":
    60  		unit = B
    61  	case "KB":
    62  		unit = KB
    63  	case "MB":
    64  		unit = MB
    65  	}
    66  	_size, _ := strconv.ParseUint(res[0][1], 10, 64)
    67  	return _size * uint64(unit), nil
    68  }
    69  
    70  func parseFileAge(age string) (time.Duration, error) {
    71  	res := fileAgeRegexp.FindAllStringSubmatch(age, -1)
    72  	if res == nil || len(res) <= 0 || len(res[0]) < 3 || res[0][0] != age {
    73  		return 0, infra.NewErrorStack("invalid file age unit")
    74  	}
    75  	var unit fileAgeUnit
    76  	switch strings.ToUpper(res[0][2]) {
    77  	case "S", "SEC":
    78  		unit = Second
    79  	case "M", "MIN":
    80  		unit = Minute
    81  	case "H", "HOUR", "HOURS":
    82  		unit = Hour
    83  	case "D", "DAY", "DAYS":
    84  		unit = Day
    85  	}
    86  	num, _ := strconv.ParseInt(res[0][1], 10, 64)
    87  	_age := time.Duration(num) * time.Duration(unit)
    88  	if _age >= time.Duration(_maxFileAge) {
    89  		_age = time.Duration(_maxFileAge)
    90  	}
    91  	return _age, nil
    92  }
    93  
    94  var _ io.WriteCloser = (*rotateLog)(nil)
    95  
    96  type rotateLog struct {
    97  	ctx               context.Context
    98  	filePath          string
    99  	filename          string
   100  	fileMaxSize       string
   101  	fileMaxAge        string
   102  	fileZipName       string
   103  	maxSize           uint64
   104  	wroteSize         uint64
   105  	mkdirOnce         sync.Once
   106  	currentFile       atomic.Pointer[os.File]
   107  	fileWatcher       atomic.Pointer[fsnotify.Watcher]
   108  	fileMaxBackups    int
   109  	fileCompressBatch int
   110  	fileCompressible  bool
   111  }
   112  
   113  func (log *rotateLog) Write(p []byte) (n int, err error) {
   114  	select {
   115  	case <-log.ctx.Done():
   116  		return 0, io.EOF
   117  	default:
   118  	}
   119  
   120  	if log.currentFile.Load() == nil {
   121  		if err := log.openOrCreate(); err != nil {
   122  			return 0, err
   123  		}
   124  	}
   125  	logLen := uint64(len(p))
   126  	if log.wroteSize+logLen > log.maxSize {
   127  		n, err = log.currentFile.Load().Write(p)
   128  		if err != nil {
   129  			return
   130  		}
   131  		if err = log.backupThenCreate(); err != nil {
   132  			return
   133  		}
   134  		return
   135  	}
   136  
   137  	n, err = log.currentFile.Load().Write(p)
   138  	log.wroteSize += uint64(n)
   139  	return
   140  }
   141  
   142  func (log *rotateLog) Close() error {
   143  	if log.currentFile.Load() == nil {
   144  		return nil
   145  	}
   146  	if err := log.currentFile.Load().Close(); err != nil {
   147  		return err
   148  	}
   149  	log.currentFile.Store(nil)
   150  	return nil
   151  }
   152  
   153  func (log *rotateLog) initialize() error {
   154  	if log.fileWatcher.Load() != nil {
   155  		return nil
   156  	}
   157  
   158  	size, err := parseFileSize(log.fileMaxSize)
   159  	if err != nil {
   160  		handleRollingError(err)
   161  		return err
   162  	}
   163  	log.maxSize = size
   164  
   165  	if _, err = parseFileAge(log.fileMaxAge); err != nil {
   166  		handleRollingError(err)
   167  		return err
   168  	}
   169  
   170  	var watcher *fsnotify.Watcher
   171  	if watcher, err = fsnotify.NewWatcher(); err != nil {
   172  		handleRollingError(infra.WrapErrorStackWithMessage(err, "failed to create file watcher"))
   173  		return err
   174  	}
   175  	log.fileWatcher.Store(watcher)
   176  
   177  	if err = log.fileWatcher.Load().Add(log.filePath); err != nil {
   178  		handleRollingError(infra.WrapErrorStackWithMessage(err, "failed to add log directory to watcher"))
   179  		return err
   180  	}
   181  
   182  	go log.watchAndArchive()
   183  	return nil
   184  }
   185  
   186  func (log *rotateLog) mkdir() error {
   187  	var err error = nil
   188  	log.mkdirOnce.Do(func() {
   189  		if log.filePath == "" {
   190  			log.filePath = os.TempDir()
   191  		}
   192  		if log.filePath == os.TempDir() {
   193  			return
   194  		}
   195  		err = os.MkdirAll(log.filePath, 0o644)
   196  	})
   197  	return infra.WrapErrorStack(err)
   198  }
   199  
   200  func (log *rotateLog) backup() error {
   201  	logName := log.filename
   202  	ext := filepath.Ext(logName)
   203  	logNamePrefix := strings.TrimSuffix(logName, ext)
   204  	now := time.Now().UTC()
   205  	ts := now.Format(backupDateTimeFormat)
   206  	pathToBackup := filepath.Join(log.filePath, logNamePrefix+"_"+ts+ext)
   207  	if err := log.currentFile.Load().Close(); err != nil {
   208  		return infra.WrapErrorStackWithMessage(err, "failed to backup current log: "+filepath.Join(log.filePath, logName))
   209  	}
   210  	return os.Rename(filepath.Join(log.filePath, logName), pathToBackup)
   211  }
   212  
   213  func (log *rotateLog) create() error {
   214  	if err := log.mkdir(); err != nil {
   215  		return err
   216  	}
   217  
   218  	f, err := safeopen.OpenFileBeneath(log.filePath, log.filename, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0o644)
   219  	if err != nil {
   220  		return infra.WrapErrorStackWithMessage(err, "unable to create new log file: "+filepath.Join(log.filePath, log.filename))
   221  	}
   222  	log.currentFile.Store(f)
   223  	log.wroteSize = 0
   224  	return nil
   225  }
   226  
   227  func (log *rotateLog) backupThenCreate() error {
   228  	if err := log.backup(); err != nil {
   229  		return err
   230  	}
   231  	return log.create()
   232  }
   233  
   234  func (log *rotateLog) openOrCreate() error {
   235  	if err := log.mkdir(); err != nil {
   236  		return err
   237  	}
   238  
   239  	pathToLog := filepath.Join(log.filePath, log.filename)
   240  	info, err := os.Stat(pathToLog)
   241  	if os.IsNotExist(err) {
   242  		var merr error
   243  		merr = multierr.Append(merr, err)
   244  		if err = log.create(); err != nil {
   245  			return multierr.Append(merr, err)
   246  		}
   247  		return log.initialize()
   248  	} else if err != nil {
   249  		log.currentFile.Store(nil)
   250  		return infra.WrapErrorStack(err)
   251  	}
   252  
   253  	if info.IsDir() {
   254  		log.currentFile.Store(nil)
   255  		return infra.NewErrorStack("log file <" + pathToLog + "> is a dir")
   256  	}
   257  
   258  	var f *os.File
   259  	if f, err = safeopen.OpenFileBeneath(log.filePath, log.filename, os.O_WRONLY|os.O_APPEND, 0o644); err != nil {
   260  		var merr error = infra.WrapErrorStackWithMessage(err, "unable to access log file: "+pathToLog)
   261  		if err = log.backupThenCreate(); err != nil {
   262  			return infra.WrapErrorStackWithMessage(multierr.Combine(merr, err), "failed to backup then open new log file: "+pathToLog)
   263  		}
   264  	}
   265  	log.currentFile.Store(f)
   266  	log.wroteSize = uint64(info.Size())
   267  	return log.initialize()
   268  }
   269  
   270  // Watch the log directory and filter the match log files then archive
   271  // or delete the expired. Endless until the rotate log is closed.
   272  func (log *rotateLog) watchAndArchive() {
   273  	ext := filepath.Ext(log.filename)
   274  	logName := log.filename[:len(log.filename)-len(ext)]
   275  	duration, _ := parseFileAge(log.fileMaxAge)
   276  	for {
   277  		select {
   278  		case <-log.ctx.Done():
   279  			_ = log.Close()
   280  			handleRollingError(log.fileWatcher.Load().Close())
   281  			log.fileWatcher.Store(nil)
   282  			return
   283  		case event, ok := <-log.fileWatcher.Load().Events:
   284  			if !ok {
   285  				return
   286  			}
   287  			if event.Has(fsnotify.Create) {
   288  				// Walk through the log files and find the expired ones.
   289  				logInfos, err := log.loadFileInfos(logName, ext)
   290  				if err != nil || len(logInfos) <= 0 {
   291  					handleRollingError(err)
   292  					continue
   293  				}
   294  				now := time.Now().UTC()
   295  				expired, rest := filterExpiredLogs(now, logName, ext, duration, logInfos)
   296  				expired = filterMaxBackupLogs(expired, rest, log.fileMaxBackups)
   297  				if log.fileCompressible {
   298  					if len(expired) < log.fileCompressBatch {
   299  						continue
   300  					}
   301  					if err := compressExpiredLogs(log.filePath, log.fileZipName, expired); err != nil {
   302  						handleRollingError(err)
   303  						continue
   304  					}
   305  				} else {
   306  					for _, info := range expired {
   307  						filename := filepath.Base(info.Name())
   308  						_ = os.Remove(filepath.Join(log.filePath, filename))
   309  					}
   310  				}
   311  			}
   312  		case err, ok := <-log.fileWatcher.Load().Errors:
   313  			if !ok {
   314  				return
   315  			}
   316  			handleRollingError(err)
   317  		}
   318  	}
   319  }
   320  
   321  func (log *rotateLog) loadFileInfos(logName, ext string) ([]fs.FileInfo, error) {
   322  	// Walk through the log files and find the expired ones.
   323  	entries, err := os.ReadDir(log.filePath)
   324  	if err == nil && len(entries) > 0 {
   325  		logInfos := make([]os.FileInfo, 0, 16)
   326  		for _, entry := range entries {
   327  			if !entry.IsDir() {
   328  				filename := entry.Name()
   329  				if strings.HasPrefix(filename, logName) && strings.HasSuffix(filename, ext) && filename != log.filename {
   330  					if info, err := entry.Info(); err == nil && info != nil {
   331  						logInfos = append(logInfos, info)
   332  					}
   333  				}
   334  			}
   335  		}
   336  		return logInfos, nil
   337  	}
   338  	return nil, infra.WrapErrorStack(err)
   339  }
   340  
   341  func RotateLog(ctx context.Context, cfg *FileCoreConfig) io.WriteCloser {
   342  	if cfg == nil || ctx == nil {
   343  		return nil
   344  	}
   345  	w := &rotateLog{
   346  		filename:          cfg.Filename,
   347  		filePath:          cfg.FilePath,
   348  		fileCompressible:  cfg.FileCompressible,
   349  		fileCompressBatch: cfg.FileCompressBatch,
   350  		fileMaxAge:        cfg.FileMaxAge,
   351  		fileZipName:       cfg.FileZipName,
   352  		fileMaxSize:       cfg.FileMaxSize,
   353  		fileMaxBackups:    cfg.FileMaxBackups,
   354  		ctx:               ctx,
   355  	}
   356  	if err := w.initialize(); err != nil {
   357  		return nil
   358  	}
   359  	return w
   360  }
   361  
   362  func filterExpiredLogs(now time.Time, logName, ext string, duration time.Duration, logInfos []fs.FileInfo) ([]fs.FileInfo, []fs.FileInfo) {
   363  	// Firstly, we satisfy the max age requirement.
   364  	expired := make([]os.FileInfo, 0, 16)
   365  	rest := make([]os.FileInfo, 0, 16)
   366  	for _, info := range logInfos {
   367  		filename := filepath.Base(info.Name())
   368  		if !strings.HasPrefix(filename, logName) || !strings.HasSuffix(filename, ext) {
   369  			continue
   370  		}
   371  		ts := strings.TrimPrefix(filename, logName+"_")
   372  		ts = strings.TrimSuffix(ts, ext)
   373  		if dateTime, err := time.Parse(backupDateTimeFormat, ts); err == nil {
   374  			if now.Sub(dateTime) > duration {
   375  				expired = append(expired, info)
   376  			} else {
   377  				rest = append(rest, info)
   378  			}
   379  		}
   380  	}
   381  	return expired, rest
   382  }
   383  
   384  func handleRollingError(err error) {
   385  	if err != nil {
   386  		_, _ = fmt.Fprintf(os.Stderr, "[XLogger] rolling file occurs error: %s\n", err)
   387  	}
   388  }
   389  
   390  func filterMaxBackupLogs(expired, rest []fs.FileInfo, maxBackups int) []fs.FileInfo {
   391  	// Secondly, we satisfy the max backups requirement.
   392  	redundant := len(rest) - maxBackups
   393  	if redundant > 0 {
   394  		sort.Slice(rest, func(i, j int) bool {
   395  			// If the log file is modified manually, the sort maybe wrong!
   396  			return rest[i].ModTime().Before(rest[j].ModTime())
   397  		})
   398  		for i := 0; i < redundant; i++ {
   399  			expired = append(expired, rest[i])
   400  		}
   401  	}
   402  	return expired
   403  }
   404  
   405  // Only one zip file will be presented.
   406  func compressExpiredLogs(filePath, zipName string, expired []fs.FileInfo) error {
   407  	var (
   408  		logZip  *os.File
   409  		prevZip *zip.ReadCloser
   410  	)
   411  	info, err := os.Stat(filepath.Join(filePath, zipName))
   412  	if err == nil && !info.IsDir() {
   413  		// Exists
   414  		if logZip, err = safeopen.OpenFileBeneath(filePath, "xlog-tmp.zip", os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644); err != nil {
   415  			return err
   416  		}
   417  		if prevZip, err = zip.OpenReader(filepath.Join(filePath, zipName)); err != nil {
   418  			return err
   419  		}
   420  	} else {
   421  		if logZip, err = os.Create(filepath.Join(filePath, zipName)); err != nil {
   422  			return err
   423  		}
   424  	}
   425  	zipWriter := zip.NewWriter(logZip)
   426  	for _, info := range expired {
   427  		filename := filepath.Base(info.Name())
   428  		file, err := safeopen.OpenBeneath(filePath, filename)
   429  		if err == nil {
   430  			if zipFile, err := zipWriter.Create(filename); err == nil {
   431  				if _, err = io.Copy(zipFile, file); err == nil {
   432  					_ = file.Close()
   433  					file = nil
   434  					if err = os.Remove(filepath.Join(filePath, filename)); err != nil {
   435  						handleRollingError(err)
   436  					}
   437  				}
   438  			}
   439  			if file != nil {
   440  				_ = file.Close()
   441  			}
   442  		}
   443  	}
   444  	// Copy previous zip content to new zip file.
   445  	if prevZip != nil {
   446  		prevZip.SetSecurityMode(prevZip.GetSecurityMode() | zip.MaximumSecurityMode)
   447  		for _, f := range prevZip.File {
   448  			oldReader, err := f.Open()
   449  			if err != nil || f.Mode().IsDir() {
   450  				if oldReader != nil {
   451  					_ = oldReader.Close()
   452  				}
   453  				continue
   454  			}
   455  
   456  			header := &zip.FileHeader{
   457  				Name:   f.Name,
   458  				Method: f.Method,
   459  			}
   460  			if zipFile, err := zipWriter.CreateHeader(header); err == nil {
   461  				if _, err = io.Copy(zipFile, oldReader); err == nil {
   462  					_ = oldReader.Close()
   463  				}
   464  			}
   465  			if oldReader != nil {
   466  				_ = oldReader.Close()
   467  			}
   468  		}
   469  		if err := zipWriter.Flush(); err != nil {
   470  			return err
   471  		}
   472  	}
   473  	_ = zipWriter.Close()
   474  	zipWriter = nil
   475  	_ = logZip.Close()
   476  	if prevZip != nil {
   477  		_ = prevZip.Close()
   478  		if err = os.Remove(filepath.Join(filePath, zipName)); err != nil {
   479  			handleRollingError(err)
   480  		}
   481  		if err := os.Rename(filepath.Join(filePath, "xlog-tmp.zip"), filepath.Join(filePath, zipName)); err != nil {
   482  			handleRollingError(err)
   483  		}
   484  	}
   485  	return nil
   486  }