/lspace

Safe operation-local global variables!

Primary LanguageRubyMIT LicenseMIT

LSpace, named after the Discworld's L-Space, is an implementation of dynamic scoping for Ruby.

Dynamic scope

Variables that are stored inside an LSpace are dynamically scoped, this means that they take effect only for the duration of a block:

LSpace.with(:user_id => 5) do
  LSpace[:user_id] == 5
end
LSpace[:user_id] == nil

You can enter a new LSpace as many times as you need, to add as much state as you need:

LSpace.with(:user_id => 5) do
  LSpace.with(:database_shard => 7) do
    LSpace[:user_id] == 5
    LSpace[:database_shard] == 7
  end
end

Operation safety

LSpace is thread-safe, so entering a new LSpace on one thread won't affect any of the other Threads. In addition, LSpace also comes with extensions for eventmachine, celluloid, and fiber which extends the notion of thread-safety to operation-safety.

This means that even if you're doing multiple things on one thread, or one thing using many threads, the changes you make to LSpace will still be local to that thing.

require 'lspace/eventmachine'
EM::run
  LSpace.with(:user_id => 5) do
    EM::defer{ LSpace[:user_id] == 5; EM::stop }
  end
end

See also examples/eventmachine.rb.

require 'lspace/celluloid'
class Actor
  include Celluloid
  def example
    LSpace[:user_id] == 5
  end
end

LSpace.with(:user_id => 5) do
  Actor.new.example!
end

See also examples/celluloid.rb.

require 'lspace/fiber'
LSpace.with(:user_id => 5) do
  Fiber.new { LSpace[:user_id] == 5 }.resume
end

lspace_reader

Because reading from the current LSpace is the most common thing to do, you can define an accessor function that lets you do this:

class Task
  lspace_reader :user_id

  def process
    puts "Running #{self} for User##{user_id}"
  end
end

LSpace.with(:user_id => 7) do
  Task.new.process
end

Around filters

The ability of LSpace to be operation-local instead of merely thread local also enables you to add around filters to your code. Whenever your operation jumps between threads, or fires a callback, the around filters are called so that code running in the context of your operation is always wrapped.

This is useful for maintaining operation-local state in libraries that only support thread-local state (like Log4r):

LSpace.around_filter do |&block|
  previous_context = Log4r::MDC.get :context
  begin
    Log4r::MDC.put :context, LSpace[:log_context]
    block.call
  ensure
    Log4r::MDC.put :context, previous_context
  end
end

You can also use this to log any unhandled exceptions that happen while your operation is running without hitting the default error handler for your thread-pool or event loop. This makes tracking down the causes of unexpected exceptions much easier:

LSpace.around_filter do |&block|
  begin
    block.call
  rescue => e
    puts "Got exception running #{LSpace[:job_id]}: #{e}"
  end
end

Use cases

LSpace is good for the parts of your application that are not directly relevant to what you're actually trying to do, but are important to the manner in which your application is written.

For example, when showing a user's page, it's normally fine to use a database slave. If the user is looking at their own page, then it's important to use a master database in case they've just edited their profile. To implement this without LSpace you have to push the use_master_database flag down through all of your page-rendering logic. With LSpace you can make this change in a much less brittle way:

require 'lspace'
class DatabaseConnection
  def get_connection
    LSpace[:preferred_connection] || any_free_connection
  end

  def self.use_master(&block)
    LSpace.with(:preferred_connection => master_connection) do
      block.call
    end
  end
end

DatabaseConnection.use_master do
  very_important_transactions!
end

Another good example is logging. We want to prefix log messages involved with handling one particular web request with the same unique string every time, so that we can tie all of those message together despite a large number of concurrent requests being handled. Without LSpace this would be a nightmare, as we'd have to push the log_prefix down into all parts of our code, with LSpace it becomes simple.

Because the changes to LSpace are only visible within the current operation, or current block, it's much safer than global state; though it has many of the same benefits.

Integrating with new libraries

If you are using a Thread-pool, or an actor system, or an event loop, you will need to teach it about LSpace in order to get the correct operation-local semantics.

There are two kinds of integration. Firstly, when your library accepts blocks from the programmer's code, and proceeds to run them on a different call-stack, you should call Proc#in_lspace:

def enqueue_task(&block)
  $todo << block.in_lspace
end

This will ensure that the user's current LSpace is re-activated when the block is run. You can automate this by using the in_lspace wrapper function at the module level:

class Scheduler
  def enqueue_task(&block)
    $todo << block
  end
  in_lspace :enqueue_task
end

Secondly, when your library creates objects that call out to the user's code, it's polite to re-use the same LSpace across each call:

class Job
  def initialize
    @lspace = LSpace.new
  end

  def run_internal
    LSpace.enter(@lspace) { run }
  end
end

A new LSpace will by default inherit everything from its parent, so it's better to store LSpace.new than LSpace.current, so that if the user mutates their LSpace in a callback, the change does not propagate upwards.

Testing

If you're using LSpace, you probably want each test case to run in its own LSpace so that tests cannot pollute each other. If you're using rspec you can do this with:

require 'lspace/rspec'

If not, you'll have to add an around filter yourself.