Class: Scarpe::Promise

Inherits:
Object
  • Object
show all
Includes:
Shoes::Log
Defined in:
lib/scarpe/components/promises.rb

Overview

Scarpe::Promise is a promises library, but one with no form of built-in concurrency. Instead, promise callbacks are executed synchronously. Even execution is usually synchronous, but can also be handled manually for forms of execution not controlled in Ruby (like Webview.)

Funny thing… We need promises as an API concept since we have a JS event loop doing its thing, and we need to respond to actions that it takes. But there’s not really a Ruby implementation of Promises without an attached form of concurrency. So here we are, writing our own :-/

In theory you could probably write some kind of “no-op thread pool” for the ruby-concurrency gem, pass it manually to every promise we created and then raise an exception any time we tried to do something in the background. That’s probably more code than writing our own, though, and we’d be fighting it constantly.

This class is inspired by concurrent-ruby [Promise](ruby-concurrency.github.io/concurrent-ruby/1.1.5/Concurrent/Promise.html) which is inspired by Javascript Promises, which is what we actually need for our use case. We can’t easily tell when our WebView begins processing our request, which removes the :processing state. This can be used for executing JS, but also generally waiting on events.

We don’t fully control ordering here, so it is conceivable that a child waiting on a parent can be randomly fulfilled, even if we didn’t expect it. We don’t consider that an error. Similarly, we’ll call on_scheduled callbacks if a promise is fulfilled, even though we never explicitly scheduled it. If a promise is rejected without ever being scheduled, we won’t call those callbacks.

Constant Summary collapse

PROMISE_STATES =

The unscheduled promise state means it’s waiting on a parent promise that hasn’t completed yet. The pending state means it’s waiting to execute. Fulfilled means it has completed successfully and returned a value, while rejected means it has failed, normally producing a reason.

[:unscheduled, :pending, :fulfilled, :rejected]

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(state: nil, parents: []) { ... } ⇒ Promise

The Promise.new method, along with all the various handlers, are pretty raw. They’ll do what promises do, but they’re not the prettiest. However, they ensure that guarantees are made and so on, so they’re great as plumbing under the syntactic sugar above.

Note that the state passed in may not be the actual initial state. If a parent is rejected, the state will become rejected. If no parents are waiting or failed then a state of nil or :unscheduled will become :pending.

Parameters:

  • state (Symbol) (defaults to: nil)

    One of PROMISE_STATES for the initial state

  • parents (Array) (defaults to: [])

    A list of promises that must be fulfilled before this one is scheduled

Yields:

  • A block that executes when this promise is scheduled - when its parents, if any, are all fulfilled



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
147
148
149
150
151
152
153
154
# File 'lib/scarpe/components/promises.rb', line 99

def initialize(state: nil, parents: [], &scheduler)
  log_init("Promise")

  # These are as initially specified, and effectively immutable
  @state = state
  @parents = parents

  # These are what we're waiting on, and will be
  # removed as time goes forward.
  @waiting_on = parents.select { |p| !p.complete? }
  @on_fulfilled = []
  @on_rejected = []
  @on_scheduled = []
  @scheduler = scheduler
  @executor = nil
  @returned_value = nil
  @reason = nil

  if complete?
    # Did we start out already fulfilled or rejected?
    # If so, we can skip a lot of fiddly checking.
    # Don't need a scheduler or to care about parents
    # or anything.
    @waiting_on = []
    @scheduler = nil
  elsif @parents.any? { |p| p.state == :rejected }
    @state = :rejected
    @waiting_on = []
    @scheduler =  nil
  elsif @state == :pending
    # Did we get an explicit :pending? Then we don't need
    # to schedule ourselves, or care about the scheduler
    # in general.
    @scheduler = nil
  elsif @state.nil? || @state == :unscheduled
    # If no state was given or we're unscheduled, we'll
    # wait until our parents have all completed to
    # schedule ourselves.

    if @waiting_on.empty?
      # No further dependencies, we can schedule ourselves
      @state = :pending

      # We have no on_scheduled handlers yet, but this will
      # call and clear the scheduler.
      call_handlers_for(:pending)
    else
      # We're still waiting on somebody, no scheduling yet
      @state = :unscheduled
      @waiting_on.each do |dep|
        dep.on_fulfilled { parent_fulfilled!(dep) }
        dep.on_rejected { parent_rejected!(dep) }
      end
    end
  end
end

Instance Attribute Details

#parentsObject (readonly)

The parent promises of this promise, sometimes an empty array



47
48
49
# File 'lib/scarpe/components/promises.rb', line 47

def parents
  @parents
end

#reasonObject (readonly)

If the promise is rejected, this is the reason, sometimes an exception



53
54
55
# File 'lib/scarpe/components/promises.rb', line 53

def reason
  @reason
end

#returned_valueObject (readonly)

If the promise is fulfilled, this is the value returned



50
51
52
# File 'lib/scarpe/components/promises.rb', line 50

def returned_value
  @returned_value
end

#stateObject (readonly)

The state of the promise, which should be one of PROMISE_STATES



44
45
46
# File 'lib/scarpe/components/promises.rb', line 44

def state
  @state
end

Class Method Details

.fulfilled(return_val = nil, parents: [], &block) ⇒ Object

Create a promise and then instantly fulfill it.



56
57
58
59
60
# File 'lib/scarpe/components/promises.rb', line 56

def self.fulfilled(return_val = nil, parents: [], &block)
  p = Promise.new(parents: parents, &block)
  p.fulfilled!(return_val)
  p
end

