iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🛡️

Preventing Duplicate LINE Bot Responses: Atomic Firestore Operations and a 3-Layer Guard Design

に公開

Introduction

When sending surveys (using button templates) via the LINE Messaging API, users can tap choices multiple times. This triggers a postback event for every tap, leading to issues such as multiple answer tags being assigned and duplicate entries being saved.

Furthermore, if a user manually sends a text message like "Yes." after answering, the scenario might restart via the message event.

This article explains a design and implementation that solves these problems using atomic duplicate prevention via Firestore's create() and by placing guards at three entry points.

What you will gain from this article:

  • An atomic duplicate prevention pattern using Firestore create().
  • Deterministic document ID design using SHA-256 hashing.
  • A migration strategy that maintains backward compatibility with legacy data.
  • Guard design for the three entry points of LINE webhooks.

Overview

Here is an overview of the three paths where duplicate answers occur and the guards placed for each.

User Action               Webhook Event            Guard Location
────────────────────────────────────────────────────────────────────────────
① Tap choice multiple times → postback(survey_answer) → handleSurveyAnswer()
② Re-tap Flex image       → postback(flex_tap)      → handleFlexImageTap()
③ Send text (e.g., "Yes")  → message                 → triggerScenariosByEvent()
                                                         ↓
                                                   Firestore Presence Check
                                                   Already answered → Skip
                                                   Not answered     → Proceed

Design Points

Point 1: Why use create()?

There are several ways to prevent duplicate writes in Firestore.

Method Pros Cons
set() + pre-query Simple implementation Vulnerable to race conditions (two requests query simultaneously → both write)
Transaction Strong consistency Performance cost, requires retry logic
create() + Deterministic ID Atomic, fast Requires document ID design
Security Rules Client-side control possible Not needed if writing only via server (Admin SDK)

Point 2: Designing Deterministic Document IDs

The key used for duplicate detection is the combination of scenarioId + nodeId + lineUserId. If this can be used as the Firestore document ID, the exclusive control of create() will work.

However, simple string concatenation can lead to collisions in ID delimiters.

// Dangerous example: Delimiter "_" might exist within IDs
"scenario_1" + "_" + "node_2" + "_" + "user_3"
// Indistinguishable from:
"scenario" + "_" + "1_node_2" + "_" + "user_3"

Therefore, we generate a deterministic ID using a SHA-256 hash.

import { createHash } from 'crypto';

function buildDeterministicDocId(
  scenarioId: string,
  nodeId: string,
  lineUserId: string
): string {
  // Using a NUL character as a delimiter ensures unique boundaries for each field
  const payload = [scenarioId, nodeId, lineUserId].join('\0');
  return createHash('sha256').update(payload).digest('hex').slice(0, 40);
}

Point 3: Backward Compatibility with Legacy Data

Existing data is saved with random IDs (nanoid). Since migrating to new deterministic IDs in bulk is high-risk, we adopt a strategy of checking both during reads.

Save Flow:
  1. Check for legacy data (scenarioId + nodeId + lineUserId query)
  2. If found → false (already answered)
  3. If not found → execute create() with deterministic ID
  4. If ALREADY_EXISTS → false
  5. If successful → true

Presence Check:
  1. doc.get() with deterministic ID (single document reference, fast path)
  2. If exists → true
  3. If not → fallback to legacy query

Implementation Tips

Tip 1: saveIfNotExists — Atomic Save Pattern

By providing an interface that returns a boolean for "whether it was saved," the calling logic remains simple.

// Repository interface
interface ISurveyResponseRepository {
  save(response: SurveyResponse): Promise<void>;
  saveIfNotExists(response: SurveyResponse): Promise<boolean>;
  // Check answer existence for a specific node (fast path + legacy fallback)
  existsByScenarioNodeAndUser(
    scenarioId: string,
    nodeId: string,
    lineUserId: string
  ): Promise<boolean>;
  // Check answer existence for the whole scenario (to prevent re-triggering)
  existsByScenarioAndUser(
    scenarioId: string,
    lineUserId: string
  ): Promise<boolean>;
}

On the implementation side, we branch based on the error code thrown by Firestore's create().

async saveIfNotExists(response: SurveyResponse): Promise<boolean> {
  const col = this.getCollection();

  // Step 1: Check for legacy (random-ID) documents
  const legacySnap = await col
    .where('scenarioId', '==', response.scenarioId)
    .where('nodeId', '==', response.nodeId)
    .where('lineUserId', '==', response.lineUserId)
    .limit(1)
    .get();
  if (!legacySnap.empty) return false;

  // Step 2: Atomic create with deterministic ID
  const docId = buildDeterministicDocId(
    response.scenarioId,
    response.nodeId,
    response.lineUserId
  );
  try {
    await col.doc(docId).create(response);
    return true;
  } catch (err: unknown) {
    if (
      typeof err === 'object' &&
      err !== null &&
      'code' in err &&
      (err as { code: number }).code === 6
    ) {
      return false; // Already exists
    }
    throw err;
  }
}

Tip 2: Fast Path for Presence Checks

existsByScenarioNodeAndUser first attempts a get() using the deterministic ID. Since this is a single document reference, it is cheaper and faster than a query.

