From 501ed1ff0d343a455fb79bf92f09fd3c227cf8d9 Mon Sep 17 00:00:00 2001 From: Erisa A Date: Sun, 11 May 2025 14:41:38 +0100 Subject: [PATCH 1/9] Merge pull request #23 from Erisa/patch-1 chore: fix mount directory names in README --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index dcc4a22..ebef3fd 100644 --- a/README.md +++ b/README.md @@ -59,12 +59,12 @@ See [docs](https://www.tigrisdata.com/docs/sdks/s3/aws-cli/) for more details. ```bash systemctl --user start tigrisfs@ ``` - The bucket is mounted at `$HOME/mnt/tigrisfs/`. + The bucket is mounted at `$HOME/mnt/tigris/`. * as root ```bash systemctl start tigrisfs@ ``` - The bucket is mounted at `/mnt/tigrisfs/`. + The bucket is mounted at `/mnt/tigris/`. ## Binary install From 5bf51d19fb431eb4ea6590ed6b931cc6600f006b Mon Sep 17 00:00:00 2001 From: Yevgeniy Firsov Date: Thu, 29 May 2025 10:08:27 -0700 Subject: [PATCH 2/9] fix: Disable prefetch on list by default --- core/cfg/flags.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/core/cfg/flags.go b/core/cfg/flags.go index 4db850a..cd92902 100644 --- a/core/cfg/flags.go +++ b/core/cfg/flags.go @@ -148,8 +148,8 @@ MISC OPTIONS: }, cli.BoolFlag{ - Name: "no-tigris-prefetch", - Usage: "Disable Tigris prefetch on list (default: on)", + Name: "tigris-prefetch", + Usage: "Enable Tigris prefetch on list (default: off)", }, cli.BoolFlag{ @@ -955,7 +955,7 @@ func PopulateFlags(c *cli.Context) (ret *FlagStorage) { ClusterMode: c.Bool("cluster"), ClusterGrpcReflection: c.Bool("grpc-reflection"), - TigrisPrefetch: !c.Bool("no-tigris-prefetch"), + TigrisPrefetch: c.Bool("tigris-prefetch"), TigrisListContent: c.Bool("tigris-list-content"), } @@ -1138,7 +1138,7 @@ func DefaultFlags() *FlagStorage { {PartSize: 25 * 1024 * 1024, PartCount: 1000}, {PartSize: 125 * 1024 * 1024, PartCount: 8000}, }, - TigrisPrefetch: true, + TigrisPrefetch: false, TigrisListContent: true, } } From e1071215cc123b41bfb7d130462c01a5d40c6524 Mon Sep 17 00:00:00 2001 From: Yevgeniy Firsov Date: Thu, 29 May 2025 00:22:20 -0700 Subject: [PATCH 3/9] fix: Installation script --- install.sh | 637 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 637 insertions(+) create mode 100644 install.sh diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..992f427 --- /dev/null +++ b/install.sh @@ -0,0 +1,637 @@ +#!/bin/bash + +# TigrisFS Installation Script +# Downloads and installs the latest release from GitHub + +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Configuration +REPO="tigrisdata/tigrisfs" +INSTALL_DIR="${INSTALL_DIR:-/usr/bin}" +BINARY_NAME="tigrisfs" + +# Function to print colored output +print_info() { + echo -e "${BLUE}[INFO]${NC} $1" +} + +print_success() { + echo -e "${GREEN}[SUCCESS]${NC} $1" +} + +print_warning() { + echo -e "${YELLOW}[WARNING]${NC} $1" +} + +print_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +# Function to detect architecture +detect_arch() { + local arch + arch=$(uname -m) + + case $arch in + x86_64|amd64) + echo "amd64" + ;; + aarch64|arm64) + echo "arm64" + ;; + armv7l|armv6l) + echo "arm" + ;; + i386|i686) + echo "386" + ;; + *) + print_error "Unsupported architecture: $arch" + exit 1 + ;; + esac +} + +# Function to detect package manager preference +detect_package_preference() { + local os="$1" + + # Use forced package type if specified + if [ -n "$FORCE_PACKAGE_TYPE" ]; then + echo "$FORCE_PACKAGE_TYPE" + return + fi + + if [ "$os" != "linux" ]; then + echo "tar.gz" + return + fi + + # Check for package managers in order of preference + if command_exists dpkg && [ -z "$FORCE_TARBALL" ]; then + echo "deb" + elif command_exists rpm && [ -z "$FORCE_TARBALL" ]; then + echo "rpm" + elif command_exists apk && [ -z "$FORCE_TARBALL" ]; then + echo "apk" + else + echo "tar.gz" + fi +} + +# Function to detect OS +detect_os() { + local os + os=$(uname -s | tr '[:upper:]' '[:lower:]') + + case $os in + linux) + echo "linux" + ;; + darwin) + echo "darwin" + ;; + windows*|mingw*|msys*) + echo "windows" + ;; + freebsd) + echo "freebsd" + ;; + *) + print_error "Unsupported operating system: $os" + exit 1 + ;; + esac +} + +# Function to check if command exists +command_exists() { + command -v "$1" >/dev/null 2>&1 +} + +# Function to check dependencies +check_dependencies() { + local missing_deps=() + local package_type="$1" + + if ! command_exists curl && ! command_exists wget; then + missing_deps+=("curl or wget") + fi + + if ! command_exists jq; then + missing_deps+=("jq") + fi + + if ! command_exists sha256sum && ! command_exists shasum; then + missing_deps+=("sha256sum or shasum") + fi + + # Check for package-specific dependencies + case "$package_type" in + tar.gz) + if ! command_exists tar; then + missing_deps+=("tar") + fi + ;; + deb) + if ! command_exists dpkg; then + missing_deps+=("dpkg") + fi + ;; + rpm) + if ! command_exists rpm; then + missing_deps+=("rpm") + fi + ;; + apk) + if ! command_exists apk; then + missing_deps+=("apk") + fi + ;; + esac + + if [ ${#missing_deps[@]} -gt 0 ]; then + print_error "Missing required dependencies: ${missing_deps[*]}" + print_info "Please install the missing dependencies and try again." + + # Provide installation hints for common package managers + if command_exists apt-get; then + print_info "Ubuntu/Debian: sudo apt-get install curl jq coreutils tar" + elif command_exists yum; then + print_info "RHEL/CentOS: sudo yum install curl jq coreutils tar" + elif command_exists brew; then + print_info "macOS: brew install curl jq coreutils gnu-tar" + fi + + exit 1 + fi +} + +# Function to download file +download_file() { + local url="$1" + local output="$2" + + if command_exists curl; then + if ! curl -fsSL -o "$output" "$url"; then + print_error "Failed to download from $url" + return 1 + fi + elif command_exists wget; then + if ! wget -q -O "$output" "$url"; then + print_error "Failed to download from $url" + return 1 + fi + else + print_error "No download tool available (curl or wget)" + return 1 + fi + + # Verify file was created and has content + if [ ! -f "$output" ] || [ ! -s "$output" ]; then + print_error "Downloaded file is empty or doesn't exist: $output" + return 1 + fi + + return 0 +} + +# Function to get latest release info +get_latest_release() { + local api_url="https://api.github.com/repos/$REPO/releases/latest" + local temp_file + temp_file=$(mktemp) + + print_info "Fetching latest release information..." >&2 + + if ! download_file "$api_url" "$temp_file"; then + print_error "Failed to fetch release information" + rm -f "$temp_file" + exit 1 + fi + + if ! jq -e . "$temp_file" >/dev/null 2>&1; then + print_error "Invalid JSON response from GitHub API" + cat "$temp_file" >&2 + rm -f "$temp_file" + exit 1 + fi + + echo "$temp_file" +} + +# Function to verify checksum +verify_checksum() { + local file="$1" + local checksums_file="$2" + local filename + filename=$(basename "$file") + + print_info "Verifying checksum for $filename..." + + # Extract expected checksum + local expected_checksum + expected_checksum=$(grep "$filename" "$checksums_file" | awk '{print $1}') + + if [ -z "$expected_checksum" ]; then + print_warning "No checksum found for $filename in checksums file" + return 1 + fi + + # Calculate actual checksum + local actual_checksum + if command_exists sha256sum; then + actual_checksum=$(sha256sum "$file" | awk '{print $1}') + elif command_exists shasum; then + actual_checksum=$(shasum -a 256 "$file" | awk '{print $1}') + else + print_error "No checksum tool available" + return 1 + fi + + if [ "$expected_checksum" = "$actual_checksum" ]; then + print_success "Checksum verification passed" + return 0 + else + print_error "Checksum verification failed!" + print_error "Expected: $expected_checksum" + print_error "Actual: $actual_checksum" + return 1 + fi +} + +# Function to verify GPG signature (optional) +verify_signature() { + local checksums_file="$1" + local signature_file="$2" + + if ! command_exists gpg; then + print_warning "GPG not available, skipping signature verification" + return 0 + fi + + print_info "Verifying GPG signature..." + + if gpg --verify "$signature_file" "$checksums_file" 2>/dev/null; then + print_success "GPG signature verification passed" + return 0 + else + print_warning "GPG signature verification failed or key not trusted" + print_info "You may need to import the signing key first" + return 1 + fi +} + +# Function to install binary +install_binary() { + local binary_file="$1" + local install_path="$INSTALL_DIR/$BINARY_NAME" + + print_info "Installing $BINARY_NAME to $install_path..." + + # Create install directory if it doesn't exist + if [ ! -d "$INSTALL_DIR" ]; then + if ! run_with_privilege mkdir -p "$INSTALL_DIR"; then + print_error "Failed to create install directory: $INSTALL_DIR" + print_info "Try running with sudo or set INSTALL_DIR to a writable location" + exit 1 + fi + fi + + # Copy and set permissions + if ! run_with_privilege cp "$binary_file" "$install_path"; then + print_error "Failed to copy binary to $install_path" + print_info "Try running with sudo or set INSTALL_DIR to a writable location" + exit 1 + fi + + run_with_privilege chmod +x "$install_path" + print_success "$BINARY_NAME installed successfully to $install_path" +} + +# Function to extract and install from tar.gz +install_from_tarball() { + local tarball_file="$1" + local temp_dir="$2" + local extract_dir="${temp_dir}/extract" + + print_info "Extracting tarball..." + mkdir -p "$extract_dir" + + if ! tar -xzf "$tarball_file" -C "$extract_dir"; then + print_error "Failed to extract tarball" + return 1 + fi + + # Find the binary in the extracted files + local binary_file + binary_file=$(find "$extract_dir" -name "$BINARY_NAME" -type f | head -n1) + + if [ -z "$binary_file" ]; then + # Try common variations + binary_file=$(find "$extract_dir" -name "tigrisfs*" -type f -executable | head -n1) + fi + + if [ -z "$binary_file" ]; then + print_error "Binary not found in extracted files" + return 1 + fi + + install_binary "$binary_file" +} + +# Function to run command with sudo if not root +run_with_privilege() { + if [ "$EUID" -eq 0 ]; then + "$@" + else + sudo "$@" + fi +} + +# Function to install package using system package manager +install_package() { + local package_file="$1" + local package_type="$2" + + print_info "Installing $package_type package..." + + case "$package_type" in + deb) + if ! run_with_privilege dpkg -i "$package_file" 2>/dev/null; then + print_info "Package installation failed, trying with apt-get to fix dependencies..." + if command_exists apt-get; then + run_with_privilege apt-get install -f -y + fi + fi + ;; + rpm) + if command_exists dnf; then + run_with_privilege dnf install -y "$package_file" + elif command_exists yum; then + run_with_privilege yum install -y "$package_file" + else + run_with_privilege rpm -i "$package_file" + fi + ;; + apk) + run_with_privilege apk add --allow-untrusted "$package_file" + ;; + *) + print_error "Unsupported package type: $package_type" + return 1 + ;; + esac + + print_success "Package installed successfully" +} + +# Main installation function +main() { + print_info "TigrisFS Installation Script" + print_info "Repository: https://github.com/$REPO" + + # Detect system + local os arch package_type + os=$(detect_os) + arch=$(detect_arch) + package_type=$(detect_package_preference "$os") + + print_info "Detected system: $os/$arch" + print_info "Preferred package type: $package_type" + + # Check dependencies + check_dependencies "$package_type" + + # Get latest release info + local release_file + release_file=$(get_latest_release) + + if [ ! -f "$release_file" ]; then + print_error "Failed to get release information" + exit 1 + fi + + local tag_name + tag_name=$(jq -r '.tag_name' "$release_file" 2>/dev/null) + + if [ -z "$tag_name" ] || [ "$tag_name" = "null" ]; then + print_error "Could not parse release tag from GitHub API response" + print_info "API Response:" + head -n 10 "$release_file" >&2 + rm -f "$release_file" + exit 1 + fi + + print_info "Latest release: $tag_name" + + # Determine package filename based on the actual release format + local package_filename + case "$package_type" in + tar.gz) + package_filename="tigrisfs_${tag_name#v}_${os}_${arch}.tar.gz" + ;; + deb) + package_filename="tigrisfs_${tag_name#v}_${os}_${arch}.deb" + ;; + rpm) + package_filename="tigrisfs_${tag_name#v}_${os}_${arch}.rpm" + ;; + apk) + package_filename="tigrisfs_${tag_name#v}_${os}_${arch}.apk" + ;; + esac + + # Find download URL for package + local package_url + package_url=$(jq -r --arg name "$package_filename" '.assets[] | select(.name == $name) | .browser_download_url' "$release_file") + + if [ -z "$package_url" ] || [ "$package_url" = "null" ]; then + # Try fallback to tar.gz if preferred package type not found + if [ "$package_type" != "tar.gz" ]; then + print_warning "Preferred package type ($package_type) not found, falling back to tar.gz" + package_type="tar.gz" + package_filename="tigrisfs_${tag_name#v}_${os}_${arch}.tar.gz" + package_url=$(jq -r --arg name "$package_filename" '.assets[] | select(.name == $name) | .browser_download_url' "$release_file") + fi + + if [ -z "$package_url" ] || [ "$package_url" = "null" ]; then + print_error "Package not found for $os/$arch" + print_info "Available assets:" + jq -r '.assets[].name' "$release_file" | sed 's/^/ - /' + rm -f "$release_file" + exit 1 + fi + fi + + # Find checksums and signature URLs + local checksums_url signature_url + checksums_url=$(jq -r '.assets[] | select(.name == "checksums.txt") | .browser_download_url' "$release_file") + signature_url=$(jq -r '.assets[] | select(.name == "checksums.sig") | .browser_download_url' "$release_file") + + rm -f "$release_file" + + # Create temporary directory + local temp_dir + temp_dir=$(mktemp -d) + + # Cleanup function + cleanup() { + rm -rf "$temp_dir" + } + trap cleanup EXIT + + # Download files + local package_file="${temp_dir}/${package_filename}" + local checksums_file="${temp_dir}/checksums.txt" + local signature_file="${temp_dir}/checksums.sig" + + print_info "Downloading $package_filename..." + download_file "$package_url" "$package_file" + + # Download checksums if available + if [ -n "$checksums_url" ] && [ "$checksums_url" != "null" ]; then + print_info "Downloading checksums.txt..." + download_file "$checksums_url" "$checksums_file" + + # Verify checksum + if ! verify_checksum "$package_file" "$checksums_file"; then + print_error "Checksum verification failed. Aborting installation." + exit 1 + fi + + # Download and verify signature if available + if [ -n "$signature_url" ] && [ "$signature_url" != "null" ]; then + print_info "Downloading checksums.sig..." + download_file "$signature_url" "$signature_file" + verify_signature "$checksums_file" "$signature_file" + fi + else + print_warning "Checksums not available, skipping verification" + fi + + # Install based on package type + case "$package_type" in + tar.gz) + install_from_tarball "$package_file" "$temp_dir" + ;; + deb|rpm|apk) + # For system packages, we need root privileges +# if [ "$EUID" -ne 0 ] && [ -z "$FORCE_TARBALL" ]; then +# print_info "System package installation requires root privileges." +# print_info "Please run with sudo, or set FORCE_TARBALL=1 to use tarball installation instead." +# exit 1 +# fi + install_package "$package_file" "$package_type" + ;; + esac + + # Verify installation + if command_exists "$BINARY_NAME"; then + print_success "Installation completed successfully!" + print_info "Run '$BINARY_NAME --help' to get started" + + # Show version if possible + if "$BINARY_NAME" --version >/dev/null 2>&1; then + local version + version=$("$BINARY_NAME" --version 2>&1| head -n1 | cut -d ' ' -f 3) + print_info "Installed version: $version" + fi + else + if [ "$package_type" = "tar.gz" ]; then + print_warning "Installation completed, but $BINARY_NAME is not in PATH" + print_info "Make sure $INSTALL_DIR is in your PATH, or run: export PATH=\"$INSTALL_DIR:\$PATH\"" + else + print_warning "Package installed, but $BINARY_NAME may not be immediately available" + print_info "Try opening a new terminal or running: hash -r" + fi + fi +} + +# Show help +show_help() { + cat << EOF +TigrisFS Installation Script + +USAGE: + $0 [OPTIONS] + +OPTIONS: + -h, --help Show this help message + --install-dir DIR Installation directory (default: /usr/local/bin) + --force-tarball Force tarball installation instead of system packages + --package-type TYPE Force specific package type (tar.gz, deb, rpm, apk) + +ENVIRONMENT VARIABLES: + INSTALL_DIR Installation directory (default: /usr/local/bin) + FORCE_TARBALL Set to 1 to force tarball installation + +EXAMPLES: + # Install using system package manager (requires sudo) + sudo $0 + + # Install tarball to default location + $0 --force-tarball + + # Install to custom directory using tarball + $0 --force-tarball --install-dir /usr/bin + + # Install to user directory + INSTALL_DIR=~/.local/bin $0 --force-tarball + + # Force specific package type + sudo $0 --package-type deb + +PACKAGE TYPES: + - System packages (deb, rpm, apk) install system-wide and require sudo + - Tarball (tar.gz) can install to user directories without sudo + - Script automatically detects the best package type for your system + +EOF +} + +# Parse command line arguments +while [[ $# -gt 0 ]]; do + case $1 in + -h|--help) + show_help + exit 0 + ;; + --install-dir) + INSTALL_DIR="$2" + shift 2 + ;; + --force-tarball) + FORCE_TARBALL=1 + shift + ;; + --package-type) + FORCE_PACKAGE_TYPE="$2" + case "$FORCE_PACKAGE_TYPE" in + tar.gz|deb|rpm|apk) + ;; + *) + print_error "Invalid package type: $FORCE_PACKAGE_TYPE" + print_info "Supported types: tar.gz, deb, rpm, apk" + exit 1 + ;; + esac + shift 2 + ;; + *) + print_error "Unknown option: $1" + show_help + exit 1 + ;; + esac +done + +# Run main function +main From ed10eca60ba15885f55bb19559ca1a83093c4b63 Mon Sep 17 00:00:00 2001 From: Yevgeniy Firsov Date: Thu, 29 May 2025 10:05:26 -0700 Subject: [PATCH 4/9] fix: MacOS support in install.sh --- install.sh | 247 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 246 insertions(+), 1 deletion(-) diff --git a/install.sh b/install.sh index 992f427..4210124 100644 --- a/install.sh +++ b/install.sh @@ -393,6 +393,245 @@ install_package() { print_success "Package installed successfully" } +# Function to check if macFUSE is installed +check_macfuse_installed() { + if [ -d "/Library/Frameworks/macFUSE.framework" ] || [ -d "/Library/Frameworks/OSXFUSE.framework" ]; then + return 0 # Already installed + fi + return 1 # Not installed +} + +# Function to get latest macFUSE version +get_macfuse_latest_version() { + local api_url="https://api.github.com/repos/osxfuse/osxfuse/releases/latest" + local temp_file + temp_file=$(mktemp) + + if download_file "$api_url" "$temp_file"; then + local version + version=$(jq -r '.tag_name' "$temp_file" 2>/dev/null) + rm -f "$temp_file" + echo "$version" + else + rm -f "$temp_file" + # Fallback to a known stable version + echo "macfuse-4.4.3" + fi +} + +# Function to install macFUSE +install_macfuse() { + print_info "Installing macFUSE (required dependency)..." + + # Check if already installed + if check_macfuse_installed; then + print_success "macFUSE is already installed" + return 0 + fi + + local temp_dir + temp_dir=$(mktemp -d) + + # Cleanup function for macFUSE installation + cleanup_macfuse() { + rm -rf "$temp_dir" + } + trap cleanup_macfuse EXIT + + # Get latest version + local version + version=$(get_macfuse_latest_version) + print_info "Installing macFUSE version: $version" + + # Download macFUSE + local macfuse_url="https://github.com/osxfuse/osxfuse/releases/download/$version/$version.dmg" + local dmg_file="$temp_dir/macfuse.dmg" + + print_info "Downloading macFUSE..." + if ! download_file "$macfuse_url" "$dmg_file"; then + print_error "Failed to download macFUSE" + return 1 + fi + + # Mount the DMG + print_info "Mounting macFUSE installer..." + local mount_point="/Volumes/macFUSE" + if ! hdiutil attach "$dmg_file" -quiet -mountpoint "$mount_point"; then + print_error "Failed to mount macFUSE DMG" + return 1 + fi + + # Find the installer package + local pkg_file + pkg_file=$(find "$mount_point" -name "*.pkg" | head -n1) + + if [ -z "$pkg_file" ]; then + print_error "macFUSE installer package not found" + hdiutil detach "$mount_point" -quiet + return 1 + fi + + # Install the package + print_info "Installing macFUSE package (requires admin password)..." + if sudo installer -pkg "$pkg_file" -target /; then + print_success "macFUSE installed successfully" + + # Unmount the DMG + hdiutil detach "$mount_point" -quiet + + # Check if installation was successful + if check_macfuse_installed; then + print_success "macFUSE installation verified" + return 0 + else + print_warning "macFUSE installation may require a reboot to complete" + return 0 + fi + else + print_error "Failed to install macFUSE package" + hdiutil detach "$mount_point" -quiet + return 1 + fi +} + +# Alternative: Install via Homebrew if available +install_macfuse_via_homebrew() { + if ! command_exists brew; then + return 1 # Homebrew not available + fi + + print_info "Installing macFUSE via Homebrew..." + + # Add the cask tap if not already added + if ! brew tap | grep -q "homebrew/cask"; then + brew tap homebrew/cask + fi + + # Install macFUSE + if brew install --cask macfuse; then + print_success "macFUSE installed via Homebrew" + return 0 + else + print_warning "Failed to install macFUSE via Homebrew" + return 1 + fi +} + +# Function to handle macFUSE installation with multiple methods +ensure_macfuse_installed() { + # Skip if not on macOS +# if [ "$(detect_os)" != "darwin" ]; then +# return 0 +# fi + + # Check if already installed + if check_macfuse_installed; then + print_info "macFUSE is already installed" + return 0 + fi + + print_info "macFUSE is required for TigrisFS on macOS" + + # Ask user for installation preference + if [ -z "$SKIP_MACFUSE" ]; then + echo -n "Install macFUSE now? [Y/n]: " + read -r install_choice + + case "${install_choice,,}" in + n|no) + print_warning "Skipping macFUSE installation" + print_info "Note: TigrisFS may not work without macFUSE" + return 0 + ;; + esac + fi + + # Try Homebrew first if available (cleaner installation) + if install_macfuse_via_homebrew; then + return 0 + fi + + # Fall back to direct installation + print_info "Homebrew not available or failed, using direct installation..." + if install_macfuse; then + return 0 + fi + + print_error "Failed to install macFUSE" + print_info "Please install macFUSE manually from: https://osxfuse.github.io/" + + # Ask if user wants to continue without macFUSE + echo -n "Continue TigrisFS installation anyway? [y/N]: " + read -r continue_choice + + case "${continue_choice,,}" in + y|yes) + print_warning "Continuing without macFUSE - TigrisFS may not function properly" + return 0 + ;; + *) + print_info "Installation aborted" + exit 1 + ;; + esac +} + +# Function to check macOS version compatibility +check_macos_compatibility() { + local macos_version + macos_version=$(sw_vers -productVersion) + local major_version + major_version=$(echo "$macos_version" | cut -d. -f1) + + # macFUSE requires macOS 10.9 or later + if [ "$major_version" -lt 10 ]; then + print_error "macOS version $macos_version is too old for macFUSE" + return 1 + fi + + # Check for specific version requirements + case "$major_version" in + 10) + local minor_version + minor_version=$(echo "$macos_version" | cut -d. -f2) + if [ "$minor_version" -lt 9 ]; then + print_error "macOS 10.9 or later is required for macFUSE" + return 1 + fi + ;; + esac + + return 0 +} + +# Integration into main installation flow +install_macos_dependencies() { + if [ "$(detect_os)" != "darwin" ]; then + return 0 # Not macOS, skip + fi + + print_info "Checking macOS dependencies..." + + # Check macOS compatibility + if ! check_macos_compatibility; then + exit 1 + fi + + # Install macFUSE + ensure_macfuse_installed + + # Check for other macOS-specific requirements + if ! command_exists pkgutil; then + print_warning "pkgutil not found - some features may not work" + fi + + # Inform about security settings + if check_macfuse_installed; then + print_info "Note: You may need to allow macFUSE in System Preferences > Security & Privacy" + print_info "after the first run if prompted by macOS" + fi +} + # Main installation function main() { print_info "TigrisFS Installation Script" @@ -409,6 +648,8 @@ main() { # Check dependencies check_dependencies "$package_type" + # Install macOS dependencies (including macFUSE) + install_macos_dependencies # Get latest release info local release_file @@ -541,7 +782,7 @@ main() { # Show version if possible if "$BINARY_NAME" --version >/dev/null 2>&1; then local version - version=$("$BINARY_NAME" --version 2>&1| head -n1 | cut -d ' ' -f 3) + version=$("$BINARY_NAME" --version 2>&1| head -n1 | cut -d ' ' -f 3) print_info "Installed version: $version" fi else @@ -625,6 +866,10 @@ while [[ $# -gt 0 ]]; do esac shift 2 ;; + --skip-macfuse) + SKIP_MACFUSE=1 + shift + ;; *) print_error "Unknown option: $1" show_help From 76a33f1c8a909f5664fc81f78d41909bc69913d9 Mon Sep 17 00:00:00 2001 From: Yevgeniy Firsov Date: Sun, 29 Jun 2025 17:29:28 -0700 Subject: [PATCH 5/9] chore: Document install.sh --- README.md | 14 ++++++++++---- install.sh | 20 +++++++++++++++----- 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index ebef3fd..fb6f9f1 100644 --- a/README.md +++ b/README.md @@ -34,17 +34,23 @@ When mounted with the [Tigris](https://www.tigrisdata.com) backend TigrisFS supp # Installation +## Recommended: One-line install + +```bash +curl -sSL https://raw.githubusercontent.com/tigrisdata/tigrisfs/refs/heads/main/install.sh | bash +``` + ## Prebuilt DEB and RPM packages -* Download the latest release: [DEB](https://github.com/tigrisdata/tigrisfs/releases/download/v1.2.0/tigrisfs_1.2.0_linux_amd64.deb), [RPM](https://github.com/tigrisdata/tigrisfs/releases/download/v1.2.0/tigrisfs_1.2.0_linux_amd64.rpm). +* Download the latest release: [DEB](https://github.com/tigrisdata/tigrisfs/releases/download/v1.2.1/tigrisfs_1.2.1_linux_amd64.deb), [RPM](https://github.com/tigrisdata/tigrisfs/releases/download/v1.2.1/tigrisfs_1.2.1_linux_amd64.rpm). * Install the package: * Debian-based systems: ```bash - dpkg -i tigrisfs_1.2.0_linux_amd64.deb + dpkg -i tigrisfs_1.2.1_linux_amd64.deb ``` * RPM-based systems: ```bash - rpm -i tigrisfs_1.2.0_linux_amd64.rpm + rpm -i tigrisfs_1.2.1_linux_amd64.rpm ``` * Configure credentials TigrisFS can use credentials from different sources: @@ -71,7 +77,7 @@ See [docs](https://www.tigrisdata.com/docs/sdks/s3/aws-cli/) for more details. * Download and unpack the latest release: * MacOS ARM64 ``` - curl -L https://github.com/tigrisdata/tigrisfs/releases/download/v1.2.0/tigrisfs_1.2.0_darwin_arm64.tar.gz | sudo tar -xz -C /usr/local/bin + curl -L https://github.com/tigrisdata/tigrisfs/releases/download/v1.2.1/tigrisfs_1.2.1_darwin_arm64.tar.gz | sudo tar -xz -C /usr/local/bin ``` * Configuration is the same as for the DEB and RPM packages above. * Mount the bucket: diff --git a/install.sh b/install.sh index 4210124..c9ca4f3 100644 --- a/install.sh +++ b/install.sh @@ -520,9 +520,9 @@ install_macfuse_via_homebrew() { # Function to handle macFUSE installation with multiple methods ensure_macfuse_installed() { # Skip if not on macOS -# if [ "$(detect_os)" != "darwin" ]; then -# return 0 -# fi + if [ "$(detect_os)" != "darwin" ]; then + return 0 + fi # Check if already installed if check_macfuse_installed; then @@ -777,14 +777,24 @@ main() { # Verify installation if command_exists "$BINARY_NAME"; then print_success "Installation completed successfully!" - print_info "Run '$BINARY_NAME --help' to get started" # Show version if possible if "$BINARY_NAME" --version >/dev/null 2>&1; then local version version=$("$BINARY_NAME" --version 2>&1| head -n1 | cut -d ' ' -f 3) - print_info "Installed version: $version" + print_info "Installed version: ${GREEN}$version${NC}" + fi + + if [ "$package_type" = "tar.gz" ]; then + print_info "Run '$BINARY_NAME --help' to get started" + else + print_info "Configure credentials in: + /etc/default/tigrisfs - global + /etc/default/tigrisfs- - per bucket" + print_info "Run 'systemctl --user start tigrisfs@' to mount the bucket + 'systemctl --user stop tigrisfs@' to unmount the bucket" fi + else if [ "$package_type" = "tar.gz" ]; then print_warning "Installation completed, but $BINARY_NAME is not in PATH" From 4e485557f243f0a43c594c844a3ba0632723c6f9 Mon Sep 17 00:00:00 2001 From: Ovais Tariq Date: Wed, 16 Jul 2025 18:47:53 -0400 Subject: [PATCH 6/9] refactor: Replace deprecated semaphore with golang.org/x/sync/semaphore (#33) --- core/goofys_common_test.go | 31 +++++++++++++++++-------------- core/utils.go | 34 +++++++++++++++++++++++----------- 2 files changed, 40 insertions(+), 25 deletions(-) diff --git a/core/goofys_common_test.go b/core/goofys_common_test.go index 96a0313..08ac375 100644 --- a/core/goofys_common_test.go +++ b/core/goofys_common_test.go @@ -129,13 +129,12 @@ func waitFor(t *C, addr string) (err error) { func (t *GoofysTest) deleteBlobsParallelly(cloud StorageBackend, blobs []string) error { const concurrency = 10 - sem := make(semaphore, concurrency) - sem.P(concurrency) + sem := NewSemaphore(concurrency) var err error for _, blobOuter := range blobs { - sem.V(1) + sem.P(1) // Acquire slot go func(blob string) { - defer sem.P(1) + defer sem.V(1) // Release slot _, localerr := cloud.DeleteBlob(&DeleteBlobInput{blob}) if localerr != nil && localerr != syscall.ENOENT { err = localerr @@ -145,7 +144,9 @@ func (t *GoofysTest) deleteBlobsParallelly(cloud StorageBackend, blobs []string) break } } - sem.V(concurrency) + // Wait for all goroutines to complete + sem.P(concurrency) + sem.V(concurrency) // Release them back return err } @@ -373,12 +374,11 @@ func (s *GoofysTest) removeBlob(cloud StorageBackend, t *C, blobPath string) { func (s *GoofysTest) setupBlobs(cloud StorageBackend, t *C, env map[string]*string) { const concurrency = 10 - throttler := make(semaphore, concurrency) - throttler.P(concurrency) + throttler := NewSemaphore(concurrency) var globalErr atomic.Value for path, c := range env { - throttler.V(1) + throttler.P(1) // Acquire slot before spawning goroutine go func(path string, content *string) { dir := false if content == nil { @@ -392,7 +392,7 @@ func (s *GoofysTest) setupBlobs(cloud StorageBackend, t *C, env map[string]*stri content = &path } } - defer throttler.P(1) + defer throttler.V(1) // Release slot when goroutine completes params := &PutBlobInput{ Key: path, Body: bytes.NewReader([]byte(*content)), @@ -410,9 +410,10 @@ func (s *GoofysTest) setupBlobs(cloud StorageBackend, t *C, env map[string]*stri t.Assert(err, IsNil) }(path, c) } - throttler.V(concurrency) - throttler = make(semaphore, concurrency) + // Wait for all goroutines to complete by acquiring all slots throttler.P(concurrency) + // Release them back + throttler.V(concurrency) t.Assert(globalErr.Load(), IsNil) // double check, except on AWS S3, because there we sometimes @@ -420,9 +421,9 @@ func (s *GoofysTest) setupBlobs(cloud StorageBackend, t *C, env map[string]*stri // from 404 KeyNotFound if !hasEnv("AWS") { for path, c := range env { - throttler.V(1) + throttler.P(1) // Acquire slot go func(path string, content *string) { - defer throttler.P(1) + defer throttler.V(1) // Release slot params := &HeadBlobInput{Key: path} res, err := cloud.HeadBlob(params) if err != nil { @@ -442,7 +443,9 @@ func (s *GoofysTest) setupBlobs(cloud StorageBackend, t *C, env map[string]*stri } }(path, c) } - throttler.V(concurrency) + // Wait for all goroutines to complete + throttler.P(concurrency) + throttler.V(concurrency) // Release them back t.Assert(globalErr.Load(), IsNil) } } diff --git a/core/utils.go b/core/utils.go index f1ad25d..7c67441 100644 --- a/core/utils.go +++ b/core/utils.go @@ -16,10 +16,13 @@ package core import ( + "context" "fmt" "strings" "time" "unicode" + + "golang.org/x/sync/semaphore" ) var TIME_MAX = time.Unix(1<<63-62135596801, 999999999) @@ -170,20 +173,29 @@ func Dup(value []byte) []byte { return ret } -type empty struct{} - -// TODO(dotslash/khc): Remove this semaphore in favor of -// https://godoc.org/golang.org/x/sync/semaphore -type semaphore chan empty +// Semaphore is a counting semaphore implementation using golang.org/x/sync/semaphore. +// It provides P (wait/acquire) and V (signal/release) operations. +type Semaphore struct { + sem *semaphore.Weighted +} -func (sem semaphore) P(n int) { - for i := 0; i < n; i++ { - sem <- empty{} +// NewSemaphore creates a new semaphore with the given initial count. +// The count represents the number of resources available. +func NewSemaphore(n int) *Semaphore { + return &Semaphore{ + sem: semaphore.NewWeighted(int64(n)), } } -func (sem semaphore) V(n int) { - for i := 0; i < n; i++ { - <-sem +// P (proberen/wait) acquires n resources from the semaphore, blocking until they are available. +// It panics if the context is canceled. +func (s *Semaphore) P(n int) { + if err := s.sem.Acquire(context.Background(), int64(n)); err != nil { + panic(err) } } + +// V (verhogen/signal) releases n resources back to the semaphore. +func (s *Semaphore) V(n int) { + s.sem.Release(int64(n)) +} From b6eba91f4a7b8d8e6695bfb374131cc40dc8d716 Mon Sep 17 00:00:00 2001 From: Ovais Tariq Date: Sat, 27 Sep 2025 10:02:14 -0700 Subject: [PATCH 7/9] fix: Resolve deadlock in directory operations using generation-based invalidation (#39) * fix: Resolve deadlock in directory operations using generation-based invalidation This fixes issue #37 where tigrisfs would deadlock during concurrent directory operations, particularly when listing directories. The deadlock occurred because: 1. listObjectsFlat() held dh.mu while calling sealDir() 2. sealDir() -> removeExpired() -> removeChildUnlocked() tried to lock ALL directory handles' mutexes 3. Other threads could hold different directory handle mutexes while waiting for dh.mu, creating a circular lock dependency The solution uses a generation counter approach: - Each directory maintains an atomic generation counter - The counter increments on structural changes (add/remove children) - Directory handles check the generation before operations - If generation changed, handles reset their position without locking * fix: Address race condition in generation handling after sealDir * fix: Initialize DirHandle generation correctly to prevent spurious invalidations --- core/cluster_fs.go | 1 + core/cluster_fs_fuse.go | 5 +++- core/dir.go | 55 +++++++++++++++++++++++++++++------------ 3 files changed, 44 insertions(+), 17 deletions(-) diff --git a/core/cluster_fs.go b/core/cluster_fs.go index 9a02034..b9ae7ce 100644 --- a/core/cluster_fs.go +++ b/core/cluster_fs.go @@ -430,6 +430,7 @@ func (fs *ClusterFs) readDir(handleId fuseops.HandleID, offset fuseops.DirOffset dh.lastExternalOffset = 0 dh.lastInternalOffset = 0 dh.lastName = "" + dh.generation = atomic.LoadUint64(&dh.inode.dir.generation) } for { diff --git a/core/cluster_fs_fuse.go b/core/cluster_fs_fuse.go index f1ea317..07ba55d 100644 --- a/core/cluster_fs_fuse.go +++ b/core/cluster_fs_fuse.go @@ -550,7 +550,10 @@ func (fs *ClusterFsFuse) OpenDir(ctx context.Context, op *fuseops.OpenDirOp) (er // 2nd phase fs.Goofys.mu.Lock() - dh := &DirHandle{inode: inode} + dh := &DirHandle{ + inode: inode, + generation: atomic.LoadUint64(&inode.dir.generation), + } fs.Goofys.dirHandles[fuseops.HandleID(resp.HandleId)] = dh fs.Goofys.mu.Unlock() diff --git a/core/dir.go b/core/dir.go index d2b67a9..3e5d13b 100644 --- a/core/dir.go +++ b/core/dir.go @@ -60,6 +60,7 @@ type DirInodeData struct { DeletedChildren map[string]*Inode Gaps []*SlurpGap handles []*DirHandle + generation uint64 // incremented on structural changes } // Returns the position of first char < '/' in `inp` after prefixLen + any continued '/' characters. @@ -87,11 +88,15 @@ type DirHandle struct { // or from the previous offset lastExternalOffset fuseops.DirOffset lastInternalOffset int + generation uint64 // tracks directory structure changes lastName string } func NewDirHandle(inode *Inode) (dh *DirHandle) { - dh = &DirHandle{inode: inode} + dh = &DirHandle{ + inode: inode, + generation: atomic.LoadUint64(&inode.dir.generation), + } return } @@ -385,7 +390,9 @@ func (dir *DirInodeData) checkGapLoaded(key string, newerThan time.Time) bool { return false } +// sealDir completes directory listing and cleans up expired entries // LOCKS_REQUIRED(inode.mu) +// LOCKS_EXCLUDED(dh.mu for all directory handles) func (inode *Inode) sealDir() { inode.dir.listMarker = "" inode.dir.listDone = true @@ -397,6 +404,10 @@ func (inode *Inode) sealDir() { } else { inode.Attributes.Mtime, inode.Attributes.Ctime = inode.findChildMaxTime() } + + // Increment generation to signal all handles need revalidation + atomic.AddUint64(&inode.dir.generation, 1) + inode.removeExpired("") } @@ -622,7 +633,14 @@ func (dh *DirHandle) listObjectsFlat() (start string, err error) { dh.inode.dir.listMarker = lastName } } else { + // We must release dh.mu before calling sealDir to avoid deadlock + dh.mu.Unlock() dh.inode.sealDir() + // Reload generation immediately after sealDir completes to get accurate state + currentGen := atomic.LoadUint64(&dh.inode.dir.generation) + dh.mu.Lock() + // Update our generation to match the new state + dh.generation = currentGen } dh.inode.mu.Unlock() @@ -633,6 +651,16 @@ func (dh *DirHandle) listObjectsFlat() (start string, err error) { // LOCKS_REQUIRED(dh.mu) // LOCKS_REQUIRED(dh.inode.mu) func (dh *DirHandle) checkDirPosition() { + // Check if directory structure changed since we last checked + // Note: There's a benign race here where generation could change between + // the load and assignment. This is acceptable as we'll catch it on the + // next operation. The worst case is an unnecessary position reset. + currentGen := atomic.LoadUint64(&dh.inode.dir.generation) + if dh.generation != currentGen { + dh.lastInternalOffset = -1 + dh.generation = currentGen + } + if dh.lastInternalOffset < 0 { parent := dh.inode // Directory position invalidated, try to find it again using lastName @@ -752,6 +780,7 @@ func (dh *DirHandle) Seek(newOffset fuseops.DirOffset) { dh.lastExternalOffset = 0 dh.lastInternalOffset = 0 dh.lastName = "" + dh.generation = atomic.LoadUint64(&dh.inode.dir.generation) } } @@ -996,6 +1025,10 @@ func (parent *Inode) removeChildUnlocked(inode *Inode) { if l == 0 { return } + + // Increment generation to invalidate all directory handles + atomic.AddUint64(&parent.dir.generation, 1) + i := sort.Search(l, parent.findInodeFunc(inode.Name)) if i >= l || parent.dir.Children[i].Name != inode.Name { panic(fmt.Sprintf("%v.removeName(%v) but child not found: %v", @@ -1004,11 +1037,7 @@ func (parent *Inode) removeChildUnlocked(inode *Inode) { // POSIX allows parallel readdir() and modifications, // so preserve position of all directory handles - for _, dh := range parent.dir.handles { - dh.mu.Lock() - dh.lastInternalOffset = -1 - dh.mu.Unlock() - } + // Handles will detect the generation change and reset themselves // >= because we use the "last open dir" as the "next" one if parent.dir.lastOpenDirIdx >= i { parent.dir.lastOpenDirIdx-- @@ -1038,11 +1067,8 @@ func (parent *Inode) removeAllChildrenUnlocked() { child.DeRef(1) child.mu.Unlock() } - // POSIX allows parallel readdir() and modifications, - // so reset position of all directory handles - for _, dh := range parent.dir.handles { - dh.lastInternalOffset = -1 - } + // Increment generation to invalidate all directory handles + atomic.AddUint64(&parent.dir.generation, 1) parent.dir.Children = nil } @@ -1091,11 +1117,8 @@ func (parent *Inode) insertChildUnlocked(inode *Inode) { panic(fmt.Sprintf("double insert of %v", parent.getChildName(inode.Name))) } - // POSIX allows parallel readdir() and modifications, - // so preserve position of all directory handles - for _, dh := range parent.dir.handles { - dh.lastInternalOffset = -1 - } + // Increment generation to invalidate all directory handles + atomic.AddUint64(&parent.dir.generation, 1) if parent.dir.lastOpenDirIdx >= i { parent.dir.lastOpenDirIdx++ } From 97e8dc91e05985a55c7b953e51c122aa0f9aecdc Mon Sep 17 00:00:00 2001 From: Ovais Tariq Date: Mon, 29 Sep 2025 11:24:39 -0700 Subject: [PATCH 8/9] fix: Fix flaky TestNotifyRefreshSubfile with recheckInodeByName (#42) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This fixes the flaky test by addressing the root cause: stale inode references causing removeChild to fail due to pointer mismatch. The issue occurs when RefreshInodeCache receives an inode from fs.inodes that's different from the instance in parent.dir.Children. The solution introduces recheckInodeByName which: 1. First finds the current child by name from parent's children list 2. Uses that instance for removal if the file doesn't exist 3. Ensures we always work with the most up-to-date inode reference This approach: - Preserves all safety guarantees (pointer equality checks remain) - Fixes the mutex and ID mismatch issues identified in review - Doesn't reassign variables or violate locking rules - Is clear about intent: we're rechecking the current state by name 🤖 Generated with [Claude Code](https://claude.ai/code) Co-authored-by: Claude --- core/dir.go | 21 +++++++++++++++++++++ core/goofys.go | 5 ++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/core/dir.go b/core/dir.go index 3e5d13b..c6d353e 100644 --- a/core/dir.go +++ b/core/dir.go @@ -1994,6 +1994,27 @@ func (parent *Inode) recheckInode(inode *Inode, name string) (newInode *Inode, e return newInode, nil } +// recheckInodeByName is similar to recheckInode but finds the current child by name +// first to ensure we're working with the most up-to-date inode instance. +// This avoids issues with stale inode references that might not match what's +// currently in the parent's children list. +func (parent *Inode) recheckInodeByName(name string) (newInode *Inode, err error) { + // First get the current child if it exists + parent.mu.Lock() + currentChild := parent.findChildUnlocked(name) + parent.mu.Unlock() + + newInode, err = parent.LookUp(name, currentChild == nil && !parent.fs.flags.NoPreloadDir) + if err != nil { + if currentChild != nil { + // Remove the actual current child from parent's children list + parent.removeChild(currentChild) + } + return nil, err + } + return newInode, nil +} + func (parent *Inode) LookUp(name string, doSlurp bool) (*Inode, error) { _, parentKey := parent.cloud() key := appendChildName(parentKey, name) diff --git a/core/goofys.go b/core/goofys.go index b8ee6b6..310175d 100644 --- a/core/goofys.go +++ b/core/goofys.go @@ -958,7 +958,10 @@ func (fs *Goofys) RefreshInodeCache(inode *Inode) error { } return mappedErr } - _, err := parent.recheckInode(inode, name) + // Use recheckInodeByName to ensure we work with the current child instance + // This handles cases where the inode passed to RefreshInodeCache might be + // a stale reference from fs.inodes while parent.dir.Children has a newer instance + _, err := parent.recheckInodeByName(name) mappedErr = mapAwsError(err) if mappedErr == syscall.ENOENT { notifications = append(notifications, &fuseops.NotifyDelete{ From d3e466141a3154b8199f8d6d32a7759d66605331 Mon Sep 17 00:00:00 2001 From: Ovais Tariq Date: Mon, 29 Sep 2025 14:42:54 -0700 Subject: [PATCH 9/9] fix: Prevent deadlock in loadListing unlock/relock pattern (#43) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: Prevent deadlock in loadListing unlock/relock pattern This fixes potential deadlocks in the loadListing() function where unlock/relock patterns could cause circular lock dependencies. The deadlock could occur in two places: 1. When calling slurpOnce() at line 718-725 2. When calling listObjectsFlat() at line 737-742 Both cases released parent.mu (and dh.mu in case 1) while other goroutines could be holding different locks and waiting, creating circular dependencies. The solution uses the generation counter approach from PR #39: - Check generation before unlocking - After relocking, detect if generation changed - If changed, reset directory handle position This ensures proper synchronization without risking deadlocks. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- core/dir.go | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/core/dir.go b/core/dir.go index c6d353e..e660650 100644 --- a/core/dir.go +++ b/core/dir.go @@ -716,11 +716,21 @@ func (dh *DirHandle) loadListing() error { // token if useSlurp { + // We must release both locks before calling slurpOnce to avoid deadlock + // Store current generation before unlocking + currentGen := atomic.LoadUint64(&parent.dir.generation) parent.mu.Unlock() dh.mu.Unlock() done, err := parent.slurpOnce(true) dh.mu.Lock() parent.mu.Lock() + // Check if generation changed while we were unlocked + newGen := atomic.LoadUint64(&parent.dir.generation) + if newGen != currentGen { + // Directory structure changed, reset our position + dh.generation = newGen + dh.lastInternalOffset = -1 + } if err != nil { return err } @@ -734,12 +744,24 @@ func (dh *DirHandle) loadListing() error { loaded, startMarker := false, "" for parent.dir.lastFromCloud == nil && !parent.dir.listDone { + // We must release parent.mu before calling listObjectsFlat to avoid deadlock + // Store current generation before unlocking + currentGen := atomic.LoadUint64(&parent.dir.generation) parent.mu.Unlock() start, err := dh.listObjectsFlat() if !loaded { loaded, startMarker = true, start } parent.mu.Lock() + // Check if generation changed while we were unlocked + newGen := atomic.LoadUint64(&parent.dir.generation) + if newGen != currentGen { + // Directory structure changed, reset our position and invalidate startMarker + dh.generation = newGen + dh.lastInternalOffset = -1 + // Clear startMarker to prevent removeExpired from operating on stale range + startMarker = "" + } if err != nil { return err }