Challenges / Signups grind to a halt

Signups grind to a halt

The signup API hashes every password and stores it while the caller waits. Keep signups answering when the traffic climbs.

HTTP Load Balancer, Instance Group, Table (DynamoDB)Usually solved with: Queue (SQS), Function (Lambda)A 30-second run, scored against 4 goals
Start this challengeIt runs in your browser. No account, nothing to install.

The setup

Signup TrafficRequest Generator
Signup BalancerHTTP Load Balancer
Signup APIInstance Group · 1 instance
AccountsTable (DynamoDB)

Signup API hashes every signup and writes it to the table before it answers. Signup Balancer spreads the traffic over however many instances it is set to run.

The usual answer is a queue with a function behind it: the API hands the signup over and answers, and the function does the hashing and the write. Anything that answers as quickly and stores the same thing will do.

Move the hashing wherever you like, but leave what it computes alone. The goals compare the hashes you stored against the ones this code produces.

The run

Each goal is judged over its own stretch of the run rather than the whole of it.

0 s30 s
  • 5 s · Signups climb to four a second

The code

Ordinary Node against the real AWS SDK, running as real processes in the tab. You can edit any of it once the challenge is open.

Signup API / server.js
import express from 'express';
import { createHash, scryptSync } from 'node:crypto';
import { DynamoDBClient, PutItemCommand } from '@aws-sdk/client-dynamodb';

// Glass Garden gives every instance its own PORT, because they all share one machine here.
// In a real deployment they would each be on their own machine, and could all listen on the same port.
const port = Number(process.env.PORT);
if (!port) {
  throw new Error('PORT is not set');
}

// Set by the table this node is connected to. The endpoint and the credentials the SDK
// needs to reach the local AWS region arrive in the environment too, which is why the
// client below takes no arguments at all. You can see every one of them in the Config tab
const tableName = process.env.DYNAMODB_TABLE;
if (!tableName) {
  throw new Error('DYNAMODB_TABLE is not set. Connect this instance group to one table node.');
}

const dynamodb = new DynamoDBClient({});

// A password hash is meant to be expensive to compute: that is the whole defence if this
// table is ever stolen, because it is what makes guessing a password slow. p is the cost
// knob, and scryptSync is synchronous, so this instance answers nobody while one of these
// runs. The goals compare the hash this cost produces, so try doubling it on a canvas of
// your own and watch what it does to the response time on the generator's chart
const COST = { N: 16384, r: 8, p: 8 };

// Embedded Metric Format: the shape CloudWatch extracts metrics from a log line, and how
// anything running here puts a number on its own Metrics tab
function putMetric(name, value, unit) {
  console.log(
    JSON.stringify({
      _aws: {
        Timestamp: Date.now(),
        CloudWatchMetrics: [
          { Namespace: 'glass-garden', Dimensions: [[]], Metrics: [{ Name: name, Unit: unit }] }
        ]
      },
      [name]: value
    })
  );
}

// Once at startup, so the name is in the Metrics tab before anybody signs up
putMetric('signups', 0, 'Count');

const app = express();
app.use(express.json());

// The load balancer asks for this before it sends any signups here, the way an ALB checks a
// target group. An instance that stops answering it is taken out of the rotation
app.get('/', (req, res) => {
  res.type('text/plain').send('ok\n');
});

app.post('/signup', async (req, res) => {
  const { email = 'nobody@example.com', password = 'hunter2' } = req.body;

  // The caller waits for every line of this. Ask what they are actually waiting for: the
  // signup is decided the moment the request arrives, and nothing needs the stored hash
  // until this person next logs in. Work nobody is waiting for does not belong here

  // A salt is what stops two people who chose the same password having the same stored
  // hash, so a stolen table cannot be cracked all at once. A real store keeps a random
  // one per account and saves it alongside; deriving it from the email instead makes the
  // hash recomputable, which is the only reason this challenge can check what you stored.
  // Move this code, but leave what it computes alone: the goals compare the exact bytes
  const salt = createHash('sha256').update(email).digest();
  const hash = scryptSync(password, salt, 64, COST);
  await dynamodb.send(
    new PutItemCommand({
      TableName: tableName,
      Item: {
        email: { S: email },
        hash: { S: hash.toString('base64') }
      }
    })
  );

  putMetric('signups', 1, 'Count');
  res.status(201).json({ status: 'signed up' });
});

app.listen(port, () => {
  console.log(`Server running on http://localhost:${port}`);
});

More challenges

Your first challenge

Nothing is sending the app traffic, and its code fails on every request. Wire it up, fix one line, and watch a run score itself.

Instance Group

30s run · 2 goals

The concert that oversold

Box Office counts the tickets it has left. Keep the concert from selling more than it has when Box Office scales out.

HTTP Load Balancer, Instance Group, Table (DynamoDB)

30s run · 4 goals