async existsByScenarioNodeAndUser(
  scenarioId: string,
  nodeId: string,
  lineUserId: string
): Promise<boolean> {
  const col = this.getCollection();

  // Fast path: single document get (cheaper than query)
  const docId = buildDeterministicDocId(scenarioId, nodeId, lineUserId);
  const docSnap = await col.doc(docId).get();
  if (docSnap.exists) return true;

  // Slow path: query for legacy documents
  const snap = await col
    .where('scenarioId', '==', scenarioId)
    .where('nodeId', '==', nodeId)
    .where('lineUserId', '==', lineUserId)
    .limit(1)
    .get();
  return !snap.empty;
}

As new data accumulates, the hit rate for the fast path will increase, and the frequency of legacy query execution will naturally decrease.

Tip 3: Placing the 3-Layer Guard

Duplicate access to the survey occurs through three paths, so guards are placed for each.

① Postback Answer Handler (Most Important)

Simply replacing save() with saveIfNotExists() allows conditional execution of tag assignment and the next node.

  async handleSurveyAnswer(lineUserId, answer) {
    // ... validate scenario/node/choice ...

-   await this.upsertUser({ lineUserId, tags: [surveyTag, ...customTags] });
-   await this.surveyResponseRepo.save(response);
+   const saved = await this.surveyResponseRepo.saveIfNotExists(response);
+   if (!saved) return; // Already answered — skip tag assignment
+
+   await this.upsertUser({ lineUserId, tags: [surveyTag, ...customTags] });

    const nextNodeId = findNextNodeId(runtime, node.id, choice.value);
    if (nextNodeId) {
      await this.executeFromNode(runtime, nextNodeId, lineUserId);
    }
  }

② Flex Image Tap Handler

If an already-answered survey is re-tapped via a Flex image, scenario restart is skipped.

  async handleFlexImageTap(lineUserId, postbackData) {
    const payload = postbackData.replace('flex_tap:', '');
    if (payload.startsWith('start_survey:')) {
      const scenarioId = payload.replace('start_survey:', '');
+     const alreadyAnswered = await this.surveyResponseRepo
+       .existsByScenarioAndUser(scenarioId, lineUserId);
+     if (!alreadyAnswered) {
        // ... start scenario ...
+     }
    }
    // flex_image_tap tag is still applied regardless
    await this.upsertUser({ lineUserId, tags: ['flex_image_tap'] });
  }

③ Message Event Trigger

This prevents the scenario from restarting when text is sent. However, because checking every scenario affects performance, we only check scenarios that contain survey nodes.

hasSurveyNode is a simple helper that returns whether the scenario's node array contains a survey type (runtime.nodes.some(n => n.type === 'survey')).

  async triggerScenariosByEvent(runtimes, eventType, lineUserId) {
    for (const runtime of runtimes) {
      const triggers = findTriggerNodes(runtime, eventType);
      if (triggers.length === 0) continue;

+     if (hasSurveyNode(runtime)) {
+       const answered = await this.surveyResponseRepo
+         .existsByScenarioAndUser(runtime.id, lineUserId);
+       if (answered) continue;
+     }

      for (const triggerNode of triggers) {
        // ... execute from next node ...
      }
    }
  }

Pitfalls

Error Type of Firestore create()

Errors from the Firestore Admin SDK (internally @google-cloud/firestore) are compatible with Error, but behavior using instanceof for type checking can vary between SDK versions. It is more stable to check the code property of the gRPC status code.

// ❌ instanceof behavior can change with SDK versions
catch (err) {
  if (err instanceof FirestoreError && err.code === 'already-exists') { ... }
}

// ✅ Determine via gRPC status code (Admin SDK)
catch (err: unknown) {
  if (
    typeof err === 'object' &&
    err !== null &&
    'code' in err &&
    (err as { code: number }).code === 6
  ) {
    return false;
  }
  throw err;
}

Administrator Manual Sends are Not Guarded

Functions where administrators "send this scenario to all users" from the dashboard are intentionally excluded from the guards, as there are cases where manual re-sending is intended. The guards are limited to webhook events triggered by users.

Conclusion

Summary of Design Decisions

Decision Choice Reason
Method for duplicate prevention create() + Deterministic ID Atomic, no locks, fast
Document ID generation SHA-256 hash (NUL-delimited) Avoid collisions, fits Firestore ID limits
Legacy data support Check both during reads Avoid risks of bulk migration
Guard Scope 3 Webhook-based entry points Excludes admin manual sends
Filter for checking hasSurveyNode() Avoids unnecessary DB access

Key Learnings

  1. Firestore create() is an excellent, lightweight exclusive control — Lighter than transactions and resilient to race conditions.
  2. Hash-based deterministic IDs are effective during transition phases — They allow coexistence with existing data while enabling gradual migration.
  3. Place guards at the "entry" points — Checking at the beginning of each entry point, rather than mid-process, keeps the logic simple.
  4. Don't apply guards everywhere — By narrowing checks to cases where they are needed (using hasSurveyNode()), performance is maintained.

Implementation Checklist

  • Are you catching gRPC code 6 in create() error handling?
  • Will separator collisions occur in the deterministic ID generation logic?
  • Is a fallback query for legacy data (random IDs) included?
  • Are paths that shouldn't be guarded (like admin manual sends) excluded?
  • Do scenarios without survey nodes avoid unnecessary checks?
GitHubで編集を提案

Discussion