Class: NewBridge::RInstanceManager

Inherits:
Object
  • Object
show all
Defined in:
lib/new_bridge/r_instance_manager.rb

Overview

Phase 4.3 (local): Manage a dynamic set of R instances.

This is the first slice of the planned production-grade instance manager:

  • spawn local R processes on demand
  • evaluate code via the spawned SessionClient instances
  • stop one or all instances

Container provisioning and multi-version orchestration are deferred.

Defined Under Namespace

Classes: Error

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(source_path:, host: '127.0.0.1', default_r_cmd: 'R', session_client_class: SessionClient, out: $stdout, docker_access_checker: nil) ⇒ RInstanceManager

Returns a new instance of RInstanceManager.



20
21
22
23
24
25
26
27
28
29
30
31
# File 'lib/new_bridge/r_instance_manager.rb', line 20

def initialize(source_path:, host: '127.0.0.1', default_r_cmd: 'R', session_client_class: SessionClient, out: $stdout, docker_access_checker: nil)
  @source_path = File.expand_path(source_path)
  @host = host
  @default_r_cmd = default_r_cmd
  @session_client_class = session_client_class
  @out = out
  @docker_access_checker = docker_access_checker
  @clients = {} # instance_id => SessionClient
  @meta = {}    # instance_id => { provider:, runtime:, version:, r_cmd:, status:, image: }
  @default_instance_id = nil
  @preferred_bridge_host = nil
end

Instance Attribute Details

#default_instance_idObject (readonly)

Returns the value of attribute default_instance_id.



18
19
20
# File 'lib/new_bridge/r_instance_manager.rb', line 18

def default_instance_id
  @default_instance_id
end

#preferred_bridge_hostObject (readonly)

Returns the value of attribute preferred_bridge_host.



18
19
20
# File 'lib/new_bridge/r_instance_manager.rb', line 18

def preferred_bridge_host
  @preferred_bridge_host
end

Instance Method Details

#eval_all_versions(code, session_id: 'default', timeout: 60) ⇒ Object

Run the same code once per distinct version. Returns a map keyed by version label.



223
224
225
226
227
228
229
230
231
232
# File 'lib/new_bridge/r_instance_manager.rb', line 223

def eval_all_versions(code, session_id: 'default', timeout: 60)
  by_version = {}
  @meta.each do |id, m|
    v = m[:version]
    next if v.nil? || by_version.key?(v)

    by_version[v] = eval_r(code, session_id: session_id, instance_id: id, timeout: timeout)
  end
  by_version
end

#eval_r(code, session_id:, instance_id:, parent_id: nil, timeout: 60) ⇒ Object



197
198
199
200
201
202
203
204
205
206
207
208
209
# File 'lib/new_bridge/r_instance_manager.rb', line 197

def eval_r(code, session_id:, instance_id:, parent_id: nil, timeout: 60)
  c = @clients.fetch(instance_id)
  c.eval_r(code, session_id: session_id, instance_id: instance_id, parent_id: parent_id, timeout: timeout)
rescue StandardError => e
  raise unless infrastructure_retryable_error?(e)
  raise unless @clients.key?(instance_id)

  @out.puts("[RInstanceManager] runtime failure on instance=#{instance_id}: #{e.class}: #{e.message}")
  @out.puts("[RInstanceManager] attempting one restart+retry for instance=#{instance_id}")
  restart_instance(instance_id)
  c2 = @clients.fetch(instance_id)
  c2.eval_r(code, session_id: session_id, instance_id: instance_id, parent_id: parent_id, timeout: timeout)
end

#eval_with_version(code, version:, session_id: 'default', timeout: 60) ⇒ Object

Route to an instance by version label. If multiple instances share a version, first match is used (deterministic by insertion order).

Raises:

  • (KeyError)


213
214
215
216
217
218
219
# File 'lib/new_bridge/r_instance_manager.rb', line 213

def eval_with_version(code, version:, session_id: 'default', timeout: 60)
  entry = @meta.find { |_id, m| m[:version] == version }
  raise KeyError, "no instance for version=#{version}" unless entry

  instance_id, = entry
  eval_r(code, session_id: session_id, instance_id: instance_id, timeout: timeout)
end

#instance_idsObject



173
174
175
# File 'lib/new_bridge/r_instance_manager.rb', line 173

def instance_ids
  @clients.keys
end

#instancesObject

