fix: various bugs in SLA calculation

prevents multiple update queries unnecessarily on applied sla table.
clear next sla deadline in conversations properly when there's no deadline to be met.
uses the new status column in the applied sla table to determine if the sla is still active and has to be calculated again.
This commit is contained in:
Abhinav Raut
2025-03-12 02:45:17 +05:30
parent fc0e0a8fff
commit 45541c221a
6 changed files with 85 additions and 44 deletions

View File

@@ -57,15 +57,15 @@
<div class="flex items-center mt-2 space-x-2">
<SlaBadge
v-if="conversation.first_response_due_at"
:dueAt="conversation.first_response_due_at"
v-if="conversation.first_response_deadline_at"
:dueAt="conversation.first_response_deadline_at"
:actualAt="conversation.first_reply_at"
:label="'FRD'"
:showExtra="false"
/>
<SlaBadge
v-if="conversation.resolution_due_at"
:dueAt="conversation.resolution_due_at"
v-if="conversation.resolution_deadline_at"
:dueAt="conversation.resolution_deadline_at"
:actualAt="conversation.resolved_at"
:label="'RD'"
:showExtra="false"

View File

@@ -27,8 +27,8 @@
<div class="flex justify-start items-center space-x-2">
<p class="font-medium">First reply at</p>
<SlaBadge
v-if="conversation.first_response_due_at"
:dueAt="conversation.first_response_due_at"
v-if="conversation.first_response_deadline_at"
:dueAt="conversation.first_response_deadline_at"
:actualAt="conversation.first_reply_at"
:key="conversation.uuid"
/>
@@ -46,8 +46,8 @@
<div class="flex justify-start items-center space-x-2">
<p class="font-medium">Resolved at</p>
<SlaBadge
v-if="conversation.resolution_due_at"
:dueAt="conversation.resolution_due_at"
v-if="conversation.resolution_deadline_at"
:dueAt="conversation.resolution_deadline_at"
:actualAt="conversation.resolved_at"
:key="conversation.uuid"
/>

View File

@@ -61,10 +61,11 @@ type Conversation struct {
SLAPolicyID null.Int `db:"sla_policy_id" json:"sla_policy_id"`
SlaPolicyName null.String `db:"sla_policy_name" json:"sla_policy_name"`
NextSLADeadlineAt null.Time `db:"next_sla_deadline_at" json:"next_sla_deadline_at"`
FirstResponseDueAt null.Time `db:"first_response_deadline_at" json:"first_response_deadline_at"`
ResolutionDueAt null.Time `db:"resolution_deadline_at" json:"resolution_deadline_at"`
SLAStatus null.String `db:"sla_status" json:"sla_status"`
BCC json.RawMessage `db:"bcc" json:"bcc"`
CC json.RawMessage `db:"cc" json:"cc"`
FirstResponseDueAt null.Time `db:"-" json:"first_response_due_at"`
ResolutionDueAt null.Time `db:"-" json:"resolution_due_at"`
PreviousConversations []Conversation `db:"-" json:"previous_conversations"`
Total int `db:"total" json:"-"`
}

View File

@@ -28,6 +28,9 @@ type AppliedSLA struct {
ResolutionDeadlineAt time.Time `db:"resolution_deadline_at"`
FirstResponseBreachedAt null.Time `db:"first_response_breached_at"`
ResolutionBreachedAt null.Time `db:"resolution_breached_at"`
FirstResponseAt null.Time `db:"first_response_at"`
ResolvedAt null.Time `db:"resolved_at"`
FirstResponseMetAt null.Time `db:"first_response_met_at"`
ResolutionMetAt null.Time `db:"resolution_met_at"`
ConversationFirstResponseAt null.Time `db:"conversation_first_response_at"`
ConversationResolvedAt null.Time `db:"conversation_resolved_at"`
}

View File

