Overview

Infrastructure

My go-to setup for running infrastructure services locally is docker-compose. I’m using postgres as a database and grafana for some dashboards.

  • docker-compose setup for infrastructure
    • postgres
    • grafana

Since I also developed a dashboard for the agents and wanted to reuse some helpers, I added profiles to docker-compose. If I want to start the stack for the agent, I run this command:

docker compose --profile agent up

agent architecture

I like coding in a functional style and use cats-effect as the runtime effect system. I had trouble with the openapi generator for scala, since it didn’t work in some cases or didn’t produce code that matches my coding style.

Luckily the folks at Disney created a nice ecosystem around smithy that worked very well for code generation. I’m using smithy-translate to convert the openapi-spec into a smithy model (with minor fixes) and from there I use smithy4s to generate the client. Shout out to the authors (especially @Baccata) who were very helpful in their discord channel when I ran into problems.

The library is awesome and generates code for an http4s-client that is easily extensible:

  • adding authorization via bearer-token
  • enabling request logging
  • adding rate-limiting with priority upperbound
  • easy to fix
    • had to change the behavior of posting an empty request; client was sending null but st-server didn’t like that

libraries used

libdescription
smithy-translatetool for converting openapi-sepc to smithy model
smithy4splugin for generating http4s client/server from smithy model
http4shttp client/server
cats-effectruntime effect system
upperboundrate limiting for cats-effect IO
circejson (de)serialization
enumeratumbetter enums for scala
monocleoptics library for easier manipulation of nested properties inside immutable datastructures
flywaydatabase migration
doobiefunctional JDBC layer
fs2functional streaming

bootstrapping of agent

The startup of the agent happens with the unregistered instance of the client. It doesn’t use a bearer-token.

database migration

When I start the agent, the first thing I check is the status endpoint. I take the reset-date from its response, parse it and use it as a schema name in postgres. e.g. this response

{
  "status": "SpaceTraders is currently online and available to play",
  "version": "v2.1.5",
  "resetDate": "2024-02-25"
}

becomes reset_2024_02_25.

I use flyway to migrate my database schema (it also creates one if it doesn’t exist). From there I have a schema ready to be used and instantiate a hikari connection-pool with the connectionstring.

def transactorResource(connectionString: String,
  username: String,
  password: String,
  schema: String): Resource[IO, HikariTransactor[IO]] =
  for {
    ce <- ExecutionContexts.fixedThreadPool[IO](32)
    xa <- HikariTransactor
      .newHikariTransactor[IO](
        "org.postgresql.Driver",
        connectionString,
        username,
        password,
        ce,
      )
      .evalTap(tx =>
        tx.configure { ds =>
          Async[IO].delay {
            ds setSchema schema
          }
        }
      )
  } yield xa

After that step I modify the readonly grafana user to have that schema as default in its search path.

-- Grant usage and all privileges on the schema to the user

GRANT USAGE ON SCHEMA reset_2024_02_25 TO grafana_user;

GRANT SELECT ON ALL TABLES IN SCHEMA reset_2024_02_25 TO grafana_user;

-- Grant all privileges on new tables in the schema to the user
ALTER DEFAULT PRIVILEGES IN SCHEMA reset_2024_02_25
    GRANT ALL ON TABLES TO grafana_user;

-- Set the default search path to the new schema
ALTER USER grafana_user SET search_path TO reset_2024_02_25;

That way I don’t need to update the grafana dashboard with new connectionstrings.

registering my agent if necessray

On startup I check my registration table to see if I already registered my agent and use its token. If not I use the infos from env vars to register. I added a 10s delay before registering, because I forgot to change some settings in the past ;-)

IO.println(
  s"""Registering agent with these details:
     |  Symbol: ${registrationInputBody.symbol}
     |  E-Mail: ${registrationInputBody.email}
     |  Faction: ${registrationInputBody.faction}
     |
     |You have 10s to cancel if you want to change anything.""".stripMargin) *>
  IO.sleep(10.seconds) /* *> ... */

background collection of “big” data

In my codebase I use two different clients that talk to the st-servers. One with a high priority that controls the ships or loads essential data and another one with a low priority that collects data in the background (like systems, waypoints, jump-gates etc.)

The low priority client is used in a streaming context (with fs2) to prevent excessive memory usage and provide back-pressure. It’s also straightforward to ingest responses from the paginated endpoints.

Here’s an example in where I collect the waypoints of a system:

def loadSystemWaypointsPaginated(systemSymbol: String): Stream[IO, GetSystemWaypoints200] = {
  Stream.eval(getListWaypointsPageIO(systemSymbol, PaginationInput(page = Some(1), limit = Some(20))))
    .flatMap { first =>
      val rest = PaginationHelper.calcRestRequests(first.body.meta)
      val restStream: Stream[IO, GetSystemWaypoints200] = Stream
        .iterable(rest)
        .lift[IO]
        .evalMap(page => getListWaypointsPageIO(systemSymbol, page))

      Stream(first) ++ restStream
    }
}
  • collect first page
  • take Meta object from response of the first page to
  • calculate the numbers of the remaining pages to create a stream
  • concat the first page with the rest of the stream