Lightweight metadata view suitable for management UIs and tests. Example: [{ id: "a", provider: "local", version: "4.3.3", status: :healthy, r_cmd: "R" }]



180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
# File 'lib/new_bridge/r_instance_manager.rb', line 180

def instances
  @clients.keys.map do |id|
    m = @meta.fetch(id, {})
    {
      id: id,
      provider: m[:provider] || 'local',
      runtime: m[:runtime] || 'local',
      version: m[:version],
      status: m[:status] || :healthy,
      r_cmd: m[:r_cmd],
      image: m[:image],
      container_name: m[:container_name],
      default: (id == @default_instance_id)
    }
  end
end

#kill_container(instance_id) ⇒ Object

Explicitly force-kill a container-backed runtime instance. Useful when user wants to ensure no container stays alive after a versioned run.

Raises:

  • (ArgumentError)


277
278
279
280
281
282
# File 'lib/new_bridge/r_instance_manager.rb', line 277

def kill_container(instance_id)
  m = @meta.fetch(instance_id)
  raise ArgumentError, "instance #{instance_id} is not container runtime" unless m[:runtime] == 'container'

  stop(instance_id, force_container: true)
end

#set_default_instance(instance_id) ⇒ Object

Raises:

  • (KeyError)


234
235
236
237
238
# File 'lib/new_bridge/r_instance_manager.rb', line 234

def set_default_instance(instance_id)
  raise KeyError, "unknown instance_id=#{instance_id}" unless @clients.key?(instance_id)

  @default_instance_id = instance_id
end

#spawn(instance_id:, runtime:, version:, r_cmd: @default_r_cmd, image: nil, container_name: nil, accept_timeout: 30, bridge_host: nil, bridge_hosts: nil, relearn_bridge_host: false) ⇒ Object

Unified spawn API. runtime: 'local' or 'container'



150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
# File 'lib/new_bridge/r_instance_manager.rb', line 150

def spawn(instance_id:, runtime:, version:, r_cmd: @default_r_cmd, image: nil, container_name: nil, accept_timeout: 30,
  bridge_host: nil, bridge_hosts: nil, relearn_bridge_host: false)
  case runtime.to_s
  when 'local'
    spawn_local(instance_id: instance_id, r_cmd: r_cmd, version: version)
  when 'container'
    raise ArgumentError, 'image is required for container runtime' if image.nil? || image.empty?

    spawn_container(
      instance_id: instance_id,
      image: image,
      version: version,
      container_name: container_name,
      accept_timeout: accept_timeout,
      bridge_host: bridge_host,
      bridge_hosts: bridge_hosts,
      relearn_bridge_host: relearn_bridge_host
    )
  else
    raise ArgumentError, "unsupported runtime=#{runtime} (expected 'local' or 'container')"
  end
end

#spawn_container(instance_id:, image:, version:, container_name: nil, r_exec: 'R', accept_timeout: 30, bridge_host: nil, bridge_hosts: nil, relearn_bridge_host: false) ⇒ Object

Spawn R in a container image.

Notes:

  • This uses Docker and assumes the image can execute R with Rcpp available.
  • We mount the project root into /workspace and source the gatekeeper from that path.

Raises:

  • (ArgumentError)


62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
# File 'lib/new_bridge/r_instance_manager.rb', line 62

