DEV Community

Byunghee Kim
Byunghee Kim

Posted on

Unread badges that would not clear: 59 of my 64 messages were junk

I am building a small chat feature for an HR system. Shared hosting, PHP, no WebSocket, polling every few seconds. Nothing exotic.

Then a bug report landed. This is what I actually wrote, translated from Korean:

"If I open room A, it gets read. So if I close it and open another room, it should stay read. But that doesn't happen — the badge on the room I already opened is still there."

The original had two typos in it. That tells you how many times I had already retyped it.

The symptom

  • Open room A. Badge clears.
  • Open room B. Room A has a badge again.
  • Nobody sent anything. I was the only person on the server.

That last line is what made it worth writing about. Unread messages were appearing in rooms that nobody was in.

Wrong theory #1: the read-state cookie

The engine stores "which messages you have already seen" client side. My first guess was an encoding problem — the value round-tripping badly, so the comparison silently failed.

I checked. The cookie was fine.

Wrong theory #2: a stale high-water mark

Second guess: the "last message ID I have seen" was being captured before the room finished loading, so it lagged one step behind. That would explain a badge reappearing exactly one room-switch later, which is what it looked like.

Also wrong.

Two theories, both plausible, both about why the counter was reading the data wrong. Neither of them questioned the data.

What actually found it

I stopped guessing and counted rows.

SELECT LEFT(text, 14) AS kind, COUNT(*)
FROM chat_messages
GROUP BY kind
ORDER BY 2 DESC;
Enter fullscreen mode Exit fullscreen mode
/channelEnter    31
/channelLeave    28
real messages     5
Enter fullscreen mode Exit fullscreen mode

64 rows in the table. 59 of them were not written by a human.

The cause

The chat engine writes a system message every time you enter or leave a room. There is a config flag that is supposed to turn this off:

'showChannelMessages' => false,
Enter fullscreen mode Exit fullscreen mode

The name is exactly right, and that is the trap. It controls showing. It does not control storing. The rows are still inserted, still owned by a channel, still newer than your last-seen marker — so the unread counter counts them.

Which produces this:

Open room A  ->  system stores "entered" in room A
Open room B  ->  system stores "left"    in room A
             ->  room A now has 2 unread messages
Enter fullscreen mode Exit fullscreen mode

The act of switching rooms was generating the unread messages. I was the one creating them, by clicking around looking for the bug.

The fix

The engine is a third-party library and I did not want to patch it, so I overrode one method:

function insertChatBotMessage($channelID, $messageText, $ip = null, $mode = 0) {
    if (!$this->getConfig('showChannelMessages')) {
        $t = (string) $messageText;
        if (strpos($t, '/channelEnter') === 0 || strpos($t, '/channelLeave') === 0) {
            return;                          // not shown -> not stored
        }
    }
    return parent::insertChatBotMessage($channelID, $messageText, $ip, $mode);
}
Enter fullscreen mode Exit fullscreen mode

Nine lines. The rule it encodes is one sentence: if we are never going to display it, do not write it.

Two follow-ups were needed, and I would have missed both if I had stopped at the first one:

  1. Exclude system rows from the unread count — the junk already in the table does not delete itself.
  2. Exclude them from search too. Otherwise searching for a username returns a wall of /channelEnter username. Same rows, second symptom, different screen.

The effect

before after
unread per room 16 · 5 · 8 · 3 · 6 · 6 · 4 · 2 2 · 1 · 0 · 0 · 0 · 0 · 0 · 0
junk share of table 92% 0 new rows

The second row mattered more than the first. On shared hosting the messages table is the only thing that grows without a ceiling, and I had been sizing a weekly archive job around it. I was designing storage policy for a table that was 92% garbage.

What I would keep

A flag named after the UI can quietly own the database. showChannelMessages reads as a display setting and behaves as one — and still writes rows forever. If a boolean controls both display and persistence, the name will only ever describe one of them.

When a counter is wrong, count the rows before theorizing. My two wrong theories were both about the reading side, because that is where the visible code was. The query took thirty seconds and ended the argument.

A bug that only shows up when you go looking for it is not rare. Switching rooms was both the diagnostic action and the cause. That is worth remembering the next time something only reproduces while you are watching.

Top comments (1)

Collapse
 
tacckim profile image
Byunghee Kim

One thing I keep coming back to: showChannelMessages is not a badly
named flag. It does exactly what it says. The problem is that it says
nothing about storage, and I assumed it did.

Has anyone else hit a config flag that only owned half of what you
thought it owned? I would like to collect a few.