Files
Sarv d1d28eb7bb feat(wechat): support macOS 26 (#293)
* support macOS 26

* fix filecopy
2025-10-12 01:03:35 +08:00

978 lines
33 KiB
Go

// Package filecopy provides a high-performance file copying service with persistent caching.
// It creates temporary copies of files that can be reused across application restarts,
// significantly reducing I/O overhead for large files.
//
// Key features:
// - Instance-based isolation: Different instance IDs maintain separate cache namespaces
// - Persistent caching: Temporary files survive application restarts
// - Automatic cleanup: Removes orphaned files and manages cache lifecycle
// - Thread-safe operations: Concurrent access is fully supported
// - Version management: Only keeps the latest version of each cached file
package filecopy
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/cespare/xxhash"
"github.com/rs/zerolog/log"
)
// Configuration constants for cache management and behavior tuning.
const (
// CleanupInterval defines how often to run unified cleanup (1 minute).
// First run is delayed by CleanupInterval after manager initialization.
CleanupInterval = 1 * time.Minute
// OrphanFileCleanupThreshold defines when orphaned files should be cleaned up (10 minutes).
OrphanFileCleanupThreshold = 10 * time.Minute
// MaxCacheEntries defines the maximum number of files to keep in the cache to prevent memory leaks.
MaxCacheEntries = 10000 // Reasonable limit for most use cases
// RecentFileProtectionWindow prevents deletion of recently modified/accessed files.
RecentFileProtectionWindow = 2 * CleanupInterval
// DedupSkipWindow skips version deduplication for very recent files during periodic cleanup.
DedupSkipWindow = CleanupInterval
// PathHashHexLen limits path-hash length in filenames (increase to lower collision risk).
PathHashHexLen = 12
// DataHashHexLen limits data-hash length in filenames.
DataHashHexLen = 16
// MaxBaseNameLen limits base filename length in temp file naming.
MaxBaseNameLen = 100
)
// Version detection policy for generating data hash in file naming and cache keys.
type VersionDetectionMode int
const (
// VersionDetectContentHash computes data hash from entire file content (strong consistency).
VersionDetectContentHash VersionDetectionMode = iota
// VersionDetectSizeModTime computes data hash from size+modtime only (faster, weaker consistency).
VersionDetectSizeModTime
)
// VersionDetection controls how data hash is computed. Adjust as needed.
const VersionDetection = VersionDetectContentHash
// Manager instances per instanceID for proper isolation.
var (
managers = make(map[string]*FileCopyManager)
managersMu sync.RWMutex
)
// FileCopyManager manages temporary file copies with persistent caching capabilities.
// It provides thread-safe operations for creating, accessing, and cleaning up temporary files.
type FileCopyManager struct {
instanceID string // Instance identifier for this manager
tempDir string // Base directory for storing temporary files
fileIndex sync.Map // File index: key -> *FileIndexEntry (thread-safe)
lastAccess time.Time // Last access time for TTL cleanup
startTime time.Time // Manager initialization time
ctx context.Context // Context for goroutine lifecycle management
cancel context.CancelFunc // Cancel function for graceful shutdown
wg sync.WaitGroup // WaitGroup for goroutine synchronization
cacheSize int64 // Current number of cached entries (atomic)
pathLocks sync.Map // Per-original-path locks to prevent duplicate concurrent copies
}
// FileIndexEntry represents an indexed temporary file with comprehensive metadata.
// It provides O(1) lookup and intelligent file lifecycle management.
// Thread-safe for concurrent access through atomic operations and mutex protection.
type FileIndexEntry struct {
mu sync.RWMutex // Protects concurrent access to mutable fields
TempPath string // Path to the temporary file copy (immutable after creation)
OriginalPath string // Original source file path (protected by mu)
Size int64 // Size of the original file in bytes (immutable after creation)
ModTime time.Time // Modification time of the original file (immutable after creation)
lastAccess int64 // Unix timestamp of most recent access (atomic)
PathHash string // Path hash for collision detection (immutable after creation)
DataHash string // Content hash for file integrity verification (immutable after creation)
BaseName string // Base name for multi-version cleanup (immutable after creation)
Extension string // File extension (normalized, without leading dot) (immutable after creation)
}
// GetLastAccess returns the last access time in a thread-safe manner
func (e *FileIndexEntry) GetLastAccess() time.Time {
return time.Unix(0, atomic.LoadInt64(&e.lastAccess))
}
// SetLastAccess updates the last access time atomically
func (e *FileIndexEntry) SetLastAccess(t time.Time) {
atomic.StoreInt64(&e.lastAccess, t.UnixNano())
}
// GetOriginalPath returns the original path in a thread-safe manner
func (e *FileIndexEntry) GetOriginalPath() string {
e.mu.RLock()
defer e.mu.RUnlock()
return e.OriginalPath
}
// SetOriginalPath updates the original path in a thread-safe manner
func (e *FileIndexEntry) SetOriginalPath(path string) {
e.mu.Lock()
defer e.mu.Unlock()
e.OriginalPath = path
}
// indexCandidate represents a candidate file during index building with timestamp information
type indexCandidate struct {
filePath string // Full path to the temporary file
baseName string // Base name extracted from filename
ext string // File extension extracted from filename
hash string // Hash extracted from filename
timestamp int64 // Timestamp extracted from filename
fileInfo os.FileInfo // File metadata
}
// Utility functions for code consolidation
// extractFileExtension extracts and normalizes file extension (without dot)
func extractFileExtension(filePath string) string {
ext := strings.TrimPrefix(filepath.Ext(filePath), ".")
if ext == "" {
return "bin"
}
return ext
}
// parseHashComponents splits combined hash into pathHash and dataHash
func parseHashComponents(combinedHash string) (pathHash, dataHash string) {
parts := strings.Split(combinedHash, "_")
if len(parts) >= 2 {
return parts[0], parts[1]
}
if len(parts) == 1 {
return parts[0], ""
}
return "", ""
}
// declaredExtFromName extracts the declared extension (without dot) from a temp filename
// using the naming convention: instanceID_+baseName_+ext_+pathHash_+dataHash.ext
// Returns ext and true on success; otherwise false.
func declaredExtFromName(fileName string) (string, bool) {
parts := strings.Split(fileName, "_+")
if len(parts) < 5 {
return "", false
}
// ext is the third from the end
return parts[len(parts)-3], true
}
// toIndexEntry converts the candidate to a FileIndexEntry
func (c *indexCandidate) toIndexEntry() *FileIndexEntry {
// Use utility function to parse hash components
pathHash, dataHash := parseHashComponents(c.hash)
return &FileIndexEntry{
TempPath: c.filePath,
OriginalPath: "", // Will be set when matched during GetTempCopy
Size: c.fileInfo.Size(),
ModTime: c.fileInfo.ModTime(),
lastAccess: time.Now().UnixNano(), // Use atomic field
PathHash: pathHash,
DataHash: dataHash,
BaseName: c.baseName,
Extension: c.ext, // normalized without dot
}
}
// parseFileCandidate parses a filename and creates an indexCandidate if valid
// New format: instanceID_+baseName_+ext_+pathHash_+dataHash.ext
func (fm *FileCopyManager) parseFileCandidate(fileName, filePath string) *indexCandidate {
// Get file info for metadata
info, err := os.Stat(filePath)
if err != nil {
return nil
}
// Parse filename pattern using "_+" separator, but allow baseName to contain the token.
// Format: instanceID _+ baseName (can contain _+) _+ ext _+ pathHash _+ dataHash.ext
parts := strings.Split(fileName, "_+")
if len(parts) < 5 {
return nil // Need at least: instanceID, baseName, ext, pathHash, dataHash
}
if parts[0] != fm.instanceID {
return nil
}
// Extract from right to left to tolerate _+ in baseName
// ... [0]=instanceID, [1:len-3]=baseName parts, [len-3]=ext, [len-2]=pathHash, [len-1]=dataHash.ext
ext := parts[len(parts)-3]
pathHash := parts[len(parts)-2]
dataHashPart := parts[len(parts)-1]
baseName := strings.Join(parts[1:len(parts)-3], "_+")
dataHash := dataHashPart
if dotIndex := strings.Index(dataHashPart, "."); dotIndex != -1 {
dataHash = dataHashPart[:dotIndex]
}
// Critical: Verify actual file extension matches declared extension
// This prevents indexing of auxiliary files like *.db-shm, *.db-wal when we expect *.db
actualExt := extractFileExtension(fileName)
// Strict extension matching: declared ext must match actual file extension
if ext != actualExt {
return nil // Extension mismatch, skip this file
}
// Use file modification time as version timestamp (no longer embedded in filename)
timestamp := info.ModTime().UnixNano()
return &indexCandidate{
filePath: filePath,
baseName: baseName,
ext: ext,
hash: pathHash + "_" + dataHash, // Combine for compatibility with existing logic
timestamp: timestamp,
fileInfo: info,
}
}
// findLatestCandidate finds the candidate with the highest timestamp
func (fm *FileCopyManager) findLatestCandidate(candidates []*indexCandidate) *indexCandidate {
if len(candidates) == 0 {
return nil
}
latest := candidates[0]
for _, candidate := range candidates[1:] {
if candidate.timestamp > latest.timestamp {
latest = candidate
}
}
return latest
}
// getManager returns the FileCopyManager instance for the specified instanceID.
// Creates a new manager if one doesn't exist for this instanceID.
func getManager(instanceID string) *FileCopyManager {
managersMu.RLock()
manager, exists := managers[instanceID]
managersMu.RUnlock()
if exists {
return manager
}
managersMu.Lock()
defer managersMu.Unlock()
// Double-check after acquiring write lock
if manager, exists := managers[instanceID]; exists {
return manager
}
// Create new manager for this instanceID
manager = newManager(instanceID)
managers[instanceID] = manager
return manager
}
// newManager creates and initializes a new FileCopyManager instance for the specified instanceID.
// It sets up the temporary directory and starts background cleanup routines with proper lifecycle management.
func newManager(instanceID string) *FileCopyManager {
procName := getProcessName()
tempDir := filepath.Join(os.TempDir(), "filecopy_"+procName)
// Create temporary directory with improved error handling
if err := os.MkdirAll(tempDir, 0755); err != nil {
// Try fallback directory
tempDir = filepath.Join(os.TempDir(), "filecopy")
if err := os.MkdirAll(tempDir, 0755); err != nil {
// If both fail, use system temp directly (last resort)
tempDir = os.TempDir()
}
}
ctx, cancel := context.WithCancel(context.Background())
fm := &FileCopyManager{
instanceID: instanceID,
tempDir: tempDir,
lastAccess: time.Now(),
startTime: time.Now(),
ctx: ctx,
cancel: cancel,
}
// Build initial file index during initialization
fm.rebuildIndexAndCleanup()
// Start managed goroutines with proper lifecycle
fm.wg.Add(1)
go fm.periodicCleanupWorker()
return fm
}
// processDeletionInline deletes a file inline with safety delay and debug logging on failure.
func (fm *FileCopyManager) processDeletionInline(filePath string) {
// Skip .tmp files to avoid interfering with atomic operations
if strings.Contains(filePath, ".tmp.") {
return
}
if err := os.Remove(filePath); err != nil && !os.IsNotExist(err) {
log.Debug().Err(err).Str("path", filePath).Msg("filecopy: delete failed")
}
}
// periodicCleanupWorker runs unified cleanup periodically.
// First run is delayed by CleanupInterval, then runs every CleanupInterval.
func (fm *FileCopyManager) periodicCleanupWorker() {
defer fm.wg.Done()
ticker := time.NewTicker(CleanupInterval)
defer ticker.Stop()
for {
select {
case <-fm.ctx.Done():
return // Context cancelled, exit
case <-ticker.C:
fm.rebuildIndexAndCleanup()
}
}
}
// rebuildIndexAndCleanup performs unified cleanup by scanning the temporary directory.
// This function is called:
// 1. Once during manager initialization
// 2. Periodically every CleanupInterval (1 minute)
//
// It performs the following tasks:
// 1. Scans all files belonging to this instanceID
// 2. Groups files by version key (same file, different versions)
// 3. Keeps only the latest version of each file
// 4. Removes orphaned files (not in index, older than threshold)
// 5. Performs LRU cleanup if cache size exceeds limit
//
// IMPORTANT: Uses incremental update strategy to avoid concurrent access issues.
// The index is NOT cleared during cleanup, only updated incrementally.
func (fm *FileCopyManager) rebuildIndexAndCleanup() {
entries, err := os.ReadDir(fm.tempDir)
if err != nil {
return // Directory doesn't exist or is inaccessible, skip indexing
}
expectedPrefix := fm.instanceID + "_+"
now := time.Now()
recentTimeThreshold := now.Add(-RecentFileProtectionWindow)
// Only use veryRecentThreshold during periodic cleanup (not during initialization)
// During init, we want to index all files immediately
isPeriodicCleanup := now.Sub(fm.startTime) > CleanupInterval
var veryRecentThreshold time.Time
if isPeriodicCleanup {
veryRecentThreshold = now.Add(-DedupSkipWindow)
}
// Build set of on-disk files later; avoid snapshotting index here to reduce coupling
// Track valid files and latest version per versionKey
// versionKey: instanceID_baseName_ext_pathHash
latestByKey := make(map[string]*indexCandidate)
diskFiles := make(map[string]*indexCandidate) // All valid files on disk
// First pass: collect all matching files and group by version key
for _, entry := range entries {
if entry.IsDir() || !strings.HasPrefix(entry.Name(), expectedPrefix) {
continue
}
filePath := filepath.Join(fm.tempDir, entry.Name())
// Parse filename: instanceID_+baseName_+ext_+pathHash_+dataHash.ext
if candidate := fm.parseFileCandidate(entry.Name(), filePath); candidate != nil {
// Always add to diskFiles so third pass knows file exists
diskFiles[filePath] = candidate
// Skip very recent files from version deduplication during periodic cleanup only
// During initialization, process all files normally
if isPeriodicCleanup && candidate.fileInfo.ModTime().After(veryRecentThreshold) {
continue // Skip version grouping for very recent files during periodic cleanup
}
// Extract components for version grouping
pathHash, _ := parseHashComponents(candidate.hash)
versionKey := fm.generateVersionKey(fm.instanceID, candidate.baseName, candidate.ext, pathHash)
if cur := latestByKey[versionKey]; cur == nil || candidate.timestamp > cur.timestamp {
latestByKey[versionKey] = candidate
}
} else {
// File doesn't match expected pattern. Consider orphan cleanup.
if info, err := entry.Info(); err == nil {
name := entry.Name()
// Never delete temp intermediates
if strings.Contains(filePath, ".tmp.") {
continue
}
// If declared ext exists and differs from actual ext, ignore (e.g., .db-wal/.db-shm)
if declared, ok := declaredExtFromName(name); ok {
actual := extractFileExtension(name)
if declared != actual {
continue
}
}
if now.Sub(info.ModTime()) > OrphanFileCleanupThreshold {
fm.processDeletionInline(filePath)
}
}
}
}
// Second pass: ensure each latest version is present in index
for _, latest := range latestByKey {
pathHash, dataHash := parseHashComponents(latest.hash)
latestCacheKey := fm.generateCacheKey(fm.instanceID, latest.baseName, latest.ext, pathHash, dataHash)
indexEntry := latest.toIndexEntry()
if _, loaded := fm.fileIndex.LoadOrStore(latestCacheKey, indexEntry); !loaded {
atomic.AddInt64(&fm.cacheSize, 1)
}
}
// Third pass: queue old versions for deletion (safe window)
for _, entry := range entries {
if entry.IsDir() || !strings.HasPrefix(entry.Name(), expectedPrefix) {
continue
}
filePath := filepath.Join(fm.tempDir, entry.Name())
candidate := fm.parseFileCandidate(entry.Name(), filePath)
if candidate == nil {
continue
}
// Skip very recent files in periodic cleanup
if isPeriodicCleanup && candidate.fileInfo.ModTime().After(veryRecentThreshold) {
continue
}
pathHash, _ := parseHashComponents(candidate.hash)
versionKey := fm.generateVersionKey(fm.instanceID, candidate.baseName, candidate.ext, pathHash)
latest := latestByKey[versionKey]
if latest == nil || latest.filePath == candidate.filePath {
continue
}
if !candidate.fileInfo.ModTime().Before(recentTimeThreshold) {
continue
}
// Remove from index if present
_, dataHash := parseHashComponents(candidate.hash)
cacheKey := fm.generateCacheKey(fm.instanceID, candidate.baseName, candidate.ext, pathHash, dataHash)
if _, loaded := fm.fileIndex.LoadAndDelete(cacheKey); loaded {
atomic.AddInt64(&fm.cacheSize, -1)
}
// Delete inline (best-effort)
fm.processDeletionInline(candidate.filePath)
}
// Third pass: Remove stale index entries (files that no longer exist on disk)
// But ONLY if they're not recently accessed (to avoid race with GetTempCopy)
fm.fileIndex.Range(func(key, value any) bool {
entry := value.(*FileIndexEntry)
// Skip if recently accessed (likely in active use)
if entry.GetLastAccess().After(recentTimeThreshold) {
return true // Continue iteration
}
// Check if file exists on disk
if _, exists := diskFiles[entry.TempPath]; !exists {
// File not on disk and not recently accessed, remove from index
fm.fileIndex.Delete(key)
atomic.AddInt64(&fm.cacheSize, -1)
}
return true
})
// Fourth pass: perform LRU cleanup if cache size exceeds limit
fm.performCacheCleanup()
}
// extractBaseName extracts the base filename without path and extension for indexing.
func (fm *FileCopyManager) extractBaseName(originalPath string) string {
fileName := filepath.Base(originalPath)
fileExt := filepath.Ext(fileName)
baseName := fileName
if len(fileExt) > 0 && len(fileName) > len(fileExt) {
baseName = fileName[:len(fileName)-len(fileExt)]
}
if baseName == "" || baseName == fileExt {
baseName = "file"
}
return baseName
}
// performCacheCleanup removes least recently used cache entries when cache size exceeds limit
func (fm *FileCopyManager) performCacheCleanup() {
currentSize := atomic.LoadInt64(&fm.cacheSize)
if currentSize <= MaxCacheEntries {
return // Cache size is acceptable
}
// Collect all entries with their last access times
type cacheEntry struct {
key string
lastAccess int64
entry *FileIndexEntry
}
var entries []cacheEntry
fm.fileIndex.Range(func(key, value any) bool {
entry := value.(*FileIndexEntry)
entries = append(entries, cacheEntry{
key: key.(string),
lastAccess: atomic.LoadInt64(&entry.lastAccess),
entry: entry,
})
return true
})
// Sort by last access time (oldest first) - O(n log n) instead of O(n²)
sort.Slice(entries, func(i, j int) bool {
return entries[i].lastAccess < entries[j].lastAccess
})
// Remove oldest 25% of entries to make room for new ones
removeCount := len(entries) / 4
if removeCount < 1 {
removeCount = 1
}
for i := 0; i < removeCount && i < len(entries); i++ {
entry := entries[i]
fm.fileIndex.Delete(entry.key)
atomic.AddInt64(&fm.cacheSize, -1)
// Skip .tmp files (safety check - should not happen but防御性编程)
if strings.Contains(entry.entry.TempPath, ".tmp.") {
continue
}
// Delete inline (best-effort)
fm.processDeletionInline(entry.entry.TempPath)
}
}
// GetTempCopy creates or retrieves a temporary copy of the specified file.
// It provides persistent caching with instance-based isolation.
//
// Parameters:
// - instanceID: Unique identifier for the application instance (e.g., "app_v1.0", "service_name")
// - originalPath: Absolute path to the original file to copy
//
// Returns:
// - string: Path to the temporary copy
// - error: Any error encountered during the operation
//
// The function performs these operations:
// 1. Checks in-memory cache for existing valid copy
// 2. Scans disk for existing cached file that can be reused
// 3. Creates new copy if none found, cleaning up old versions
//
// Thread-safe for concurrent use.
func GetTempCopy(instanceID, originalPath string) (string, error) {
return getManager(instanceID).GetTempCopy(originalPath)
}
// GetTempCopy implements optimized file copying with intelligent index-based lookup.
// This eliminates repeated directory scanning and provides O(1) lookup performance.
// Old file versions are cleaned up by the periodic cleanup worker, not here.
func (fm *FileCopyManager) GetTempCopy(originalPath string) (string, error) {
// Validate original file and get metadata
stat, err := os.Stat(originalPath)
if err != nil {
return "", fmt.Errorf("original file does not exist: %w", err)
}
// Ensure only one goroutine copies the same source path at a time
lock := fm.getPathLock(originalPath)
lock.Lock()
defer lock.Unlock()
now := time.Now()
currentModTime := stat.ModTime()
currentSize := stat.Size()
currentHash := fm.hashString(originalPath)
// Update last access time for TTL cleanup (no lock needed for time.Time)
fm.lastAccess = now
// Compute data hash once per policy under the per-path lock
var expectedDataHash string
if VersionDetection == VersionDetectSizeModTime {
hex := fmt.Sprintf("%x", currentSize+currentModTime.UnixNano())
if len(hex) > DataHashHexLen {
expectedDataHash = hex[:DataHashHexLen]
} else {
expectedDataHash = hex
}
} else {
expectedDataHash, err = fm.hashFileContent(originalPath, currentSize)
if err != nil {
hex := fmt.Sprintf("%x", currentSize+currentModTime.UnixNano())
if len(hex) > DataHashHexLen {
expectedDataHash = hex[:DataHashHexLen]
} else {
expectedDataHash = hex
}
} else if len(expectedDataHash) > DataHashHexLen {
expectedDataHash = expectedDataHash[:DataHashHexLen]
}
}
// Strategy 1: Check index for existing file using unified cache key
baseName := fm.extractBaseName(originalPath)
ext := extractFileExtension(originalPath)
cacheKey := fm.generateCacheKey(fm.instanceID, baseName, ext, currentHash, expectedDataHash)
if value, exists := fm.fileIndex.Load(cacheKey); exists {
entry := value.(*FileIndexEntry)
if _, err := os.Stat(entry.TempPath); err == nil && currentSize == entry.Size {
entry.SetLastAccess(now)
entry.SetOriginalPath(originalPath)
return entry.TempPath, nil
}
// Remove stale index entry; physical deletion handled elsewhere
fm.fileIndex.Delete(cacheKey)
atomic.AddInt64(&fm.cacheSize, -1)
}
// Strategy 2: No valid cached file found, create new one (avoid re-hashing)
tempPath := fm.generateTempPathWithHash(originalPath, expectedDataHash)
if err := fm.atomicCopyFile(originalPath, tempPath); err != nil {
return "", err
}
// Add to index using LoadOrStore to keep cacheSize accurate under races
newEntry := &FileIndexEntry{
TempPath: tempPath,
OriginalPath: originalPath,
Size: currentSize,
ModTime: currentModTime,
lastAccess: now.UnixNano(),
PathHash: currentHash,
DataHash: expectedDataHash,
BaseName: baseName,
Extension: ext, // normalized without dot
}
if _, loaded := fm.fileIndex.LoadOrStore(cacheKey, newEntry); !loaded {
atomic.AddInt64(&fm.cacheSize, 1)
}
return tempPath, nil
}
// getPathLock returns a per-original-path mutex to serialize copy operations for the same source.
func (fm *FileCopyManager) getPathLock(key string) *sync.Mutex {
if v, ok := fm.pathLocks.Load(key); ok {
return v.(*sync.Mutex)
}
m := &sync.Mutex{}
actual, _ := fm.pathLocks.LoadOrStore(key, m)
return actual.(*sync.Mutex)
}
// generateTempPath creates a unique temporary file path using a structured naming convention.
// The format is: instanceID_+baseName_+ext_+pathHash_+dataHash.ext
// This naming scheme uses "_+" separator to avoid conflicts with filenames containing underscores.
func (fm *FileCopyManager) generateTempPath(originalPath string) string {
fileName := filepath.Base(originalPath)
fileExt := filepath.Ext(fileName)
baseName := fileName
if len(fileExt) > 0 && len(fileName) > len(fileExt) {
baseName = fileName[:len(fileName)-len(fileExt)]
}
if baseName == "" || baseName == fileExt {
baseName = "file"
}
// Limit baseName length to prevent filesystem errors (most filesystems have 255 char limit)
// Reserve space for metadata in filename
if len(baseName) > MaxBaseNameLen {
baseName = baseName[:MaxBaseNameLen]
}
// Generate path hash for collision avoidance
pathHash := fm.hashString(originalPath)
if len(pathHash) > PathHashHexLen {
pathHash = pathHash[:PathHashHexLen]
}
// Generate content hash here is expensive and duplicates work with GetTempCopy.
// Keep this method for callers that don't pre-compute the data hash.
stat, err := os.Stat(originalPath)
var dataHash string
if err != nil {
hex := fmt.Sprintf("%x", time.Now().UnixNano())
if len(hex) > DataHashHexLen {
dataHash = hex[:DataHashHexLen]
} else {
dataHash = hex
}
} else {
// Respect version detection policy
if VersionDetection == VersionDetectSizeModTime {
hex := fmt.Sprintf("%x", stat.Size()+stat.ModTime().UnixNano())
if len(hex) > DataHashHexLen {
dataHash = hex[:DataHashHexLen]
} else {
dataHash = hex
}
} else {
if h, err2 := fm.hashFileContent(originalPath, stat.Size()); err2 == nil {
if len(h) > DataHashHexLen {
dataHash = h[:DataHashHexLen]
} else {
dataHash = h
}
} else {
hex := fmt.Sprintf("%x", stat.Size()+stat.ModTime().UnixNano())
if len(hex) > DataHashHexLen {
dataHash = hex[:DataHashHexLen]
} else {
dataHash = hex
}
}
}
}
// Clean extension (remove dot)
cleanExt := strings.TrimPrefix(fileExt, ".")
if cleanExt == "" {
cleanExt = "bin" // Default extension for files without extensions
}
// Construct temporary file path with new naming convention
return filepath.Join(fm.tempDir, fmt.Sprintf("%s_+%s_+%s_+%s_+%s%s",
fm.instanceID, baseName, cleanExt, pathHash, dataHash, fileExt))
}
// generateTempPathWithHash creates a temp path using a precomputed dataHash to avoid re-hashing.
func (fm *FileCopyManager) generateTempPathWithHash(originalPath, dataHash string) string {
fileName := filepath.Base(originalPath)
fileExt := filepath.Ext(fileName)
baseName := fileName
if len(fileExt) > 0 && len(fileName) > len(fileExt) {
baseName = fileName[:len(fileName)-len(fileExt)]
}
if baseName == "" || baseName == fileExt {
baseName = "file"
}
if len(baseName) > MaxBaseNameLen {
baseName = baseName[:MaxBaseNameLen]
}
pathHash := fm.hashString(originalPath)
if len(pathHash) > PathHashHexLen {
pathHash = pathHash[:PathHashHexLen]
}
cleanExt := strings.TrimPrefix(fileExt, ".")
if cleanExt == "" {
cleanExt = "bin"
}
if len(dataHash) > DataHashHexLen {
dataHash = dataHash[:DataHashHexLen]
}
return filepath.Join(fm.tempDir, fmt.Sprintf("%s_+%s_+%s_+%s_+%s%s",
fm.instanceID, baseName, cleanExt, pathHash, dataHash, fileExt))
}
// atomicCopyFile performs an atomic file copy operation to ensure data integrity.
// It uses a temporary file and atomic rename to prevent partial writes from being visible.
func (fm *FileCopyManager) atomicCopyFile(src, dst string) error {
// Create temporary file for atomic operation
tempDst := dst + ".tmp." + fmt.Sprintf("%d", time.Now().UnixNano())
// Open source file for reading
srcFile, err := os.Open(src)
if err != nil {
return fmt.Errorf("failed to open source file: %w", err)
}
defer srcFile.Close()
// Create temporary destination file
dstFile, err := os.Create(tempDst)
if err != nil {
return fmt.Errorf("failed to create temporary file: %w", err)
}
// Ensure cleanup of temporary file on error
defer func() {
if err != nil {
os.Remove(tempDst)
}
}()
// Use buffered copy for better performance with large files
buf := make([]byte, 256*1024) // 256KB buffer
if _, err = io.CopyBuffer(dstFile, srcFile, buf); err != nil {
return fmt.Errorf("failed to copy file contents: %w", err)
}
// Force write to disk to ensure data persistence
if err = dstFile.Sync(); err != nil {
return fmt.Errorf("failed to sync temporary file: %w", err)
}
// Close file before rename operation
if err = dstFile.Close(); err != nil {
return fmt.Errorf("failed to close temporary file: %w", err)
}
// Verify temp file still exists before rename with retries (safety check against race conditions)
maxRetries := 3
for attempt := 0; attempt < maxRetries; attempt++ {
if _, err = os.Stat(tempDst); err == nil {
break // File exists, proceed with rename
}
if attempt < maxRetries-1 {
time.Sleep(10 * time.Millisecond) // Wait before retry
} else {
return fmt.Errorf("temporary file disappeared before rename after %d attempts: %w", maxRetries, err)
}
}
// Atomic rename to final destination
if err = os.Rename(tempDst, dst); err != nil {
return fmt.Errorf("failed to rename temporary file: %w", err)
}
return nil
}
// hashString generates a 64-bit hash of the input string.
// Use xxhash (fast, well-distributed) to reduce collision risk for version grouping.
func (fm *FileCopyManager) hashString(s string) string {
h := xxhash.New()
// write never errors for xxhash
_, _ = h.Write([]byte(s))
return fmt.Sprintf("%x", h.Sum64())
}
// generateCacheKey creates a unified cache key for file indexing and lookup.
// Format: instanceID_baseName_ext_pathHash_dataHash
// This ensures consistent key generation across buildFileIndex and GetTempCopy.
func (fm *FileCopyManager) generateCacheKey(instanceID, baseName, ext, pathHash, dataHash string) string {
return instanceID + "_" + baseName + "_" + ext + "_" + pathHash + "_" + dataHash
}
// generateVersionKey creates a key for version deduplication (without dataHash).
// Files with same instanceID+baseName+ext+pathHash are considered versions of the same file.
func (fm *FileCopyManager) generateVersionKey(instanceID, baseName, ext, pathHash string) string {
return instanceID + "_" + baseName + "_" + ext + "_" + pathHash
}
// hashFileContent generates a fast hash of file content for integrity verification.
// Uses xxhash for complete file hashing, providing excellent performance (7120+ MB/s).
func (fm *FileCopyManager) hashFileContent(filePath string, _ int64) (string, error) {
file, err := os.Open(filePath)
if err != nil {
return "", err
}
defer file.Close()
// Use xxhash for complete file hashing - benchmark shows 3.3x faster than SHA-256
h := xxhash.New()
if _, err := io.Copy(h, file); err != nil {
return "", err
}
return fmt.Sprintf("%x", h.Sum64()), nil
}
// Shutdown performs graceful shutdown and cleanup of all resources (Public API).
// This cleans up all manager instances and allows for re-initialization if needed.
func Shutdown() {
managersMu.Lock()
defer managersMu.Unlock()
// Shutdown all managers
for _, manager := range managers {
manager.Shutdown()
}
// Clear managers map
managers = make(map[string]*FileCopyManager)
}
// Shutdown performs complete cleanup by removing all temporary files and cache entries.
// This method ensures clean resource deallocation with proper goroutine lifecycle management.
func (fm *FileCopyManager) Shutdown() {
// Stop periodic cleanup first
fm.cancel()
// Delete all cached temporary files inline (best-effort)
fm.fileIndex.Range(func(key, value any) bool {
entry := value.(*FileIndexEntry)
fm.processDeletionInline(entry.TempPath)
return true
})
// Clear all entries and reset cacheSize
fm.fileIndex.Range(func(key, value any) bool {
fm.fileIndex.Delete(key)
return true
})
atomic.StoreInt64(&fm.cacheSize, 0)
// Wait for all goroutines to finish properly
fm.wg.Wait()
// Note: Do NOT remove the shared temp directory here as other instances may still be using it
}
// getProcessName extracts and sanitizes the current process name for use in temporary directory naming.
// Returns a clean process name suitable for filesystem path construction.
func getProcessName() string {
executable, err := os.Executable()
if err != nil {
return "unknown"
}
// Extract base name (without extension)
baseName := filepath.Base(executable)
ext := filepath.Ext(baseName)
if ext != "" {
baseName = baseName[:len(baseName)-len(ext)]
}
// Sanitize name to contain only safe characters
baseName = cleanProcessName(baseName)
return baseName
}
// cleanProcessName sanitizes a process name by replacing invalid characters with underscores.
// Keeps only alphanumeric characters, hyphens, and underscores for filesystem safety.
func cleanProcessName(name string) string {
result := make([]rune, 0, len(name))
for _, r := range name {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
(r >= '0' && r <= '9') || r == '-' || r == '_' {
result = append(result, r)
} else {
result = append(result, '_')
}
}
return string(result)
}