def spawn_container(instance_id:, image:, version:, container_name: nil, r_exec: 'R', accept_timeout: 30,
  bridge_host: nil, bridge_hosts: nil, relearn_bridge_host: false)
  raise ArgumentError, "instance already exists: #{instance_id}" if @clients.key?(instance_id)
  ensure_docker_daemon_access!

  source_dir = File.dirname(@source_path)
  project_root = File.expand_path('../..', source_dir)
  runtime_source = "/workspace/ext/new_bridge/#{File.basename(@source_path)}"
  base_name = container_name || "galaaz-#{instance_id}"
  host_candidates = if bridge_host
                      [bridge_host]
                    elsif bridge_hosts
                      bridge_hosts
                    else
                      bridge_host_candidates(relearn: relearn_bridge_host)
                    end
  @out.puts("[RInstanceManager] starting container instance #{instance_id} image=#{image} version=#{version}")
  @out.puts("[RInstanceManager] bridge host candidates: #{host_candidates.join(', ')}")

  c = nil
  last_error = nil
  selected_name = nil
  selected_host = nil
  selected_launch = nil

  host_candidates.each_with_index do |bridge_host, idx|
    name = "#{base_name}-#{idx}"
    @out.puts("[RInstanceManager] trying bridge host #{bridge_host} (attempt #{idx + 1}/#{host_candidates.size})")
    launch = [
      'docker', 'run',
      '--name', name,
      '--add-host', 'host.docker.internal:host-gateway',
      '-v', "#{project_root}:/workspace",
      image, r_exec
    ]

    c = @session_client_class.new(
      source_path: @source_path,
      runtime_source_path: runtime_source,
      host: '0.0.0.0',
      bridge_host: bridge_host,
      r_cmd: launch
    )
    begin
      c.start(accept_timeout: accept_timeout)
      selected_name = name
      selected_host = bridge_host
      selected_launch = launch
      @out.puts("[RInstanceManager] container instance #{instance_id} connected via #{bridge_host}")
      break
    rescue StandardError => e
      last_error = e
      logs = docker_logs(name, tail: 120)
      @out.puts("[RInstanceManager] bridge host #{bridge_host} failed: #{e.message}")
      if bridge_host == @preferred_bridge_host
        @out.puts("[RInstanceManager] cached bridge host failed, trying fallback candidates...")
      end
      @out.puts(logs) unless logs.nil? || logs.empty?
      docker_rm_force(name)
      c = nil
    end
  end

  if c.nil?
    raise Error, "container runtime start failed instance=#{instance_id} image=#{image}: #{last_error&.message}"
  end
  if selected_host && selected_host != @preferred_bridge_host
    @preferred_bridge_host = selected_host
    @out.puts("[RInstanceManager] learned preferred bridge host: #{@preferred_bridge_host}")
  end

  @clients[instance_id] = c
  @meta[instance_id] = {
    provider: 'docker',
    runtime: 'container',
    version: version,
    image: image,
    bridge_host: selected_host,
    container_name: selected_name,
    r_cmd: selected_launch,
    status: :healthy
  }
  @default_instance_id ||= instance_id
  c
end

#spawn_local(instance_id:, r_cmd: @default_r_cmd, version: nil) ⇒ SessionClient

Spawn a new local R gatekeeper process.

Parameters:

  • instance_id (String)
  • r_cmd (String) (defaults to: @default_r_cmd)

    R executable (or full path to it)

  • version (String, nil) (defaults to: nil)

    semantic/version label for routing (e.g., "4.3.3")

Returns:

Raises:

  • (ArgumentError)


39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
# File 'lib/new_bridge/r_instance_manager.rb', line 39

def spawn_local(instance_id:, r_cmd: @default_r_cmd, version: nil)
  raise ArgumentError, "instance already exists: #{instance_id}" if @clients.key?(instance_id)

  @out.puts("[RInstanceManager] starting local instance #{instance_id} version=#{version || 'n/a'}")
  c = @session_client_class.new(source_path: @source_path, host: @host, r_cmd: r_cmd)
  c.start
  @clients[instance_id] = c
  @meta[instance_id] = {
    provider: 'local',
    runtime: 'local',
    version: version,
    r_cmd: r_cmd,
    status: :healthy
  }
  @default_instance_id ||= instance_id
  c
end

#stop(instance_id = nil, keep_default: false, force_container: false) ⇒ Object

Stop one instance or all instances.

  • keep_default: when stopping all, leaves default instance alive.
  • force_container: when stopping container instances, force-remove docker container.


243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
# File 'lib/new_bridge/r_instance_manager.rb', line 243

def stop(instance_id = nil, keep_default: false, force_container: false)
  if instance_id
    c = @clients.delete(instance_id)
    c&.stop
    m = @meta.delete(instance_id)
    cleanup_container(m, force: force_container)
    @default_instance_id = @clients.keys.first if @default_instance_id == instance_id
    return self
  end

  ids = @clients.keys
  ids.each do |id|
    next if keep_default && id == @default_instance_id

    c = @clients.delete(id)
    c&.stop
    m = @meta.delete(id)
    cleanup_container(m, force: force_container)
  end

  if @clients.empty?
    @default_instance_id = nil
  elsif @default_instance_id.nil? || !@clients.key?(@default_instance_id)
    @default_instance_id = @clients.keys.first
  end
  self
end

#stop_all(keep_default: false, force_container: false) ⇒ Object



271
272
273
# File 'lib/new_bridge/r_instance_manager.rb', line 271

def stop_all(keep_default: false, force_container: false)
  stop(nil, keep_default: keep_default, force_container: force_container)
end