The import of the tenders is the longest path in the Kladde. A scheduled job fetches the notices from TED at night, stores each one raw in Blob Storage and sends a reference to it through Service Bus. A worker reads the message, fetches the notice from the blob, writes it to SQL and announces it on the topic tender-events. Next to it runs the CSV import of the contacts through Event Grid. Every part comes from modules 3.5 and 3.6. In this lesson the whole thing runs in the new subscription, and you prove with a drill that it counts correctly even when messages arrive twice or are broken.

job-kladde-import (02:30 UTC)
   │ query TED (timeout, retry for GET only, 3.9)
   ├─► tenders-raw/<date>/<notice>.json             Blob Storage, raw (3.6.3)
   └─► tender-import  { "blob": "..." }             Service Bus, only the reference (claim check, 3.5.1)
          │ MessageId = <notice>:<version>, duplicate detection within the time window
          ▼
       Worker (KEDA, down to zero, 3.5.7)
          ├─ unreadable ────────► dead letter with a reason (3.5.5)
          ├─ already known ─────► complete, nothing more (3.5.4)
          └─ new ───────────────► SQL (unique key), tender-events, metrics

imports/*.csv ──► Event Grid ──► contact-import ──► Worker ──► contacts (3.5.6)

What exists only at creation

Some settings can only be set when a resource is created. If you want them later, you have to delete the resource and create it again, with everything in it. For the import, the most important of these is the duplicate detection of Service Bus: you can switch it on only when you create a queue, and only in the Standard or Premium tier (3.5.4). If tender-import had been created without it in the old subscription, a Bicep file that asks for it afterward would only have produced an error. In the empty subscription the queue is created fresh, and this time you set it correctly.

The time window of the duplicate detection, in which Service Bus remembers a MessageId, can be changed later, though, anywhere between 20 seconds and 7 days. The Kladde uses ten minutes in prod. In dev and test it uses one minute; the drill below shows why.

bicep
// Excerpt from infra/main.bicep: the containers, queues and the Event Grid subscription of the imports, created fresh in
// the empty subscription. Some settings exist only at creation; this is the moment to get them right.
@allowed(['dev', 'test', 'prod'])
param environmentName string

param location string = resourceGroup().location
param serviceBusName string
param storageAccountName string

var serviceBusDataSender = '69a216fc-b8fb-44d8-bc22-1f3c2cd27a39'

resource serviceBus 'Microsoft.ServiceBus/namespaces@2024-01-01' existing = {
  name: serviceBusName
}

resource storage 'Microsoft.Storage/storageAccounts@2025-06-01' existing = {
  name: storageAccountName
}

resource blobService 'Microsoft.Storage/storageAccounts/blobServices@2025-06-01' existing = {
  parent: storage
  name: 'default'
}

resource rawContainer 'Microsoft.Storage/storageAccounts/blobServices/containers@2025-06-01' = {
  parent: blobService
  name: 'tenders-raw'
}

resource importsContainer 'Microsoft.Storage/storageAccounts/blobServices/containers@2025-06-01' = {
  parent: blobService
  name: 'imports'
}

// Duplicate detection can only be switched on when a queue is created. In dev and test the window is one minute,
// so the import drill can send a late duplicate without waiting ten minutes.
resource tenderImport 'Microsoft.ServiceBus/namespaces/queues@2024-01-01' = {
  parent: serviceBus
  name: 'tender-import'
  properties: {
    requiresDuplicateDetection: true
    duplicateDetectionHistoryTimeWindow: environmentName == 'prod' ? 'PT10M' : 'PT1M'
    maxDeliveryCount: 5
    lockDuration: 'PT1M'
    deadLetteringOnMessageExpiration: true
  }
}

resource contactImport 'Microsoft.ServiceBus/namespaces/queues@2024-01-01' = {
  parent: serviceBus
  name: 'contact-import'
  properties: {
    maxDeliveryCount: 5
    lockDuration: 'PT1M'
    deadLetteringOnMessageExpiration: true
  }
}

// A new CSV in the container imports becomes a message in contact-import (lesson 3.5.6). Event Grid delivers with its
// own identity; the storage account has no keys any more, and Service Bus none either.
resource blobEvents 'Microsoft.EventGrid/systemTopics@2025-02-15' = {
  name: 'evgt-kladde-${environmentName}-storage'
  location: location
  identity: {
    type: 'SystemAssigned'
  }
  properties: {
    source: storage.id
    topicType: 'Microsoft.Storage.StorageAccounts'
  }
}

resource eventsMaySend 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
  name: guid(contactImport.id, blobEvents.id, serviceBusDataSender)
  scope: contactImport
  properties: {
    roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', serviceBusDataSender)
    principalId: blobEvents.identity.principalId
    principalType: 'ServicePrincipal'
  }
}

resource csvToQueue 'Microsoft.EventGrid/systemTopics/eventSubscriptions@2025-02-15' = {
  parent: blobEvents
  name: 'contact-csv-to-queue'
  properties: {
    eventDeliverySchema: 'CloudEventSchemaV1_0'
    deliveryWithResourceIdentity: {
      identity: {
        type: 'SystemAssigned'
      }
      destination: {
        endpointType: 'ServiceBusQueue'
        properties: {
          resourceId: contactImport.id
        }
      }
    }
    filter: {
      includedEventTypes: ['Microsoft.Storage.BlobCreated']
      subjectBeginsWith: '/blobServices/default/containers/${importsContainer.name}/blobs/'
      subjectEndsWith: '.csv'
    }
    retryPolicy: {
      maxDeliveryAttempts: 10
      eventTimeToLiveInMinutes: 1440
    }
  }
  dependsOn: [eventsMaySend] // the role on the destination first, then the subscription that uses it
}

output rawContainerName string = rawContainer.name

What is in this file and why:

  • The containers tenders-raw and imports are created with the storage account from 3.10.4. They appear here because the import needs them.
  • maxDeliveryCount: 5: a message that has not been completed five times lands in the dead letter queue by itself. The poison message of the drill, though, is not supposed to be tried five times at all; the worker puts it there at once, with a reason.
  • Event Grid delivers with an identity of its own (deliveryWithResourceIdentity). The system topic gets a system-assigned identity, and that identity gets Azure Service Bus Data Sender on exactly that one queue. This is one of the roles the deploy identity may assign according to 3.10.2, and the recipient is a service principal; so the condition lets the assignment through.
  • dependsOn: [eventsMaySend]: Microsoft describes the order like this: first the role on the destination, then the subscription that delivers with the identity. Without the role, Event Grid cannot write to the queue. The subscription nowhere refers to the role assignment, so Bicep cannot see this dependency by itself and needs the dependsOn.
  • The filter takes only BlobCreated for files ending in .csv in the container imports. Without subjectBeginsWith, every new file in tenders-raw would also create a message in contact-import, hundreds every night.
  • CloudEventSchemaV1_0 is the format the worker has been reading since 3.5.6.

The topic tender-events and its subscriptions from 3.5.3 you carry over into the same file as you built them.