Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat!: add app.state #80

Merged
merged 17 commits into from
Oct 9, 2024
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,13 @@ RUN pip wheel . --wheel-dir=/wheels
# Install from wheels
FROM ghcr.io/apeworx/ape:${BASE_APE_IMAGE_TAG:-latest}
USER root
COPY --from=builder /wheels /wheels
COPY --from=builder /wheels/*.whl /wheels
RUN pip install --upgrade pip \
&& pip install silverback \
&& pip install \
--no-cache-dir --no-index --find-links=/wheels \
'taskiq-sqs>=0.0.11' \
johnson2427 marked this conversation as resolved.
Show resolved Hide resolved
--no-cache-dir --find-links=/wheels
silverback

USER harambe

ENTRYPOINT ["silverback"]
39 changes: 39 additions & 0 deletions docs/userguides/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,45 @@ def handle_on_shutdown():

*Changed in 0.2.0*: The behavior of the `@bot.on_startup()` decorator and handler signature have changed. It is now executed only once upon application startup and worker events have moved on `@bot.on_worker_startup()`.

## Application State
johnson2427 marked this conversation as resolved.
Show resolved Hide resolved

Sometimes it is very useful to have access to values in a shared state across your workers.
For example you might have a value or complex reference type that you wish to update during one of your tasks, and read during another.
Silverback provides `app.state` to help with these use cases.
johnson2427 marked this conversation as resolved.
Show resolved Hide resolved

For example, you might want to pre-populate a large dataframe into state on startup, keeping that dataframe in sync with the chain through event logs,
and then use that data to determine a signal under which you want trigger transactions to commit back to the chain.
Such an application might look like this:

```py
@app.on_startup()
johnson2427 marked this conversation as resolved.
Show resolved Hide resolved
def create_table(startup_state):
df = contract.MyEvent.query(..., start_block=startup_state.last_block_processed)
... # Do some further processing on df
app.state.table = df
johnson2427 marked this conversation as resolved.
Show resolved Hide resolved


@app.on_(contract.MyEvent)
johnson2427 marked this conversation as resolved.
Show resolved Hide resolved
def update_table(log):
app.state.table = ... # Update using stuff from `log`
johnson2427 marked this conversation as resolved.
Show resolved Hide resolved


@app.on_(chain.blocks)
johnson2427 marked this conversation as resolved.
Show resolved Hide resolved
def use_table(blk):
if app.state.table[...].mean() > app.state.table[...].sum():
# Trigger your app to send a transaction from `app.signer`
contract.myMethod(..., sender=app.signer)
johnson2427 marked this conversation as resolved.
Show resolved Hide resolved
...
```

```{warning}
You can use `app.state` to store any python variable type, however note that the item is not networked nor threadsafe so it is not recommended to have multiple tasks write to the same value in state at the same time.
johnson2427 marked this conversation as resolved.
Show resolved Hide resolved
```

```{note}
Application startup and application runtime events (e.g. block or event container) are handled distinctly and can be trusted not to execute at the same time.
johnson2427 marked this conversation as resolved.
Show resolved Hide resolved
```

### Signing Transactions

If configured, your bot with have `bot.signer` which is an Ape account that can sign arbitrary transactions you ask it to.
Expand Down
88 changes: 60 additions & 28 deletions example.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import asyncio
from typing import Annotated

from ape import chain
Expand All @@ -6,40 +7,56 @@
from ape_tokens import tokens # type: ignore[import]
from taskiq import Context, TaskiqDepends, TaskiqState

from silverback import AppState, CircuitBreaker, SilverbackApp
from silverback import CircuitBreaker, SilverbackBot, StateSnapshot

# Do this first to initialize your app
app = SilverbackApp()
# Do this first to initialize your bot
bot = SilverbackBot()

# NOTE: Don't do any networking until after initializing app
# Cannot call `bot.state` outside of an bot function handler
# bot.state.something # NOTE: raises AttributeError

# NOTE: Don't do any networking until after initializing bot
USDC = tokens["USDC"]
YFI = tokens["YFI"]


@app.on_startup()
def app_startup(startup_state: AppState):
# NOTE: This is called just as the app is put into "run" state,
# and handled by the first available worker
# raise Exception # NOTE: Any exception raised on startup aborts immediately
@bot.on_startup()
def bot_startup(startup_state: StateSnapshot):
# This is called just as the bot is put into "run" state,
# and handled by the first available worker

# Any exception raised on startup aborts immediately:
# raise Exception # NOTE: raises StartupFailure

# This is a great place to set `bot.state` values
bot.state.logs_processed = 0
# NOTE: Can put anything here, any python object works

return {"block_number": startup_state.last_block_seen}


# Can handle some resource initialization for each worker, like LLMs or database connections
class MyDB:
def execute(self, query: str):
pass
pass # Handle query somehow...


@app.on_worker_startup()
def worker_startup(state: TaskiqState): # NOTE: You need the type hint here
@bot.on_worker_startup()
# NOTE: This event is triggered internally, do not use unless you know what you're doing
def worker_startup(worker_state: TaskiqState): # NOTE: You need the type hint to load worker state
# NOTE: Worker state is per-worker, not shared with other workers
# NOTE: Can put anything here, any python object works
state.db = MyDB()
state.block_count = 0
# raise Exception # NOTE: Any exception raised on worker startup aborts immediately
worker_state.db = MyDB()

# Any exception raised on worker startup aborts immediately:
# raise Exception # NOTE: raises StartupFailure

# Cannot call `bot.state` because it is not set up yet on worker startup functions
# bot.state.something # NOTE: raises AttributeError


# This is how we trigger off of new blocks
@app.on_(chain.blocks)
@bot.on_(chain.blocks)
# NOTE: The type hint for block is `BlockAPI`, but we parse it using `EcosystemAPI`
# NOTE: If you need something from worker state, you have to use taskiq context
def exec_block(block: BlockAPI, context: Annotated[Context, TaskiqDepends()]):
Expand All @@ -49,36 +66,51 @@ def exec_block(block: BlockAPI, context: Annotated[Context, TaskiqDepends()]):

# This is how we trigger off of events
# Set new_block_timeout to adjust the expected block time.
@app.on_(USDC.Transfer, start_block=19784367, new_block_timeout=25)
@bot.on_(USDC.Transfer, start_block=19784367, new_block_timeout=25)
# NOTE: Typing isn't required, it will still be an Ape `ContractLog` type
def exec_event1(log):
if log.log_index % 7 == 3:
# If you raise any exception, Silverback will track the failure and keep running
# NOTE: By default, if you have 3 tasks fail in a row, the app will shutdown itself
# NOTE: By default, if you have 3 tasks fail in a row, the bot will shutdown itself
raise ValueError("I don't like the number 3.")

# You can update state whenever you want
bot.state.logs_processed += 1

return {"amount": log.amount}


@app.on_(YFI.Approval)
@bot.on_(YFI.Approval)
# Any handler function can be async too
async def exec_event2(log: ContractLog):
if log.log_index % 7 == 6:
# If you ever want the app to immediately shutdown under some scenario, raise this exception
raise CircuitBreaker("Oopsie!")

# All `bot.state` values are updated across all workers at the same time
bot.state.logs_processed += 1
# Do any other long running tasks...
await asyncio.sleep(5)
return log.amount


@bot.on_(chain.blocks)
# NOTE: You can have multiple handlers for any trigger we support
def check_logs(log):
if bot.state.logs_processed > 20:
# If you ever want the bot to immediately shutdown under some scenario, raise this exception
raise CircuitBreaker("Oopsie!")


# A final job to execute on Silverback shutdown
@app.on_shutdown()
def app_shutdown():
# raise Exception # NOTE: Any exception raised on shutdown is ignored
@bot.on_shutdown()
def bot_shutdown():
# NOTE: Any exception raised on worker shutdown is ignored:
# raise Exception
return {"some_metric": 123}


# Just in case you need to release some resources or something inside each worker
@app.on_worker_shutdown()
@bot.on_worker_shutdown()
def worker_shutdown(state: TaskiqState): # NOTE: You need the type hint here
# This is a good time to release resources
state.db = None
# raise Exception # NOTE: Any exception raised on worker shutdown is ignored

# NOTE: Any exception raised on worker shutdown is ignored:
# raise Exception
8 changes: 4 additions & 4 deletions silverback/__init__.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
from .application import SilverbackApp
from .main import SilverbackBot
from .exceptions import CircuitBreaker, SilverbackException
from .state import AppState
from .state import StateSnapshot

__all__ = [
"AppState",
"StateSnapshot",
"CircuitBreaker",
"SilverbackApp",
"SilverbackBot",
"SilverbackException",
]
8 changes: 4 additions & 4 deletions silverback/_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
@click.group(cls=SectionedHelpGroup)
def cli():
"""
Silverback: Build Python apps that react to on-chain events
Silverback: Build Python bots that react to on-chain events

To learn more about our cloud offering, please check out https://silverback.apeworx.io
"""
Expand Down Expand Up @@ -110,7 +110,7 @@ def _network_callback(ctx, param, val):
@click.option("-x", "--max-exceptions", type=int, default=3)
@click.argument("bot", required=False, callback=bot_path_callback)
def run(cli_ctx, account, runner_class, recorder_class, max_exceptions, bot):
"""Run Silverback application"""
"""Run Silverback bot"""

if not runner_class:
# NOTE: Automatically select runner class
Expand All @@ -120,7 +120,7 @@ def run(cli_ctx, account, runner_class, recorder_class, max_exceptions, bot):
runner_class = PollingRunner
else:
raise click.BadOptionUsage(
option_name="network", message="Network choice cannot support running app"
option_name="network", message="Network choice cannot support running bot"
)

runner = runner_class(
Expand Down Expand Up @@ -213,7 +213,7 @@ def login(auth: FiefAuth):

@cli.group(cls=SectionedHelpGroup, section="Cloud Commands (https://silverback.apeworx.io)")
def cluster():
"""Manage a Silverback hosted application cluster
"""Manage a Silverback hosted bot cluster

For clusters on the Silverback Platform, please provide a name for the cluster to access under
your platform account via `-c WORKSPACE/NAME`"""
Expand Down
4 changes: 2 additions & 2 deletions silverback/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,11 @@ def __init__(self):

class Halt(SilverbackException):
def __init__(self):
super().__init__("App halted, must restart manually")
super().__init__("Bot halted, must restart manually")


class CircuitBreaker(Halt):
"""Custom exception (created by user) that will trigger an application shutdown."""
"""Custom exception (created by user) that will trigger an bot shutdown."""

def __init__(self, message: str):
super(SilverbackException, self).__init__(message)
Loading
Loading