.rejected(reason = nil, parents: []) ⇒ Object

Create a promise and then instantly reject it.



63
64
65
66
67
# File 'lib/scarpe/components/promises.rb', line 63

def self.rejected(reason = nil, parents: [])
  p = Promise.new(parents: parents)
  p.rejected!(reason)
  p
end

Instance Method Details

#complete?Boolean

Return true if the Promise is either fulfilled or rejected.

Returns:

  • (Boolean)

    true if the promise is fulfilled or rejected



159
160
161
# File 'lib/scarpe/components/promises.rb', line 159

def complete?
  @state == :fulfilled || @state == :rejected
end

#fulfilled!(value = nil) ⇒ Object

Fulfill the promise, setting the returned_value to value



70
71
72
# File 'lib/scarpe/components/promises.rb', line 70

def fulfilled!(value = nil)
  set_state(:fulfilled, value)
end

#fulfilled?Boolean

Return true if the promise is already fulfilled.

Returns:

  • (Boolean)

    true if the promise is fulfilled



166
167
168
# File 'lib/scarpe/components/promises.rb', line 166

def fulfilled?
  @state == :fulfilled
end

#inspectObject

An inspect method to give slightly smaller output, for ease of reading in irb



178
179
180
181
182
183
184
185
186
# File 'lib/scarpe/components/promises.rb', line 178

def inspect
  "#<Scarpe::Promise:#{object_id} " +
    "@state=#{@state.inspect} @parents=#{@parents.inspect} " +
    "@waiting_on=#{@waiting_on.inspect} @on_fulfilled=#{@on_fulfilled.size} " +
    "@on_rejected=#{@on_rejected.size} @on_scheduled=#{@on_scheduled.size} " +
    "@scheduler=#{@scheduler ? "Y" : "N"} @executor=#{@executor ? "Y" : "N"} " +
    "@returned_value=#{@returned_value.inspect} @reason=#{@reason.inspect}" +
    ">"
end

#on_fulfilled { ... } ⇒ Scarpe::Promise

Register a handler to be called when the promise is fulfilled. If called on a fulfilled promise, the handler will be called immediately.

Yields:

  • Handler to be called on fulfilled

Returns:



390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
# File 'lib/scarpe/components/promises.rb', line 390

def on_fulfilled(&handler)
  unless handler
    raise Shoes::Errors::InvalidAttributeValueError, "You must pass a block to on_fulfilled!"
  end

  case @state
  when :fulfilled
    handler.call(*@parents.map(&:returned_value))
  when :pending, :unscheduled
    @on_fulfilled << handler
  when :rejected
    # Do nothing
  end

  self
end

#on_rejected { ... } ⇒ Scarpe::Promise

Register a handler to be called when the promise is rejected. If called on a rejected promise, the handler will be called immediately.

Yields:

  • Handler to be called on rejected

Returns:



412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
# File 'lib/scarpe/components/promises.rb', line 412

def on_rejected(&handler)
  unless handler
    raise Shoes::Errors::InvalidAttributeValueError, "You must pass a block to on_rejected!"
  end

  case @state
  when :rejected
    handler.call(*@parents.map(&:returned_value))
  when :pending, :unscheduled
    @on_rejected << handler
  when :fulfilled
    # Do nothing
  end

  self
end

#on_scheduled { ... } ⇒ Scarpe::Promise

Register a handler to be called when the promise is scheduled. If called on a promise that was scheduled earlier, the handler will be called immediately.

Yields:

  • Handler to be called on scheduled

Returns:



435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
# File 'lib/scarpe/components/promises.rb', line 435

def on_scheduled(&handler)
  unless handler
    raise Shoes::Errors::InvalidAttributeValueError, "You must pass a block to on_scheduled!"
  end

  # Add a pending handler or call it now
  case @state
  when :fulfilled, :pending
    handler.call(*@parents.map(&:returned_value))
  when :unscheduled
    @on_scheduled << handler
  when :rejected
    # Do nothing
  end

  self
end

#rejected!(reason = nil) ⇒ Object

Reject the promise, setting the reason to reason



75
76
77
# File 'lib/scarpe/components/promises.rb', line 75

def rejected!(reason = nil)
  set_state(:rejected, reason)
end

#rejected?Boolean

Return true if the promise is already rejected.

Returns:

  • (Boolean)

    true if the promise is rejected



173
174
175
# File 'lib/scarpe/components/promises.rb', line 173

def rejected?
  @state == :rejected
end

#then(&block) ⇒ Object

Create a new promise with this promise as a parent. It runs the specified code in block when scheduled.



81
82
83
# File 'lib/scarpe/components/promises.rb', line 81

def then(&block)
  Promise.new(parents: [self], &block)
end

#to_execute(&block) ⇒ Object

These promises are mostly designed for external execution. You could put together your own thread-pool, or use RPC, a WebView, a database or similar source of external calculation. But in many cases it’s reasonable to execute locally. In those cases, you can register an executor which will be called when the promise is ready to execute but has not yet done so. Registering an executor on a promise that is already fulfilled is an error. Registering an executor on a promise that has already rejected is a no-op.



197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
# File 'lib/scarpe/components/promises.rb', line 197

def to_execute(&block)
  case @state
  when :fulfilled
    # Should this be a no-op instead?
    raise Scarpe::NoOperationError, "Registering an executor on an already fulfilled promise means it will never run!"
  when :rejected
    return
  when :unscheduled
    @executor = block # save for later
  when :pending
    @executor = block
    call_executor
  else
    raise Scarpe::InternalError, "Internal error, illegal state!"
  end

  self
end