ce13331e26
Replace in-process generation with queue-based async execution via RabbitMQ. Add generation task runtime for lifecycle management, article generation payload/queue abstractions, and template-assist queue publisher. Refactor prompt generation service to support batch generation with per-item error tracking. Update stream hub to bridge queue events to SSE. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
417 lines
9.4 KiB
Go
417 lines
9.4 KiB
Go
package stream
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/geo-platform/tenant-api/internal/shared/messaging/rabbitmq"
|
|
)
|
|
|
|
type GenerationEvent struct {
|
|
Type string `json:"type"`
|
|
ArticleID int64 `json:"article_id"`
|
|
TaskID int64 `json:"task_id"`
|
|
Title string `json:"title,omitempty"`
|
|
Status string `json:"status,omitempty"`
|
|
Delta string `json:"delta,omitempty"`
|
|
Content string `json:"content,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
Done bool `json:"done,omitempty"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
type generationEventEnvelope struct {
|
|
Source string `json:"source"`
|
|
Event GenerationEvent `json:"event"`
|
|
}
|
|
|
|
type generationStream struct {
|
|
snapshot GenerationEvent
|
|
subscribers map[int]chan GenerationEvent
|
|
nextSubscriber int
|
|
completed bool
|
|
}
|
|
|
|
type GenerationHub struct {
|
|
mu sync.Mutex
|
|
streams map[int64]*generationStream
|
|
rabbitMQ *rabbitmq.Client
|
|
instanceID string
|
|
consumerName string
|
|
}
|
|
|
|
func NewGenerationHub(rabbitMQClient *rabbitmq.Client) *GenerationHub {
|
|
instanceID := fmt.Sprintf("%d-%d", os.Getpid(), time.Now().UnixNano())
|
|
return &GenerationHub{
|
|
streams: make(map[int64]*generationStream),
|
|
rabbitMQ: rabbitMQClient,
|
|
instanceID: instanceID,
|
|
consumerName: "generation-stream-" + instanceID,
|
|
}
|
|
}
|
|
|
|
func (h *GenerationHub) Run(ctx context.Context) {
|
|
if h == nil || h.rabbitMQ == nil {
|
|
return
|
|
}
|
|
go h.run(ctx)
|
|
}
|
|
|
|
func (h *GenerationHub) Seed(event GenerationEvent) {
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
|
|
state := h.ensureLocked(event.ArticleID)
|
|
applySnapshotLocked(state, event)
|
|
if state.completed && len(state.subscribers) == 0 {
|
|
delete(h.streams, event.ArticleID)
|
|
}
|
|
}
|
|
|
|
func (h *GenerationHub) StartEvent(articleID, taskID int64, title string) {
|
|
h.publish(articleID, func(state *generationStream) GenerationEvent {
|
|
now := time.Now()
|
|
state.completed = false
|
|
state.snapshot = GenerationEvent{
|
|
Type: "snapshot",
|
|
ArticleID: articleID,
|
|
TaskID: taskID,
|
|
Title: title,
|
|
Status: "generating",
|
|
UpdatedAt: now,
|
|
}
|
|
return GenerationEvent{
|
|
Type: "status",
|
|
ArticleID: articleID,
|
|
TaskID: taskID,
|
|
Title: title,
|
|
Status: "generating",
|
|
UpdatedAt: now,
|
|
}
|
|
})
|
|
}
|
|
|
|
func (h *GenerationHub) Start(articleID, taskID int64, title string) {
|
|
h.StartEvent(articleID, taskID, title)
|
|
}
|
|
|
|
func (h *GenerationHub) AppendDelta(articleID, taskID int64, title, delta string) {
|
|
h.publish(articleID, func(state *generationStream) GenerationEvent {
|
|
now := time.Now()
|
|
state.snapshot.Type = "snapshot"
|
|
state.snapshot.ArticleID = articleID
|
|
state.snapshot.TaskID = taskID
|
|
state.snapshot.Title = title
|
|
state.snapshot.Status = "generating"
|
|
state.snapshot.Content += delta
|
|
state.snapshot.UpdatedAt = now
|
|
state.completed = false
|
|
|
|
return GenerationEvent{
|
|
Type: "delta",
|
|
ArticleID: articleID,
|
|
TaskID: taskID,
|
|
Title: title,
|
|
Status: "generating",
|
|
Delta: delta,
|
|
Content: state.snapshot.Content,
|
|
UpdatedAt: now,
|
|
}
|
|
})
|
|
}
|
|
|
|
func (h *GenerationHub) Complete(articleID, taskID int64, title, content string) {
|
|
h.publish(articleID, func(state *generationStream) GenerationEvent {
|
|
now := time.Now()
|
|
state.completed = true
|
|
state.snapshot = GenerationEvent{
|
|
Type: "snapshot",
|
|
ArticleID: articleID,
|
|
TaskID: taskID,
|
|
Title: title,
|
|
Status: "completed",
|
|
Content: content,
|
|
Done: true,
|
|
UpdatedAt: now,
|
|
}
|
|
|
|
return GenerationEvent{
|
|
Type: "completed",
|
|
ArticleID: articleID,
|
|
TaskID: taskID,
|
|
Title: title,
|
|
Status: "completed",
|
|
Content: content,
|
|
Done: true,
|
|
UpdatedAt: now,
|
|
}
|
|
})
|
|
}
|
|
|
|
func (h *GenerationHub) Fail(articleID, taskID int64, title, errMsg string) {
|
|
h.publish(articleID, func(state *generationStream) GenerationEvent {
|
|
now := time.Now()
|
|
state.completed = true
|
|
state.snapshot.Type = "snapshot"
|
|
state.snapshot.ArticleID = articleID
|
|
state.snapshot.TaskID = taskID
|
|
state.snapshot.Title = title
|
|
state.snapshot.Status = "failed"
|
|
state.snapshot.Error = errMsg
|
|
state.snapshot.Done = true
|
|
state.snapshot.UpdatedAt = now
|
|
|
|
return GenerationEvent{
|
|
Type: "error",
|
|
ArticleID: articleID,
|
|
TaskID: taskID,
|
|
Title: title,
|
|
Status: "failed",
|
|
Error: errMsg,
|
|
Done: true,
|
|
UpdatedAt: now,
|
|
}
|
|
})
|
|
}
|
|
|
|
func (h *GenerationHub) Subscribe(articleID int64) (<-chan GenerationEvent, *GenerationEvent, func()) {
|
|
h.mu.Lock()
|
|
state := h.streams[articleID]
|
|
if state == nil {
|
|
h.mu.Unlock()
|
|
return nil, nil, func() {}
|
|
}
|
|
|
|
id := state.nextSubscriber
|
|
state.nextSubscriber++
|
|
|
|
ch := make(chan GenerationEvent, 64)
|
|
state.subscribers[id] = ch
|
|
snapshot := state.snapshot
|
|
h.mu.Unlock()
|
|
|
|
cancel := func() {
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
|
|
current := h.streams[articleID]
|
|
if current == nil {
|
|
close(ch)
|
|
return
|
|
}
|
|
|
|
delete(current.subscribers, id)
|
|
shouldDelete := current.completed && len(current.subscribers) == 0
|
|
if shouldDelete {
|
|
delete(h.streams, articleID)
|
|
}
|
|
|
|
close(ch)
|
|
}
|
|
|
|
return ch, &snapshot, cancel
|
|
}
|
|
|
|
func (h *GenerationHub) Snapshot(articleID int64) (*GenerationEvent, bool) {
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
|
|
state := h.streams[articleID]
|
|
if state == nil {
|
|
return nil, false
|
|
}
|
|
|
|
snapshot := state.snapshot
|
|
return &snapshot, true
|
|
}
|
|
|
|
func (h *GenerationHub) run(ctx context.Context) {
|
|
for {
|
|
deliveries, ch, err := h.rabbitMQ.ConsumeGenerationEvents(h.consumerName)
|
|
if err != nil {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-time.After(2 * time.Second):
|
|
continue
|
|
}
|
|
}
|
|
|
|
closed := false
|
|
for !closed {
|
|
select {
|
|
case <-ctx.Done():
|
|
_ = ch.Close()
|
|
return
|
|
case delivery, ok := <-deliveries:
|
|
if !ok {
|
|
closed = true
|
|
continue
|
|
}
|
|
h.handleDelivery(delivery.Body)
|
|
_ = delivery.Ack(false)
|
|
}
|
|
}
|
|
_ = ch.Close()
|
|
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-time.After(2 * time.Second):
|
|
}
|
|
}
|
|
}
|
|
|
|
func (h *GenerationHub) handleDelivery(body []byte) {
|
|
var envelope generationEventEnvelope
|
|
if err := json.Unmarshal(body, &envelope); err != nil {
|
|
return
|
|
}
|
|
if envelope.Source == h.instanceID {
|
|
return
|
|
}
|
|
|
|
h.mu.Lock()
|
|
state := h.streams[envelope.Event.ArticleID]
|
|
if state == nil {
|
|
h.mu.Unlock()
|
|
return
|
|
}
|
|
|
|
applyRemoteEventLocked(state, envelope.Event)
|
|
subscribers := collectSubscribersLocked(state)
|
|
shouldDelete := state.completed && len(state.subscribers) == 0
|
|
if shouldDelete {
|
|
delete(h.streams, envelope.Event.ArticleID)
|
|
}
|
|
h.mu.Unlock()
|
|
|
|
for _, ch := range subscribers {
|
|
select {
|
|
case ch <- envelope.Event:
|
|
default:
|
|
}
|
|
}
|
|
}
|
|
|
|
func (h *GenerationHub) publish(articleID int64, mutate func(*generationStream) GenerationEvent) {
|
|
h.mu.Lock()
|
|
state := h.ensureLocked(articleID)
|
|
event := mutate(state)
|
|
subscribers := collectSubscribersLocked(state)
|
|
shouldDelete := state.completed && len(state.subscribers) == 0
|
|
if shouldDelete {
|
|
delete(h.streams, articleID)
|
|
}
|
|
h.mu.Unlock()
|
|
|
|
for _, ch := range subscribers {
|
|
select {
|
|
case ch <- event:
|
|
default:
|
|
}
|
|
}
|
|
|
|
if h.rabbitMQ != nil {
|
|
payload, err := json.Marshal(generationEventEnvelope{
|
|
Source: h.instanceID,
|
|
Event: event,
|
|
})
|
|
if err == nil {
|
|
_ = h.rabbitMQ.PublishGenerationEvent(context.Background(), payload)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (h *GenerationHub) ensureLocked(articleID int64) *generationStream {
|
|
state := h.streams[articleID]
|
|
if state != nil {
|
|
return state
|
|
}
|
|
|
|
state = &generationStream{
|
|
subscribers: make(map[int]chan GenerationEvent),
|
|
}
|
|
h.streams[articleID] = state
|
|
return state
|
|
}
|
|
|
|
func collectSubscribersLocked(state *generationStream) []chan GenerationEvent {
|
|
subscribers := make([]chan GenerationEvent, 0, len(state.subscribers))
|
|
for _, ch := range state.subscribers {
|
|
subscribers = append(subscribers, ch)
|
|
}
|
|
return subscribers
|
|
}
|
|
|
|
func applySnapshotLocked(state *generationStream, event GenerationEvent) {
|
|
state.snapshot = GenerationEvent{
|
|
Type: "snapshot",
|
|
ArticleID: event.ArticleID,
|
|
TaskID: event.TaskID,
|
|
Title: event.Title,
|
|
Status: event.Status,
|
|
Content: event.Content,
|
|
Error: event.Error,
|
|
Done: event.Done,
|
|
UpdatedAt: event.UpdatedAt,
|
|
}
|
|
state.completed = event.Done
|
|
}
|
|
|
|
func applyRemoteEventLocked(state *generationStream, event GenerationEvent) {
|
|
switch event.Type {
|
|
case "delta":
|
|
state.completed = false
|
|
state.snapshot.Type = "snapshot"
|
|
state.snapshot.ArticleID = event.ArticleID
|
|
state.snapshot.TaskID = event.TaskID
|
|
state.snapshot.Title = event.Title
|
|
state.snapshot.Status = event.Status
|
|
if event.Content != "" {
|
|
state.snapshot.Content = event.Content
|
|
} else {
|
|
state.snapshot.Content += event.Delta
|
|
}
|
|
state.snapshot.UpdatedAt = event.UpdatedAt
|
|
case "completed":
|
|
applySnapshotLocked(state, GenerationEvent{
|
|
Type: "snapshot",
|
|
ArticleID: event.ArticleID,
|
|
TaskID: event.TaskID,
|
|
Title: event.Title,
|
|
Status: "completed",
|
|
Content: event.Content,
|
|
Done: true,
|
|
UpdatedAt: event.UpdatedAt,
|
|
})
|
|
case "error":
|
|
applySnapshotLocked(state, GenerationEvent{
|
|
Type: "snapshot",
|
|
ArticleID: event.ArticleID,
|
|
TaskID: event.TaskID,
|
|
Title: event.Title,
|
|
Status: "failed",
|
|
Error: event.Error,
|
|
Done: true,
|
|
UpdatedAt: event.UpdatedAt,
|
|
})
|
|
default:
|
|
applySnapshotLocked(state, GenerationEvent{
|
|
Type: "snapshot",
|
|
ArticleID: event.ArticleID,
|
|
TaskID: event.TaskID,
|
|
Title: event.Title,
|
|
Status: event.Status,
|
|
Content: event.Content,
|
|
Error: event.Error,
|
|
Done: event.Done,
|
|
UpdatedAt: event.UpdatedAt,
|
|
})
|
|
}
|
|
}
|