Skip to content
academia.sh

Lesson 16 / 21

Notifications

The notification class, the notification channel and the recipient's channel preference; the chosen synchronous channels running and the unchosen channel not being wired; the same channels being deferred when the notification is marked as queued.

Published Updated

In this lesson

  • Separates the notification class, the notification channel and the recipient's preference
  • Measures that the channels the recipient chose ran and the channel the recipient did not choose never entered the path
  • Shows how marking a notification as queued leaves the same channels outside the request
Contents

In the Events and Listeners lesson the index was updated when a post was saved; telling the user is a separate side effect. There can be more than one way to tell, and not everyone wants the same one. This lesson asks: by which routes does a notification go, and is a route the recipient did not choose called?

The notification class defines what is said and through which notification channels it may go. A notification channel is the part that delivers the notification somewhere: mail, database and SMS are the three channels in this lesson. The recipient’s channel preference says which of them will be used. The notification class lists all three channels; the recipient chooses one or two. The notifier places the intersection of the two on the path. A channel outside the intersection is neither called nor placed on the path.

AS4. All three channels are array records: the mail channel does not connect to a mail server and the SMS channel does not connect to a carrier; each channel appends the sent text to its own array, and what is measured is whether there is a record in that array. As in the earlier lessons, the model is in the same process. Failed delivery, resending and throttling are not modeled. Whether a notification is queued is a field of the notification class; the notifier looks at that field.

The Measurement Core

The same measurement is used in every lesson of the course. ran.php records whether an expected part ran in this request; the file is given verbatim in every lesson, and it has no output block, since it is only saved to disk. Ledger keeps three lists: the parts placed on the request’s path, the parts called within the request, and the parts left outside the request. classify() checks them in order. The decision order is classify() itself: ran, deferred, skipped, not wired. The developer names the part; what is measured are the parts the developer expects to run in this request.

<?php
// ran.php — did an expected part run in this request?
declare(strict_types=1);

final class Ledger {
    public static array $path = [];        // parts placed on the request's path
    public static array $ran = [];         // parts called within the request
    public static array $deferred = [];    // parts left outside the request
    public static function newRequest(): void { self::$path = self::$ran = self::$deferred = []; }
    public static function onPath(string $part): void { self::$path[] = $part; }
    public static function ran(string $part): void { self::$ran[] = $part; }
    public static function defer(string $part): void { self::$deferred[] = $part; }
}

function classify(string $part): string {
    return match (true) {
        in_array($part, Ledger::$ran, true) => 'ran',
        in_array($part, Ledger::$deferred, true) => 'deferred',
        in_array($part, Ledger::$path, true) => 'skipped',
        default => 'not wired',
    };
}

In this lesson each channel is a part: mail, database and sms. The notifier places the channel the recipient chose on the path and, if the notification is synchronous, calls it and marks it with Ledger::ran(); if it is queued, it leaves it on the queue and marks it with Ledger::defer(). No call is made for a channel that was not chosen.

The Notification, the Channel and the Recipient

notification.php is the whole slice. Recipient carries a name and channel preferences. CommentNotification carries a comment title, the names of the three channels (channels()) and the queued flag. The NotificationChannel interface has two methods, name() and send(); ArrayChannel implements it with an array of records. Notifier::send() walks the notification’s channels; it skips the ones that are not in the recipient’s preferences (continue), places the others on the path, and either calls them or leaves them on the queue. process() runs what is on the queue. CommentController::postComment() saves the comment and hands it to the notifier.

<?php
// notification.php — the notification class, the channels and the recipient preference
declare(strict_types=1);
require_once 'ran.php';

final class Recipient {
    public function __construct(public readonly string $name, public readonly array $preferences) {}
}

final class CommentNotification {
    public function __construct(public readonly string $title, public readonly bool $queued = false) {}
    public function channels(): array { return ['mail', 'database', 'sms']; }
    public function text(): string { return "New comment: {$this->title}"; }
}

interface NotificationChannel {
    public function name(): string;
    public function send(Recipient $recipient, CommentNotification $notification): void;
}

final class ArrayChannel implements NotificationChannel {   // every channel is an array of records
    public array $records = [];
    public function __construct(private string $name) {}
    public function name(): string { return $this->name; }
    public function send(Recipient $recipient, CommentNotification $notification): void {
        $this->records[] = $recipient->name . ': ' . $notification->text();
    }
}

final class Notifier {
    private array $channels = [];
    private array $queue = [];
    public function addChannel(NotificationChannel $channel): void { $this->channels[$channel->name()] = $channel; }
    public function channel(string $name): ArrayChannel { return $this->channels[$name]; }

    public function send(Recipient $recipient, CommentNotification $notification): void {
        foreach ($notification->channels() as $name) {
            if (!in_array($name, $recipient->preferences, true)) continue;   // the recipient did not choose it
            Ledger::onPath($name);
            if ($notification->queued) {
                Ledger::defer($name);
                $this->queue[] = [$this->channels[$name], $recipient, $notification];
            } else {
                Ledger::ran($name);
                $this->channels[$name]->send($recipient, $notification);
            }
        }
    }