@@ -43,14 +43,12 @@ next_sla_deadline_at = LEAST(
WHERE id IN (SELECT conversation_id FROM new_sla);
-- name: get-pending-slas
-- Get all the applied SLAs that are not yet breached or met and is also set on the conversation.
-- This make sure when SLA is changed, we don't update the breached or met status of the previous SLA.
SELECT a.id, a.first_response_deadline_at, c.first_reply_at as first_response_at,
a.resolution_deadline_at, c.resolved_at as resolved_at
-- Get all the applied SLAs (applied to a conversation) that are pending
SELECT a.id, a.first_response_deadline_at, c.first_reply_at as conversation_first_response_at,
a.resolution_deadline_at, c.resolved_at as conversation_resolved_at, c.id as conversation_id, a.first_response_met_at, a.resolution_met_at, a.first_response_breached_at, a.resolution_breached_at
FROM applied_slas a
JOIN conversations c ON a.conversation_id = c.id and c.sla_policy_id = a.sla_policy_id
WHERE (first_response_breached_at IS NULL AND first_response_met_at IS NULL)
OR (resolution_breached_at IS NULL AND resolution_met_at IS NULL);
WHERE a.status = 'pending'::applied_sla_status;
-- name: update-breach
UPDATE applied_slas SET
@@ -66,8 +64,29 @@ UPDATE applied_slas SET
updated_at = NOW()
WHERE id = $1;
-- name: get-latest-sla-deadlines
SELECT first_response_deadline_at, resolution_deadline_at
FROM applied_slas
WHERE conversation_id = $1
ORDER BY created_at DESC LIMIT 1;
-- name: set-next-sla-deadline
UPDATE conversations c
SET next_sla_deadline_at = CASE
WHEN c.status_id IN (SELECT id from conversation_statuses where name in ('Resolved', 'Closed')) THEN NULL
WHEN c.first_reply_at IS NOT NULL AND c.resolved_at IS NULL AND a.resolution_deadline_at IS NOT NULL THEN a.resolution_deadline_at
WHEN c.first_reply_at IS NULL AND c.resolved_at IS NULL AND a.first_response_deadline_at IS NOT NULL THEN a.first_response_deadline_at
WHEN a.first_response_deadline_at IS NOT NULL AND a.resolution_deadline_at IS NOT NULL THEN LEAST(a.first_response_deadline_at, a.resolution_deadline_at)
ELSE NULL
END
FROM applied_slas a
WHERE a.conversation_id = c.id
AND c.id = $1;
-- name: update-sla-status
UPDATE applied_slas
SET
status = CASE
WHEN first_response_met_at IS NOT NULL AND resolution_met_at IS NOT NULL THEN 'met'::applied_sla_status
WHEN first_response_breached_at IS NOT NULL AND resolution_breached_at IS NOT NULL THEN 'breached'::applied_sla_status
WHEN (first_response_met_at IS NOT NULL OR first_response_breached_at IS NOT NULL)
AND (resolution_met_at IS NOT NULL OR resolution_breached_at IS NOT NULL) THEN 'partially_met'::applied_sla_status
WHEN first_response_met_at IS NULL AND first_response_breached_at IS NULL THEN 'pending'::applied_sla_status
ELSE 'pending'::applied_sla_status
END,
updated_at = NOW()
WHERE applied_slas.id = $1;

View File

@@ -2,7 +2,6 @@ package sla
import (
"context"
"database/sql"
"embed"
"encoding/json"
"fmt"
@@ -78,7 +77,8 @@ type queries struct {
GetPendingSLAs *sqlx.Stmt `query:"get-pending-slas"`
UpdateBreach *sqlx.Stmt `query:"update-breach"`
UpdateMet *sqlx.Stmt `query:"update-met"`
GetLatestDeadlines *sqlx.Stmt `query:"get-latest-sla-deadlines"`
SetNextSLADeadline *sqlx.Stmt `query:"set-next-sla-deadline"`
UpdateSLAStatus *sqlx.Stmt `query:"update-sla-status"`
}
// New creates a new SLA manager.
@@ -254,23 +254,14 @@ func (m *Manager) ApplySLA(startTime time.Time, conversationID, assignedTeamID,
return sla, nil
}
// GetLatestDeadlines returns the latest deadlines for a conversation.
func (m *Manager) GetLatestDeadlines(conversationID int) (time.Time, time.Time, error) {
var first, resolution time.Time
err := m.q.GetLatestDeadlines.QueryRow(conversationID).Scan(&first, &resolution)
if err == sql.ErrNoRows {
return first, resolution, nil
}
return first, resolution, err
}
// Run starts the SLA evaluation loop and evaluates pending SLAs.
func (m *Manager) Run(ctx context.Context, evalInterval time.Duration) {
m.wg.Add(1)
defer m.wg.Done()
ticker := time.NewTicker(evalInterval)
defer ticker.Stop()
m.wg.Add(1)
defer func() {
m.wg.Done()
ticker.Stop()
}()
for {
select {
@@ -315,23 +306,30 @@ func (m *Manager) evaluatePendingSLAs(ctx context.Context) error {
// evaluateSLA evaluates an SLA policy on an applied SLA.
func (m *Manager) evaluateSLA(sla models.AppliedSLA) error {
now := time.Now()
m.lo.Debug("evaluating SLA", "conversation_id", sla.ConversationID, "applied_sla_id", sla.ID)
checkDeadline := func(deadline time.Time, metAt null.Time, slaType string) error {
if deadline.IsZero() {
m.lo.Debug("deadline zero, skipping checking the deadline")
return nil
}
now := time.Now()
if !metAt.Valid && now.After(deadline) {
m.lo.Debug("SLA breached as current time is after deadline", "deadline", deadline, "now", now, "sla_type", slaType)
if _, err := m.q.UpdateBreach.Exec(sla.ID, slaType); err != nil {
return fmt.Errorf("updating SLA breach: %w", err)
}
return nil
}
if metAt.Valid {
if metAt.Time.After(deadline) {
m.lo.Debug("SLA breached as met_at is after deadline", "deadline", deadline, "met_at", metAt.Time, "sla_type", slaType)
if _, err := m.q.UpdateBreach.Exec(sla.ID, slaType); err != nil {
return fmt.Errorf("updating SLA breach: %w", err)
}
} else {
m.lo.Debug("SLA type met", "deadline", deadline, "met_at", metAt.Time, "sla_type", slaType)
if _, err := m.q.UpdateMet.Exec(sla.ID, slaType); err != nil {
return fmt.Errorf("updating SLA met: %w", err)
}
@@ -340,11 +338,31 @@ func (m *Manager) evaluateSLA(sla models.AppliedSLA) error {
return nil
}
if err := checkDeadline(sla.FirstResponseDeadlineAt, sla.FirstResponseAt, SLATypeFirstResponse); err != nil {
return err
// If first response is not breached and not met, check the deadline and set them.
if !sla.FirstResponseBreachedAt.Valid && !sla.FirstResponseMetAt.Valid {
m.lo.Debug("checking deadline", "deadline", sla.FirstResponseDeadlineAt, "met_at", sla.ConversationFirstResponseAt.Time, "sla_type", SLATypeFirstResponse)
if err := checkDeadline(sla.FirstResponseDeadlineAt, sla.ConversationFirstResponseAt, SLATypeFirstResponse); err != nil {
return err
}
}
if err := checkDeadline(sla.ResolutionDeadlineAt, sla.ResolvedAt, SLATypeResolution); err != nil {
return err
// If resolution is not breached and not met, check the deadine and set them.
if !sla.ResolutionBreachedAt.Valid && !sla.ResolutionMetAt.Valid {
m.lo.Debug("checking deadline", "deadline", sla.ResolutionDeadlineAt, "met_at", sla.ConversationResolvedAt.Time, "sla_type", SLATypeResolution)
if err := checkDeadline(sla.ResolutionDeadlineAt, sla.ConversationResolvedAt, SLATypeResolution); err != nil {
return err
}
}
// Update the conversation next SLA deadline.
if _, err := m.q.SetNextSLADeadline.Exec(sla.ConversationID); err != nil {
return fmt.Errorf("setting conversation next SLA deadline: %w", err)
}
// Update status of applied SLA.
if _, err := m.q.UpdateSLAStatus.Exec(sla.ID); err != nil {
return fmt.Errorf("updating applied SLA status: %w", err)
}
return nil
}