factorix 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +20 -0
- data/LICENSE.txt +21 -0
- data/README.md +105 -0
- data/completion/_factorix.bash +202 -0
- data/completion/_factorix.fish +197 -0
- data/completion/_factorix.zsh +376 -0
- data/doc/factorix.1 +377 -0
- data/exe/factorix +20 -0
- data/lib/factorix/api/category.rb +69 -0
- data/lib/factorix/api/image.rb +35 -0
- data/lib/factorix/api/license.rb +71 -0
- data/lib/factorix/api/mod_download_api.rb +66 -0
- data/lib/factorix/api/mod_info.rb +166 -0
- data/lib/factorix/api/mod_management_api.rb +237 -0
- data/lib/factorix/api/mod_portal_api.rb +204 -0
- data/lib/factorix/api/release.rb +49 -0
- data/lib/factorix/api/tag.rb +95 -0
- data/lib/factorix/api.rb +7 -0
- data/lib/factorix/api_credential.rb +54 -0
- data/lib/factorix/application.rb +218 -0
- data/lib/factorix/cache/file_system.rb +307 -0
- data/lib/factorix/cli/commands/backup_support.rb +46 -0
- data/lib/factorix/cli/commands/base.rb +90 -0
- data/lib/factorix/cli/commands/cache/evict.rb +180 -0
- data/lib/factorix/cli/commands/cache/stat.rb +201 -0
- data/lib/factorix/cli/commands/command_wrapper.rb +71 -0
- data/lib/factorix/cli/commands/completion.rb +83 -0
- data/lib/factorix/cli/commands/confirmable.rb +53 -0
- data/lib/factorix/cli/commands/download_support.rb +123 -0
- data/lib/factorix/cli/commands/launch.rb +79 -0
- data/lib/factorix/cli/commands/man.rb +29 -0
- data/lib/factorix/cli/commands/mod/check.rb +99 -0
- data/lib/factorix/cli/commands/mod/disable.rb +188 -0
- data/lib/factorix/cli/commands/mod/download.rb +291 -0
- data/lib/factorix/cli/commands/mod/edit.rb +114 -0
- data/lib/factorix/cli/commands/mod/enable.rb +216 -0
- data/lib/factorix/cli/commands/mod/image/add.rb +47 -0
- data/lib/factorix/cli/commands/mod/image/edit.rb +41 -0
- data/lib/factorix/cli/commands/mod/image/list.rb +74 -0
- data/lib/factorix/cli/commands/mod/install.rb +443 -0
- data/lib/factorix/cli/commands/mod/list.rb +372 -0
- data/lib/factorix/cli/commands/mod/search.rb +134 -0
- data/lib/factorix/cli/commands/mod/settings/dump.rb +88 -0
- data/lib/factorix/cli/commands/mod/settings/restore.rb +101 -0
- data/lib/factorix/cli/commands/mod/show.rb +202 -0
- data/lib/factorix/cli/commands/mod/sync.rb +299 -0
- data/lib/factorix/cli/commands/mod/uninstall.rb +325 -0
- data/lib/factorix/cli/commands/mod/update.rb +222 -0
- data/lib/factorix/cli/commands/mod/upload.rb +90 -0
- data/lib/factorix/cli/commands/path.rb +79 -0
- data/lib/factorix/cli/commands/requires_game_stopped.rb +32 -0
- data/lib/factorix/cli/commands/version.rb +25 -0
- data/lib/factorix/cli.rb +42 -0
- data/lib/factorix/dependency/edge.rb +89 -0
- data/lib/factorix/dependency/entry.rb +124 -0
- data/lib/factorix/dependency/graph/builder.rb +108 -0
- data/lib/factorix/dependency/graph.rb +210 -0
- data/lib/factorix/dependency/list.rb +244 -0
- data/lib/factorix/dependency/mod_version_requirement.rb +73 -0
- data/lib/factorix/dependency/node.rb +60 -0
- data/lib/factorix/dependency/parser.rb +148 -0
- data/lib/factorix/dependency/validation_result.rb +138 -0
- data/lib/factorix/dependency/validator.rb +190 -0
- data/lib/factorix/errors.rb +112 -0
- data/lib/factorix/formatting.rb +56 -0
- data/lib/factorix/game_version.rb +98 -0
- data/lib/factorix/http/cache_decorator.rb +106 -0
- data/lib/factorix/http/cached_response.rb +37 -0
- data/lib/factorix/http/client.rb +187 -0
- data/lib/factorix/http/response.rb +31 -0
- data/lib/factorix/http/retry_decorator.rb +59 -0
- data/lib/factorix/http/retry_strategy.rb +80 -0
- data/lib/factorix/info_json.rb +90 -0
- data/lib/factorix/installed_mod.rb +239 -0
- data/lib/factorix/mod.rb +55 -0
- data/lib/factorix/mod_list.rb +174 -0
- data/lib/factorix/mod_settings.rb +278 -0
- data/lib/factorix/mod_state.rb +34 -0
- data/lib/factorix/mod_version.rb +99 -0
- data/lib/factorix/portal.rb +185 -0
- data/lib/factorix/progress/download_handler.rb +46 -0
- data/lib/factorix/progress/multi_presenter.rb +45 -0
- data/lib/factorix/progress/presenter.rb +67 -0
- data/lib/factorix/progress/presenter_adapter.rb +46 -0
- data/lib/factorix/progress/scan_handler.rb +33 -0
- data/lib/factorix/progress/upload_handler.rb +33 -0
- data/lib/factorix/runtime/base.rb +233 -0
- data/lib/factorix/runtime/linux.rb +32 -0
- data/lib/factorix/runtime/mac_os.rb +53 -0
- data/lib/factorix/runtime/user_configurable.rb +69 -0
- data/lib/factorix/runtime/windows.rb +85 -0
- data/lib/factorix/runtime/wsl.rb +118 -0
- data/lib/factorix/runtime.rb +32 -0
- data/lib/factorix/save_file.rb +178 -0
- data/lib/factorix/ser_des/deserializer.rb +198 -0
- data/lib/factorix/ser_des/serializer.rb +231 -0
- data/lib/factorix/ser_des/signed_integer.rb +63 -0
- data/lib/factorix/ser_des/unsigned_integer.rb +65 -0
- data/lib/factorix/service_credential.rb +127 -0
- data/lib/factorix/transfer/downloader.rb +162 -0
- data/lib/factorix/transfer/uploader.rb +232 -0
- data/lib/factorix/version.rb +6 -0
- data/lib/factorix.rb +38 -0
- data/sig/dry/auto_inject.rbs +15 -0
- data/sig/dry/cli.rbs +19 -0
- data/sig/dry/configurable.rbs +13 -0
- data/sig/dry/core/container.rbs +17 -0
- data/sig/dry/events/publisher.rbs +22 -0
- data/sig/dry/logger.rbs +16 -0
- data/sig/factorix/api/category.rbs +15 -0
- data/sig/factorix/api/image.rbs +15 -0
- data/sig/factorix/api/license.rbs +20 -0
- data/sig/factorix/api/mod_download_api.rbs +18 -0
- data/sig/factorix/api/mod_info.rbs +67 -0
- data/sig/factorix/api/mod_management_api.rbs +25 -0
- data/sig/factorix/api/mod_portal_api.rbs +31 -0
- data/sig/factorix/api/release.rbs +27 -0
- data/sig/factorix/api/tag.rbs +15 -0
- data/sig/factorix/api.rbs +8 -0
- data/sig/factorix/api_credential.rbs +17 -0
- data/sig/factorix/application.rbs +86 -0
- data/sig/factorix/cache/file_system.rbs +35 -0
- data/sig/factorix/cli/commands/base.rbs +13 -0
- data/sig/factorix/cli/commands/cache/evict.rbs +17 -0
- data/sig/factorix/cli/commands/cache/stat.rbs +17 -0
- data/sig/factorix/cli/commands/command_wrapper.rbs +13 -0
- data/sig/factorix/cli/commands/completion/zsh.rbs +15 -0
- data/sig/factorix/cli/commands/confirmable.rbs +12 -0
- data/sig/factorix/cli/commands/download_support.rbs +12 -0
- data/sig/factorix/cli/commands/launch.rbs +15 -0
- data/sig/factorix/cli/commands/mod/check.rbs +18 -0
- data/sig/factorix/cli/commands/mod/disable.rbs +20 -0
- data/sig/factorix/cli/commands/mod/download.rbs +18 -0
- data/sig/factorix/cli/commands/mod/edit.rbs +30 -0
- data/sig/factorix/cli/commands/mod/enable.rbs +20 -0
- data/sig/factorix/cli/commands/mod/image/add.rbs +19 -0
- data/sig/factorix/cli/commands/mod/image/edit.rbs +19 -0
- data/sig/factorix/cli/commands/mod/image/list.rbs +19 -0
- data/sig/factorix/cli/commands/mod/install.rbs +19 -0
- data/sig/factorix/cli/commands/mod/list.rbs +30 -0
- data/sig/factorix/cli/commands/mod/search.rbs +18 -0
- data/sig/factorix/cli/commands/mod/settings/dump.rbs +17 -0
- data/sig/factorix/cli/commands/mod/settings/restore.rbs +17 -0
- data/sig/factorix/cli/commands/mod/sync.rbs +19 -0
- data/sig/factorix/cli/commands/mod/uninstall.rbs +20 -0
- data/sig/factorix/cli/commands/mod/update.rbs +19 -0
- data/sig/factorix/cli/commands/mod/upload.rbs +24 -0
- data/sig/factorix/cli/commands/path.rbs +18 -0
- data/sig/factorix/cli/commands/requires_game_stopped.rbs +13 -0
- data/sig/factorix/cli/commands/version.rbs +13 -0
- data/sig/factorix/cli.rbs +11 -0
- data/sig/factorix/dependency/edge.rbs +32 -0
- data/sig/factorix/dependency/entry.rbs +30 -0
- data/sig/factorix/dependency/graph/builder.rbs +17 -0
- data/sig/factorix/dependency/graph.rbs +39 -0
- data/sig/factorix/dependency/list.rbs +69 -0
- data/sig/factorix/dependency/mod_version_requirement.rbs +18 -0
- data/sig/factorix/dependency/node.rbs +24 -0
- data/sig/factorix/dependency/parser.rbs +11 -0
- data/sig/factorix/dependency/validation_result.rbs +56 -0
- data/sig/factorix/dependency/validator.rbs +13 -0
- data/sig/factorix/errors.rbs +132 -0
- data/sig/factorix/formatting.rbs +8 -0
- data/sig/factorix/game_version.rbs +24 -0
- data/sig/factorix/http/cache_decorator.rbs +64 -0
- data/sig/factorix/http/client.rbs +55 -0
- data/sig/factorix/http/response.rbs +28 -0
- data/sig/factorix/http/retry_decorator.rbs +44 -0
- data/sig/factorix/http/retry_strategy.rbs +42 -0
- data/sig/factorix/info_json.rbs +19 -0
- data/sig/factorix/installed_mod.rbs +34 -0
- data/sig/factorix/mod.rbs +20 -0
- data/sig/factorix/mod_list.rbs +44 -0
- data/sig/factorix/mod_settings.rbs +47 -0
- data/sig/factorix/mod_state.rbs +18 -0
- data/sig/factorix/mod_version.rbs +23 -0
- data/sig/factorix/portal.rbs +37 -0
- data/sig/factorix/progress/download_handler.rbs +19 -0
- data/sig/factorix/progress/multi_presenter.rbs +15 -0
- data/sig/factorix/progress/presenter.rbs +17 -0
- data/sig/factorix/progress/presenter_adapter.rbs +17 -0
- data/sig/factorix/progress/scan_handler.rbs +16 -0
- data/sig/factorix/progress/upload_handler.rbs +17 -0
- data/sig/factorix/runtime/base.rbs +45 -0
- data/sig/factorix/runtime/linux.rbs +15 -0
- data/sig/factorix/runtime/mac_os.rbs +15 -0
- data/sig/factorix/runtime/user_configurable.rbs +13 -0
- data/sig/factorix/runtime/windows.rbs +23 -0
- data/sig/factorix/runtime/wsl.rbs +19 -0
- data/sig/factorix/runtime.rbs +9 -0
- data/sig/factorix/save_file.rbs +40 -0
- data/sig/factorix/ser_des/deserializer.rbs +49 -0
- data/sig/factorix/ser_des/serializer.rbs +45 -0
- data/sig/factorix/ser_des/signed_integer.rbs +37 -0
- data/sig/factorix/ser_des/unsigned_integer.rbs +37 -0
- data/sig/factorix/service_credential.rbs +19 -0
- data/sig/factorix/transfer/downloader.rbs +15 -0
- data/sig/factorix/transfer/uploader.rbs +21 -0
- data/sig/factorix.rbs +9 -0
- data/sig/tty/progressbar.rbs +18 -0
- metadata +431 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Factorix
|
|
4
|
+
module API
|
|
5
|
+
Tag = Data.define(:value, :name, :description)
|
|
6
|
+
|
|
7
|
+
# Tag object from MOD Portal API
|
|
8
|
+
#
|
|
9
|
+
# Represents a MOD tag with value, name, and description.
|
|
10
|
+
# Uses flyweight pattern - same tag value returns same instance.
|
|
11
|
+
#
|
|
12
|
+
# @see https://wiki.factorio.com/Mod_portal_API#Tags
|
|
13
|
+
class Tag
|
|
14
|
+
# @!attribute [r] value
|
|
15
|
+
# @return [String] tag value (e.g., "transportation", "logistics")
|
|
16
|
+
# @!attribute [r] name
|
|
17
|
+
# @return [String] human-readable tag name (e.g., "Transportation", "Logistics")
|
|
18
|
+
# @!attribute [r] description
|
|
19
|
+
# @return [String] tag description
|
|
20
|
+
|
|
21
|
+
# Predefined tag instances
|
|
22
|
+
TRANSPORTATION = new(value: "transportation", name: "Transportation", description: "Transportation of the player, be it vehicles or teleporters.")
|
|
23
|
+
private_constant :TRANSPORTATION
|
|
24
|
+
LOGISTICS = new(value: "logistics", name: "Logistics", description: "Augmented or new ways of transporting materials - belts, inserters, pipes!")
|
|
25
|
+
private_constant :LOGISTICS
|
|
26
|
+
TRAINS = new(value: "trains", name: "Trains", description: "Trains are great, but what if they could do even more?")
|
|
27
|
+
private_constant :TRAINS
|
|
28
|
+
COMBAT = new(value: "combat", name: "Combat", description: "New ways to deal with enemies, be it attack or defense.")
|
|
29
|
+
private_constant :COMBAT
|
|
30
|
+
ARMOR = new(value: "armor", name: "Armor", description: "Armors or armor equipment.")
|
|
31
|
+
private_constant :ARMOR
|
|
32
|
+
ENEMIES = new(value: "enemies", name: "Enemies", description: "Changes to enemies or entirely new enemies to deal with.")
|
|
33
|
+
private_constant :ENEMIES
|
|
34
|
+
CHARACTER = new(value: "character", name: "Character", description: "Changes to the player's in-game appearance.")
|
|
35
|
+
private_constant :CHARACTER
|
|
36
|
+
ENVIRONMENT = new(value: "environment", name: "Environment", description: "Map generation and terrain modification.")
|
|
37
|
+
private_constant :ENVIRONMENT
|
|
38
|
+
PLANETS = new(value: "planets", name: "Planets", description: "New places to build more factories.")
|
|
39
|
+
private_constant :PLANETS
|
|
40
|
+
MINING = new(value: "mining", name: "Mining", description: "New ores and resources as well as machines.")
|
|
41
|
+
private_constant :MINING
|
|
42
|
+
FLUIDS = new(value: "fluids", name: "Fluids", description: "Things related to oil and other fluids.")
|
|
43
|
+
private_constant :FLUIDS
|
|
44
|
+
LOGISTIC_NETWORK = new(value: "logistic-network", name: "Logistics Network", description: "Related to roboports and logistic robots")
|
|
45
|
+
private_constant :LOGISTIC_NETWORK
|
|
46
|
+
CIRCUIT_NETWORK = new(value: "circuit-network", name: "Circuit network", description: "Entities which interact with the circuit network.")
|
|
47
|
+
private_constant :CIRCUIT_NETWORK
|
|
48
|
+
MANUFACTURING = new(value: "manufacturing", name: "Manufacture", description: "Furnaces, assembling machines, production chains")
|
|
49
|
+
private_constant :MANUFACTURING
|
|
50
|
+
POWER = new(value: "power", name: "Power Production", description: "Changes to power production and distribution.")
|
|
51
|
+
private_constant :POWER
|
|
52
|
+
STORAGE = new(value: "storage", name: "Storage", description: "More than just chests.")
|
|
53
|
+
private_constant :STORAGE
|
|
54
|
+
BLUEPRINTS = new(value: "blueprints", name: "Blueprints", description: "Change blueprint behavior.")
|
|
55
|
+
private_constant :BLUEPRINTS
|
|
56
|
+
CHEATS = new(value: "cheats", name: "Cheats", description: "Play it your way.")
|
|
57
|
+
private_constant :CHEATS
|
|
58
|
+
|
|
59
|
+
# Lookup table for flyweight pattern
|
|
60
|
+
TAGS = {
|
|
61
|
+
"transportation" => TRANSPORTATION,
|
|
62
|
+
"logistics" => LOGISTICS,
|
|
63
|
+
"trains" => TRAINS,
|
|
64
|
+
"combat" => COMBAT,
|
|
65
|
+
"armor" => ARMOR,
|
|
66
|
+
"enemies" => ENEMIES,
|
|
67
|
+
"character" => CHARACTER,
|
|
68
|
+
"environment" => ENVIRONMENT,
|
|
69
|
+
"planets" => PLANETS,
|
|
70
|
+
"mining" => MINING,
|
|
71
|
+
"fluids" => FLUIDS,
|
|
72
|
+
"logistic-network" => LOGISTIC_NETWORK,
|
|
73
|
+
"circuit-network" => CIRCUIT_NETWORK,
|
|
74
|
+
"manufacturing" => MANUFACTURING,
|
|
75
|
+
"power" => POWER,
|
|
76
|
+
"storage" => STORAGE,
|
|
77
|
+
"blueprints" => BLUEPRINTS,
|
|
78
|
+
"cheats" => CHEATS
|
|
79
|
+
}.freeze
|
|
80
|
+
private_constant :TAGS
|
|
81
|
+
|
|
82
|
+
# Get Tag instance for the given value
|
|
83
|
+
#
|
|
84
|
+
# Returns predefined instance for known tags (flyweight pattern).
|
|
85
|
+
# Raises an error for unknown tag values.
|
|
86
|
+
#
|
|
87
|
+
# @param value [String] tag value
|
|
88
|
+
# @return [Tag] Tag instance
|
|
89
|
+
# @raise [KeyError] if tag value is unknown
|
|
90
|
+
def self.for(value) = TAGS.fetch(value.to_s)
|
|
91
|
+
|
|
92
|
+
private_class_method :new, :[]
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
data/lib/factorix/api.rb
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Factorix
|
|
4
|
+
APICredential = Data.define(:api_key)
|
|
5
|
+
|
|
6
|
+
# API credentials for Factorio MOD Portal management
|
|
7
|
+
#
|
|
8
|
+
# @see https://wiki.factorio.com/Mod_upload_API
|
|
9
|
+
# @see https://wiki.factorio.com/Mod_publish_API
|
|
10
|
+
# @see https://wiki.factorio.com/Mod_details_API
|
|
11
|
+
# @see https://wiki.factorio.com/Mod_images_API
|
|
12
|
+
class APICredential
|
|
13
|
+
# @!attribute [r] api_key
|
|
14
|
+
# @return [String] API key for Factorio MOD Portal
|
|
15
|
+
|
|
16
|
+
# Environment variable name for API key
|
|
17
|
+
ENV_API_KEY = "FACTORIO_API_KEY"
|
|
18
|
+
private_constant :ENV_API_KEY
|
|
19
|
+
|
|
20
|
+
# Load API credentials from environment variables
|
|
21
|
+
#
|
|
22
|
+
# @return [APICredential] new instance with API key from environment
|
|
23
|
+
# @raise [CredentialError] if API key is not set in environment
|
|
24
|
+
def self.load
|
|
25
|
+
logger = Application["logger"]
|
|
26
|
+
logger.debug "Loading API credentials from environment"
|
|
27
|
+
|
|
28
|
+
api_key = ENV.fetch(ENV_API_KEY, nil)
|
|
29
|
+
if api_key.nil?
|
|
30
|
+
logger.error("Failed to load API credentials", reason: "#{ENV_API_KEY} not set")
|
|
31
|
+
raise CredentialError, "#{ENV_API_KEY} environment variable is not set"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
if api_key.empty?
|
|
35
|
+
logger.error("Failed to load API credentials", reason: "#{ENV_API_KEY} is empty")
|
|
36
|
+
raise CredentialError, "#{ENV_API_KEY} environment variable is empty"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
logger.info("API credentials loaded successfully")
|
|
40
|
+
new(api_key:)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private_class_method :new, :[]
|
|
44
|
+
|
|
45
|
+
# @return [String] string representation with masked API key
|
|
46
|
+
def inspect = %[#<#{self.class} api_key="*****">]
|
|
47
|
+
|
|
48
|
+
alias to_s inspect
|
|
49
|
+
|
|
50
|
+
# @param pp [PP] pretty printer
|
|
51
|
+
# @return [void]
|
|
52
|
+
def pretty_print(pp) = pp.text(inspect)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry/configurable"
|
|
4
|
+
require "dry/core"
|
|
5
|
+
require "dry/logger"
|
|
6
|
+
|
|
7
|
+
module Factorix
|
|
8
|
+
# Application container and configuration
|
|
9
|
+
#
|
|
10
|
+
# Provides dependency injection container and configuration management
|
|
11
|
+
# using dry-core's Container and dry-configurable.
|
|
12
|
+
#
|
|
13
|
+
# @example Configure the application
|
|
14
|
+
# Factorix::Application.configure do |config|
|
|
15
|
+
# config.log_level = :debug
|
|
16
|
+
# config.http.connect_timeout = 10
|
|
17
|
+
# end
|
|
18
|
+
#
|
|
19
|
+
# @example Resolve dependencies
|
|
20
|
+
# runtime = Factorix::Application[:runtime]
|
|
21
|
+
class Application
|
|
22
|
+
extend Dry::Core::Container::Mixin
|
|
23
|
+
extend Dry::Configurable
|
|
24
|
+
|
|
25
|
+
# Some items are registered with memoize: false to support independent event handlers
|
|
26
|
+
# for each parallel download task (e.g., progress tracking).
|
|
27
|
+
# Items registered with memoize: false:
|
|
28
|
+
# - :downloader (event handlers for progress tracking)
|
|
29
|
+
# - :mod_download_api (contains :downloader)
|
|
30
|
+
# - :portal (contains :mod_download_api)
|
|
31
|
+
|
|
32
|
+
# Register runtime detector
|
|
33
|
+
register(:runtime, memoize: true) do
|
|
34
|
+
Runtime.detect
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Register logger
|
|
38
|
+
register(:logger, memoize: true) do
|
|
39
|
+
runtime = resolve(:runtime)
|
|
40
|
+
log_path = runtime.factorix_log_path
|
|
41
|
+
|
|
42
|
+
# Ensure log directory exists
|
|
43
|
+
log_path.dirname.mkpath unless log_path.dirname.exist?
|
|
44
|
+
|
|
45
|
+
# Create logger with file backend
|
|
46
|
+
# Dispatcher level set to DEBUG to allow all messages through
|
|
47
|
+
# Backend controls filtering based on --log-level option
|
|
48
|
+
Dry.Logger(:factorix, level: :debug) do |dispatcher|
|
|
49
|
+
dispatcher.add_backend(level: config.log_level, stream: log_path.to_s, template: "[%<time>s] %<severity>s: %<message>s %<payload>s")
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Register retry strategy for network operations
|
|
54
|
+
register(:retry_strategy, memoize: true) do
|
|
55
|
+
HTTP::RetryStrategy.new
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Register download cache
|
|
59
|
+
register(:download_cache, memoize: true) do
|
|
60
|
+
c = config.cache.download
|
|
61
|
+
Cache::FileSystem.new(c.dir, **c.to_h.except(:dir))
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Register API cache (with compression for JSON responses)
|
|
65
|
+
register(:api_cache, memoize: true) do
|
|
66
|
+
c = config.cache.api
|
|
67
|
+
Cache::FileSystem.new(c.dir, **c.to_h.except(:dir))
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Register info.json cache (for MOD metadata from ZIP files)
|
|
71
|
+
register(:info_json_cache, memoize: true) do
|
|
72
|
+
c = config.cache.info_json
|
|
73
|
+
Cache::FileSystem.new(c.dir, **c.to_h.except(:dir))
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Register base HTTP client
|
|
77
|
+
register(:http_client, memoize: true) do
|
|
78
|
+
HTTP::Client.new(masked_params: %w[username token secure])
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Register decorated HTTP client for downloads (with retry only)
|
|
82
|
+
# Note: Caching is handled by Downloader, not at HTTP client level
|
|
83
|
+
register(:download_http_client, memoize: true) do
|
|
84
|
+
client = resolve(:http_client)
|
|
85
|
+
retry_strategy = resolve(:retry_strategy)
|
|
86
|
+
|
|
87
|
+
# Decorate: Client -> Retry (no cache, handled by Downloader)
|
|
88
|
+
HTTP::RetryDecorator.new(client:, retry_strategy:)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Register decorated HTTP client for API calls (with retry + cache)
|
|
92
|
+
register(:api_http_client, memoize: true) do
|
|
93
|
+
client = resolve(:http_client)
|
|
94
|
+
api_cache = resolve(:api_cache)
|
|
95
|
+
retry_strategy = resolve(:retry_strategy)
|
|
96
|
+
|
|
97
|
+
# Decorate: Client -> Cache -> Retry
|
|
98
|
+
cached = HTTP::CacheDecorator.new(client:, cache: api_cache)
|
|
99
|
+
HTTP::RetryDecorator.new(client: cached, retry_strategy:)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Register decorated HTTP client for uploads (with retry only, no cache)
|
|
103
|
+
register(:upload_http_client, memoize: true) do
|
|
104
|
+
client = resolve(:http_client)
|
|
105
|
+
retry_strategy = resolve(:retry_strategy)
|
|
106
|
+
|
|
107
|
+
# Decorate: Client -> Retry (no cache for uploads)
|
|
108
|
+
HTTP::RetryDecorator.new(client:, retry_strategy:)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Register downloader
|
|
112
|
+
register(:downloader, memoize: false) do
|
|
113
|
+
Transfer::Downloader.new
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Register uploader
|
|
117
|
+
register(:uploader, memoize: true) do
|
|
118
|
+
Transfer::Uploader.new
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Register service credential
|
|
122
|
+
register(:service_credential, memoize: true) { ServiceCredential.load }
|
|
123
|
+
|
|
124
|
+
# Register MOD Portal API client
|
|
125
|
+
register(:mod_portal_api, memoize: true) do
|
|
126
|
+
API::MODPortalAPI.new
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Register MOD Download API client
|
|
130
|
+
register(:mod_download_api, memoize: false) do
|
|
131
|
+
API::MODDownloadAPI.new
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Register API credential (for MOD upload/management)
|
|
135
|
+
register(:api_credential, memoize: true) { APICredential.load }
|
|
136
|
+
|
|
137
|
+
# Register MOD Management API client
|
|
138
|
+
register(:mod_management_api, memoize: true) do
|
|
139
|
+
api = API::MODManagementAPI.new
|
|
140
|
+
# Subscribe mod_portal_api to invalidate cache when MOD is changed on portal
|
|
141
|
+
api.subscribe(resolve(:mod_portal_api))
|
|
142
|
+
api
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Register portal (high-level API wrapper)
|
|
146
|
+
register(:portal, memoize: false) do
|
|
147
|
+
Portal.new
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Log level (:debug, :info, :warn, :error, :fatal)
|
|
151
|
+
setting :log_level, default: :info
|
|
152
|
+
|
|
153
|
+
# Runtime settings (optional overrides for auto-detection)
|
|
154
|
+
setting :runtime do
|
|
155
|
+
setting :executable_path, constructor: ->(v) { v ? Pathname(v) : nil }
|
|
156
|
+
setting :user_dir, constructor: ->(v) { v ? Pathname(v) : nil }
|
|
157
|
+
setting :data_dir, constructor: ->(v) { v ? Pathname(v) : nil }
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# HTTP timeout settings
|
|
161
|
+
setting :http do
|
|
162
|
+
setting :connect_timeout, default: 5
|
|
163
|
+
setting :read_timeout, default: 30
|
|
164
|
+
setting :write_timeout, default: 30
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Cache settings
|
|
168
|
+
setting :cache do
|
|
169
|
+
# Download cache settings (for MOD files)
|
|
170
|
+
setting :download do
|
|
171
|
+
setting :dir, constructor: ->(value) { Pathname(value) }
|
|
172
|
+
setting :ttl, default: nil # nil for unlimited (MOD files are immutable)
|
|
173
|
+
setting :max_file_size, default: nil # nil for unlimited
|
|
174
|
+
setting :compression_threshold, default: nil # nil for no compression (binary files)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# API cache settings (for API responses)
|
|
178
|
+
setting :api do
|
|
179
|
+
setting :dir, constructor: ->(value) { Pathname(value) }
|
|
180
|
+
setting :ttl, default: 3600 # 1 hour (API responses may change)
|
|
181
|
+
setting :max_file_size, default: 10 * 1024 * 1024 # 10MiB (JSON responses)
|
|
182
|
+
setting :compression_threshold, default: 0 # always compress (JSON is highly compressible)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# info.json cache settings (for MOD metadata from ZIP files)
|
|
186
|
+
setting :info_json do
|
|
187
|
+
setting :dir, constructor: ->(value) { Pathname(value) }
|
|
188
|
+
setting :ttl, default: nil # nil for unlimited (info.json is immutable within a MOD ZIP)
|
|
189
|
+
setting :max_file_size, default: nil # nil for unlimited (info.json is small)
|
|
190
|
+
setting :compression_threshold, default: 0 # always compress (JSON is highly compressible)
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Load configuration from file
|
|
195
|
+
#
|
|
196
|
+
# @param path [Pathname, String, nil] configuration file path
|
|
197
|
+
# @return [void]
|
|
198
|
+
# @raise [ConfigurationError] if explicitly specified path does not exist
|
|
199
|
+
def self.load_config(path=nil)
|
|
200
|
+
if path
|
|
201
|
+
# Explicitly specified path must exist
|
|
202
|
+
config_path = Pathname(path)
|
|
203
|
+
raise ConfigurationError, "Configuration file not found: #{config_path}" unless config_path.exist?
|
|
204
|
+
else
|
|
205
|
+
# Default path is optional
|
|
206
|
+
config_path = resolve(:runtime).factorix_config_path
|
|
207
|
+
return unless config_path.exist?
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
instance_eval(config_path.read, config_path.to_s)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
runtime = resolve(:runtime)
|
|
214
|
+
config.cache.download.dir = runtime.factorix_cache_dir / "download"
|
|
215
|
+
config.cache.api.dir = runtime.factorix_cache_dir / "api"
|
|
216
|
+
config.cache.info_json.dir = runtime.factorix_cache_dir / "info_json"
|
|
217
|
+
end
|
|
218
|
+
end
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "pathname"
|
|
6
|
+
require "zlib"
|
|
7
|
+
|
|
8
|
+
module Factorix
|
|
9
|
+
module Cache
|
|
10
|
+
# File system based cache storage implementation.
|
|
11
|
+
#
|
|
12
|
+
# Uses a two-level directory structure to store cached files,
|
|
13
|
+
# with file locking to handle concurrent access and TTL support
|
|
14
|
+
# for cache expiration.
|
|
15
|
+
class FileSystem
|
|
16
|
+
# @!parse
|
|
17
|
+
# # @return [Dry::Logger::Dispatcher]
|
|
18
|
+
# attr_reader :logger
|
|
19
|
+
include Import[:logger]
|
|
20
|
+
|
|
21
|
+
# Maximum lifetime of lock files in seconds.
|
|
22
|
+
# Lock files older than this will be considered stale and removed
|
|
23
|
+
LOCK_FILE_LIFETIME = 3600 # 1 hour in seconds
|
|
24
|
+
public_constant :LOCK_FILE_LIFETIME
|
|
25
|
+
|
|
26
|
+
# zlib CMF byte indicating DEFLATE compression with default window size.
|
|
27
|
+
# Used to detect if cached data is zlib-compressed
|
|
28
|
+
ZLIB_CMF_BYTE = 0x78
|
|
29
|
+
private_constant :ZLIB_CMF_BYTE
|
|
30
|
+
|
|
31
|
+
# Initialize a new file system cache storage.
|
|
32
|
+
# Creates the cache directory if it doesn't exist
|
|
33
|
+
#
|
|
34
|
+
# @param cache_dir [Pathname] path to the cache directory
|
|
35
|
+
# @param ttl [Integer, nil] time-to-live in seconds (nil for unlimited)
|
|
36
|
+
# @param max_file_size [Integer, nil] maximum file size in bytes (nil for unlimited)
|
|
37
|
+
# @param compression_threshold [Integer, nil] compress data larger than this size in bytes
|
|
38
|
+
# (nil: no compression, 0: always compress, N: compress if >= N bytes)
|
|
39
|
+
def initialize(cache_dir, ttl: nil, max_file_size: nil, compression_threshold: nil, logger: nil)
|
|
40
|
+
super(logger:)
|
|
41
|
+
@cache_dir = cache_dir
|
|
42
|
+
@ttl = ttl
|
|
43
|
+
@max_file_size = max_file_size
|
|
44
|
+
@compression_threshold = compression_threshold
|
|
45
|
+
@cache_dir.mkpath
|
|
46
|
+
logger.info("Initializing cache", dir: @cache_dir.to_s, ttl: @ttl, max_size: @max_file_size, compression_threshold: @compression_threshold)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Generate a cache key for the given URL string.
|
|
50
|
+
# Uses SHA1 to create a unique, deterministic key
|
|
51
|
+
#
|
|
52
|
+
# @param url_string [String] URL string to generate key for
|
|
53
|
+
# @return [String] cache key
|
|
54
|
+
# Use Digest(:SHA1) instead of Digest::SHA1 for thread-safety (Ruby 2.2+)
|
|
55
|
+
def key_for(url_string) = Digest(:SHA1).hexdigest(url_string)
|
|
56
|
+
|
|
57
|
+
# Check if a cache entry exists and is not expired.
|
|
58
|
+
# A cache entry is considered to exist if its file exists and is not expired
|
|
59
|
+
#
|
|
60
|
+
# @param key [String] cache key to check
|
|
61
|
+
# @return [Boolean] true if the cache entry exists and is valid, false otherwise
|
|
62
|
+
def exist?(key)
|
|
63
|
+
return false unless cache_path_for(key).exist?
|
|
64
|
+
return true if @ttl.nil?
|
|
65
|
+
|
|
66
|
+
!expired?(key)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Fetch a cached file and copy it to the output path.
|
|
70
|
+
# If the cache entry doesn't exist or is expired, returns false without modifying the output path.
|
|
71
|
+
# Automatically decompresses zlib-compressed cache entries.
|
|
72
|
+
#
|
|
73
|
+
# @param key [String] cache key to fetch
|
|
74
|
+
# @param output [Pathname] path to copy the cached file to
|
|
75
|
+
# @return [Boolean] true if the cache entry was found and copied, false otherwise
|
|
76
|
+
def fetch(key, output)
|
|
77
|
+
path = cache_path_for(key)
|
|
78
|
+
unless path.exist?
|
|
79
|
+
logger.debug("Cache miss", key:)
|
|
80
|
+
return false
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
if expired?(key)
|
|
84
|
+
logger.debug("Cache expired", key:, age_seconds: age(key))
|
|
85
|
+
return false
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
data = path.binread
|
|
89
|
+
if zlib_compressed?(data)
|
|
90
|
+
data = Zlib.inflate(data)
|
|
91
|
+
output.binwrite(data)
|
|
92
|
+
else
|
|
93
|
+
FileUtils.cp(path, output)
|
|
94
|
+
end
|
|
95
|
+
logger.debug("Cache hit", key:)
|
|
96
|
+
true
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Read a cached file as a string.
|
|
100
|
+
# If the cache entry doesn't exist or is expired, returns nil.
|
|
101
|
+
# Automatically decompresses zlib-compressed cache entries.
|
|
102
|
+
#
|
|
103
|
+
# @param key [String] cache key to read
|
|
104
|
+
# @param encoding [Encoding, String] encoding to use (default: ASCII-8BIT for binary)
|
|
105
|
+
# @return [String, nil] cached content or nil if not found/expired
|
|
106
|
+
def read(key, encoding: Encoding::ASCII_8BIT)
|
|
107
|
+
path = cache_path_for(key)
|
|
108
|
+
return nil unless path.exist?
|
|
109
|
+
return nil if expired?(key)
|
|
110
|
+
|
|
111
|
+
data = path.binread
|
|
112
|
+
data = Zlib.inflate(data) if zlib_compressed?(data)
|
|
113
|
+
data.force_encoding(encoding)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Store a file in the cache.
|
|
117
|
+
# Creates necessary subdirectories and stores the file in the cache.
|
|
118
|
+
# Optionally compresses data based on compression_threshold setting.
|
|
119
|
+
# If the (possibly compressed) size exceeds max_file_size, skips caching and returns false.
|
|
120
|
+
#
|
|
121
|
+
# @param key [String] cache key to store under
|
|
122
|
+
# @param src [Pathname] path of the file to store
|
|
123
|
+
# @return [Boolean] true if cached successfully, false if skipped due to size limit
|
|
124
|
+
def store(key, src)
|
|
125
|
+
data = src.binread
|
|
126
|
+
original_size = data.bytesize
|
|
127
|
+
|
|
128
|
+
if should_compress?(original_size)
|
|
129
|
+
data = Zlib.deflate(data)
|
|
130
|
+
logger.debug("Compressed data", original_size:, compressed_size: data.bytesize)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
if @max_file_size && data.bytesize > @max_file_size
|
|
134
|
+
logger.warn("File size exceeds cache limit, skipping", size_bytes: data.bytesize, limit_bytes: @max_file_size)
|
|
135
|
+
return false
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
path = cache_path_for(key)
|
|
139
|
+
path.dirname.mkpath
|
|
140
|
+
path.binwrite(data)
|
|
141
|
+
logger.debug("Stored in cache", key:, size_bytes: data.bytesize)
|
|
142
|
+
true
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Delete a specific cache entry.
|
|
146
|
+
#
|
|
147
|
+
# @param key [String] cache key to delete
|
|
148
|
+
# @return [Boolean] true if the entry was deleted, false if it didn't exist
|
|
149
|
+
def delete(key)
|
|
150
|
+
path = cache_path_for(key)
|
|
151
|
+
return false unless path.exist?
|
|
152
|
+
|
|
153
|
+
path.delete
|
|
154
|
+
logger.debug("Deleted from cache", key:)
|
|
155
|
+
true
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Clear all cache entries.
|
|
159
|
+
# Removes all files in the cache directory.
|
|
160
|
+
#
|
|
161
|
+
# @return [void]
|
|
162
|
+
def clear
|
|
163
|
+
logger.info("Clearing cache directory", dir: @cache_dir.to_s)
|
|
164
|
+
count = 0
|
|
165
|
+
@cache_dir.glob("**/*").each do |path|
|
|
166
|
+
if path.file?
|
|
167
|
+
path.delete
|
|
168
|
+
count += 1
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
logger.info("Cache cleared", files_removed: count)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Get the age of a cache entry in seconds.
|
|
175
|
+
# Returns nil if the entry doesn't exist.
|
|
176
|
+
#
|
|
177
|
+
# @param key [String] cache key
|
|
178
|
+
# @return [Float, nil] age in seconds, or nil if entry doesn't exist
|
|
179
|
+
def age(key)
|
|
180
|
+
path = cache_path_for(key)
|
|
181
|
+
return nil unless path.exist?
|
|
182
|
+
|
|
183
|
+
Time.now - path.mtime
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Check if a cache entry has expired based on TTL.
|
|
187
|
+
# Returns false if TTL is not set (unlimited) or if entry doesn't exist.
|
|
188
|
+
#
|
|
189
|
+
# @param key [String] cache key
|
|
190
|
+
# @return [Boolean] true if expired, false otherwise
|
|
191
|
+
def expired?(key)
|
|
192
|
+
return false if @ttl.nil?
|
|
193
|
+
|
|
194
|
+
age_seconds = age(key)
|
|
195
|
+
return false if age_seconds.nil?
|
|
196
|
+
|
|
197
|
+
age_seconds > @ttl
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Get the size of a cached file in bytes.
|
|
201
|
+
# Returns nil if the entry doesn't exist or is expired.
|
|
202
|
+
#
|
|
203
|
+
# @param key [String] cache key
|
|
204
|
+
# @return [Integer, nil] file size in bytes, or nil if entry doesn't exist/expired
|
|
205
|
+
def size(key)
|
|
206
|
+
path = cache_path_for(key)
|
|
207
|
+
return nil unless path.exist?
|
|
208
|
+
return nil if expired?(key)
|
|
209
|
+
|
|
210
|
+
path.size
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Executes the given block with a file lock.
|
|
214
|
+
# Uses flock for process-safe file locking and automatically removes stale locks
|
|
215
|
+
#
|
|
216
|
+
# @param key [String] cache key to lock
|
|
217
|
+
# @yield Executes the block with exclusive file lock
|
|
218
|
+
# @return [void]
|
|
219
|
+
def with_lock(key)
|
|
220
|
+
lock_path = lock_path_for(key)
|
|
221
|
+
cleanup_stale_lock(lock_path)
|
|
222
|
+
|
|
223
|
+
lock_path.dirname.mkpath
|
|
224
|
+
lock_path.open(File::RDWR | File::CREAT) do |lock|
|
|
225
|
+
if lock.flock(File::LOCK_EX)
|
|
226
|
+
logger.debug("Acquired lock", key:)
|
|
227
|
+
begin
|
|
228
|
+
yield
|
|
229
|
+
ensure
|
|
230
|
+
lock.flock(File::LOCK_UN)
|
|
231
|
+
logger.debug("Released lock", key:)
|
|
232
|
+
begin
|
|
233
|
+
lock_path.unlink
|
|
234
|
+
rescue => e
|
|
235
|
+
logger.debug("Failed to remove lock file", path: lock_path.to_s, error: e.message)
|
|
236
|
+
nil
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# Get the cache file path for the given key.
|
|
244
|
+
# Uses a two-level directory structure to avoid too many files in one directory
|
|
245
|
+
#
|
|
246
|
+
# @param key [String] cache key
|
|
247
|
+
# @return [Pathname] path to the cache file
|
|
248
|
+
private def cache_path_for(key)
|
|
249
|
+
prefix = key[0, 2]
|
|
250
|
+
@cache_dir.join(prefix, key[2..])
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# Get the lock file path for the given key.
|
|
254
|
+
# Lock files are stored alongside cache files with a .lock extension
|
|
255
|
+
#
|
|
256
|
+
# @param key [String] cache key
|
|
257
|
+
# @return [Pathname] path to the lock file
|
|
258
|
+
private def lock_path_for(key)
|
|
259
|
+
cache_path_for(key).sub_ext(".lock")
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# Check if data should be compressed based on compression_threshold setting.
|
|
263
|
+
#
|
|
264
|
+
# @param size [Integer] data size in bytes
|
|
265
|
+
# @return [Boolean] true if data should be compressed
|
|
266
|
+
private def should_compress?(size)
|
|
267
|
+
return false if @compression_threshold.nil?
|
|
268
|
+
|
|
269
|
+
size >= @compression_threshold
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# Check if data is zlib-compressed by examining the CMF and FLG bytes.
|
|
273
|
+
# zlib header consists of CMF (byte 0) and FLG (byte 1) where
|
|
274
|
+
# (CMF * 256 + FLG) % 31 must equal 0.
|
|
275
|
+
#
|
|
276
|
+
# @param data [String] binary data to check
|
|
277
|
+
# @return [Boolean] true if data appears to be zlib-compressed
|
|
278
|
+
private def zlib_compressed?(data)
|
|
279
|
+
return false if data.bytesize < 2
|
|
280
|
+
|
|
281
|
+
cmf = data.getbyte(0)
|
|
282
|
+
flg = data.getbyte(1)
|
|
283
|
+
cmf == ZLIB_CMF_BYTE && ((cmf << 8) | flg) % 31 == 0
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# Remove lock file if it exists and is older than LOCK_FILE_LIFETIME.
|
|
287
|
+
# This prevents orphaned locks from blocking the cache indefinitely
|
|
288
|
+
#
|
|
289
|
+
# @param lock_path [Pathname] path to the lock file
|
|
290
|
+
# @return [void]
|
|
291
|
+
private def cleanup_stale_lock(lock_path)
|
|
292
|
+
return unless lock_path.exist?
|
|
293
|
+
|
|
294
|
+
age = Time.now - lock_path.mtime
|
|
295
|
+
return if age <= LOCK_FILE_LIFETIME
|
|
296
|
+
|
|
297
|
+
begin
|
|
298
|
+
lock_path.unlink
|
|
299
|
+
logger.warn("Removed stale lock", path: lock_path.to_s, age_seconds: age)
|
|
300
|
+
rescue => e
|
|
301
|
+
logger.debug("Failed to remove stale lock", path: lock_path.to_s, error: e.message)
|
|
302
|
+
nil
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
end
|