    public function process(): int {            // worker: runs the queued notifications
        $count = count($this->queue);
        while ($entry = array_shift($this->queue)) { [$channel, $recipient, $notification] = $entry; $channel->send($recipient, $notification); }
        return $count;
    }
}

final class CommentController {
    public function __construct(private Notifier $notifier) {}
    public function postComment(Recipient $recipient, CommentNotification $notification): array {
        Ledger::newRequest();
        Ledger::onPath('postComment');
        Ledger::ran('postComment');
        $this->notifier->send($recipient, $notification);
        return [201, 'comment posted'];
    }
}

function buildNotifier(): Notifier {
    $notifier = new Notifier();
    foreach (['mail', 'database', 'sms'] as $name) $notifier->addChannel(new ArrayChannel($name));
    return $notifier;
}

The Channel the Recipient Chose

The first measurement sets up two recipients. Alice has chosen mail and database; Bob has chosen only SMS. The same comment notification is sent to both.

<?php
// preferences.php — two recipients, two different channel preferences
require_once 'notification.php';

$recipients = [
    new Recipient('alice', ['mail', 'database']),
    new Recipient('bob', ['sms']),
];

foreach ($recipients as $recipient) {
    $notifier = buildNotifier();
    $response = (new CommentController($notifier))->postComment($recipient, new CommentNotification('Hello'));
    echo "{$recipient->name} (", implode(', ', $recipient->preferences), ") -> {$response[0]}\n";
    foreach (['mail', 'database', 'sms'] as $name) {
        printf("  %-11s records: %d  %s\n", $name, count($notifier->channel($name)->records), classify($name));
    }
}
alice (mail, database) -> 201
  mail        records: 1  ran
  database    records: 1  ran
  sms         records: 0  not wired
bob (sms) -> 201
  mail        records: 0  not wired
  database    records: 0  not wired
  sms         records: 1  ran

This lesson’s observation is in the first recipient: both of the chosen notification channels are synchronous, so they ran, and each has one record. The response is 201. The sms channel that Alice did not choose was not wired: the notification class listed that channel, but the notifier never placed it on the path, because it was not in the recipient’s preference. The record count of 0 does not say that the SMS failed; it says that it was never tried.

The second recipient is the contrast and shows the same rule from the other side. Bob chose only sms; sms ran, and mail and database were not wired. The same notification class, the same notifier, the same controller; only the recipient’s preference changed. Note that a channel that was not chosen is not skipped: a skipped part is one that entered the path and was not called, but a channel that was not chosen was never placed on the path.

The Bounding Measurement

What happens to the same channels if the notification is marked as queued? The same comment notification is sent to Alice, but this time with queued: true. The measurement counts the channels right after the response and again after the worker has run.

<?php
// queued_notification.php — when the notification is marked as queued
require_once 'notification.php';

$recipient = new Recipient('alice', ['mail', 'database']);
$notifier = buildNotifier();
$response = (new CommentController($notifier))->postComment($recipient, new CommentNotification('Hello', queued: true));
echo "queued notification -> {$response[0]}\n";
foreach (['mail', 'database', 'sms'] as $name) {
    printf("  %-11s records: %d  %s\n", $name, count($notifier->channel($name)->records), classify($name));
}
echo '  worker ran: ', $notifier->process(), "\n";
foreach (['mail', 'database', 'sms'] as $name) {
    printf("  %-11s records: %d\n", $name, count($notifier->channel($name)->records));
}
queued notification -> 201
  mail        records: 0  deferred
  database    records: 0  deferred
  sms         records: 0  not wired
  worker ran: 2
  mail        records: 1
  database    records: 1
  sms         records: 0

The response is the same, 201. The two chosen channels were now deferred: they were placed on the path, left outside the request, and their record counts at response time are 0. The channel that was not chosen is still not wired. process() ran two jobs, and afterward mail and database have one record each; SMS still has 0 records, because no choice required it to enter the queue. Marking the notification as queued did not change the preference rule; it only moved the moment at which the chosen channels run to after the response.

This is the distinction from the Queues and Jobs lesson seen once more: being put on the queue means that the record exists only when the worker has run. An application that believes the notification has gone should look at the record after the worker, not at the moment it sees the response.

Summary

  • This lesson’s outcome is ran: the mail and database channels that Alice chose were synchronous, were called within the response and each left a record.
  • The notification class lists the channels, the recipient’s preference chooses, and the notifier places the intersection of the two on the path.
  • Its contrast: the sms channel that Alice did not choose, and the mail and database channels that Bob did not choose, were not wired; the record count is 0 and the channel was never tried.
  • In the bounding measurement, when the notification was marked as queued the chosen channels were deferred, process() ran two jobs, and the records appeared only afterward.
  • A channel that was not chosen is not skipped: a skipped part enters the path, and a channel that was not chosen does not.

Next Step

The notification channels have to repeat the same query on every request: the recipient’s preference, a comment count, the ordering of a list. Computing the same result again has a cost, and sometimes skipping it is enough. The Caching lesson stores a computed value under a key and measures whether the computation is skipped on a hit.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close