github.com/piotrnar/gocoin@v0.0.0-20240512203912-faa0448c5e96/lib/utxo/unspent_db.go (about) 1 package utxo 2 3 import ( 4 "bufio" 5 "bytes" 6 "encoding/binary" 7 "fmt" 8 "io" 9 "os" 10 "path/filepath" 11 "sync" 12 "sync/atomic" 13 "time" 14 15 "github.com/piotrnar/gocoin/lib/btc" 16 "github.com/piotrnar/gocoin/lib/others/sys" 17 "github.com/piotrnar/gocoin/lib/script" 18 ) 19 20 var ( 21 UTXO_WRITING_TIME_TARGET = 5 * time.Minute // Take it easy with flushing UTXO.db onto disk 22 UTXO_SKIP_SAVE_BLOCKS uint32 = 0 23 UTXO_PURGE_UNSPENDABLE bool = false 24 ) 25 26 var Memory_Malloc = func(le int) []byte { 27 return make([]byte, le) 28 } 29 30 var Memory_Free = func([]byte) { 31 } 32 33 type FunctionWalkUnspent func(*UtxoRec) 34 35 type CallbackFunctions struct { 36 // If NotifyTx is set, it will be called each time a new unspent 37 // output is being added or removed. When being removed, btc.TxOut is nil. 38 NotifyTxAdd func(*UtxoRec) 39 NotifyTxDel func(*UtxoRec, []bool) 40 } 41 42 // BlockChanges is used to pass block's changes to UnspentDB. 43 type BlockChanges struct { 44 Height uint32 45 LastKnownHeight uint32 // put here zero to disable this feature 46 AddList []*UtxoRec 47 DeledTxs map[[32]byte][]bool 48 UndoData map[[32]byte]*UtxoRec 49 } 50 51 type UnspentDB struct { 52 HashMap [256](map[UtxoKeyType][]byte) 53 MapMutex [256]sync.RWMutex // used to access HashMap 54 55 LastBlockHash []byte 56 LastBlockHeight uint32 57 ComprssedUTXO bool 58 dir_utxo, dir_undo string 59 volatimemode bool 60 UnwindBufLen uint32 61 DirtyDB sys.SyncBool 62 sync.Mutex 63 64 abortwritingnow chan bool 65 WritingInProgress sys.SyncBool 66 writingDone sync.WaitGroup 67 lastFileClosed sync.WaitGroup 68 69 CurrentHeightOnDisk uint32 70 hurryup chan bool 71 DoNotWriteUndoFiles bool 72 CB CallbackFunctions 73 74 undo_dir_created bool 75 } 76 77 type NewUnspentOpts struct { 78 Dir string 79 Rescan bool 80 VolatimeMode bool 81 CB CallbackFunctions 82 AbortNow *bool 83 CompressRecords bool 84 RecordsPrealloc uint 85 } 86 87 func NewUnspentDb(opts *NewUnspentOpts) (db *UnspentDB) { 88 //var maxbl_fn string 89 db = new(UnspentDB) 90 db.dir_utxo = opts.Dir 91 db.dir_undo = db.dir_utxo + "undo" + string(os.PathSeparator) 92 db.volatimemode = opts.VolatimeMode 93 db.UnwindBufLen = 2560 94 db.CB = opts.CB 95 db.abortwritingnow = make(chan bool, 1) 96 db.hurryup = make(chan bool, 1) 97 98 os.Remove(db.dir_undo + "tmp") // Remove unfinished undo file 99 if files, er := filepath.Glob(db.dir_utxo + "*.db.tmp"); er == nil { 100 for _, f := range files { 101 os.Remove(f) // Remove unfinished *.db.tmp files 102 } 103 } 104 105 db.ComprssedUTXO = opts.CompressRecords 106 if opts.Rescan { 107 for i := range db.HashMap { 108 db.HashMap[i] = make(map[UtxoKeyType][]byte, opts.RecordsPrealloc/256) 109 } 110 return 111 } 112 113 // Load data from disk 114 var k UtxoKeyType 115 var cnt_dwn, cnt_dwn_from, perc int 116 var le uint64 117 var u64, tot_recs uint64 118 var info string 119 var rd *bufio.Reader 120 var of *os.File 121 122 fname := "UTXO.db" 123 124 redo: 125 of, er := os.Open(db.dir_utxo + fname) 126 if er != nil { 127 goto fatal_error 128 } 129 130 rd = bufio.NewReaderSize(of, 0x40000) // read ahed buffer size 131 132 er = binary.Read(rd, binary.LittleEndian, &u64) 133 if er != nil { 134 goto fatal_error 135 } 136 db.LastBlockHeight = uint32(u64) 137 138 // If the highest bit of the block number is set, the UTXO records are compressed 139 db.ComprssedUTXO = (u64 & 0x8000000000000000) != 0 140 141 db.LastBlockHash = make([]byte, 32) 142 _, er = rd.Read(db.LastBlockHash) 143 if er != nil { 144 goto fatal_error 145 } 146 er = binary.Read(rd, binary.LittleEndian, &u64) 147 if er != nil { 148 goto fatal_error 149 } 150 151 //fmt.Println("Last block height", db.LastBlockHeight, " Number of records", u64) 152 cnt_dwn_from = int(u64 / 100) 153 perc = 0 154 155 for i := range db.HashMap { 156 db.HashMap[i] = make(map[UtxoKeyType][]byte, int(u64)/256) 157 } 158 if db.ComprssedUTXO { 159 info = fmt.Sprint("\rLoading ", u64, " compressed txs from ", fname, " - ") 160 } else { 161 info = fmt.Sprint("\rLoading ", u64, " plain txs from ", fname, " - ") 162 } 163 164 for tot_recs = 0; tot_recs < u64; tot_recs++ { 165 if opts.AbortNow != nil && *opts.AbortNow { 166 break 167 } 168 le, er = btc.ReadVLen(rd) 169 if er != nil { 170 goto fatal_error 171 } 172 173 _, er = io.ReadFull(rd, k[:]) 174 if er != nil { 175 goto fatal_error 176 } 177 178 b := Memory_Malloc(int(le) - UtxoIdxLen) 179 _, er = io.ReadFull(rd, b) 180 if er != nil { 181 goto fatal_error 182 } 183 184 // we don't lock RWMutex here as this code is only used during init phase, when no other routines are running 185 db.HashMap[k[0]][k] = b 186 187 if cnt_dwn == 0 { 188 fmt.Print(info, perc, "% complete ... ") 189 perc++ 190 cnt_dwn = cnt_dwn_from 191 } else { 192 cnt_dwn-- 193 } 194 } 195 of.Close() 196 197 fmt.Print("\r \r") 198 199 atomic.StoreUint32(&db.CurrentHeightOnDisk, db.LastBlockHeight) 200 if db.ComprssedUTXO { 201 FullUtxoRec = FullUtxoRecC 202 NewUtxoRecStatic = NewUtxoRecStaticC 203 NewUtxoRec = NewUtxoRecC 204 OneUtxoRec = OneUtxoRecC 205 Serialize = SerializeC 206 } 207 208 return 209 210 fatal_error: 211 if of != nil { 212 of.Close() 213 } 214 215 println(er.Error()) 216 if fname != "UTXO.old" { 217 fname = "UTXO.old" 218 goto redo 219 } 220 db.LastBlockHeight = 0 221 db.LastBlockHash = nil 222 for i := range db.HashMap { 223 db.HashMap[i] = make(map[UtxoKeyType][]byte, opts.RecordsPrealloc/256) 224 } 225 226 return 227 } 228 229 func (db *UnspentDB) save() { 230 //var cnt_dwn, cnt_dwn_from, perc int 231 var abort, hurryup, check_time bool 232 var total_records, current_record, data_progress, time_progress int64 233 234 const save_buffer_min = 0x10000 // write in chunks of ~64KB 235 const save_buffer_cnt = 100 236 237 os.Rename(db.dir_utxo+"UTXO.db", db.dir_utxo+"UTXO.old") 238 data_channel := make(chan []byte, save_buffer_cnt) 239 exit_channel := make(chan bool, 1) 240 241 start_time := time.Now() 242 243 for _i := range db.HashMap { 244 total_records += int64(len(db.HashMap[_i])) 245 } 246 247 buf := bytes.NewBuffer(make([]byte, 0, save_buffer_min+0x1000)) // add 4K extra for the last record (it will still be able to grow over it) 248 u64 := uint64(db.LastBlockHeight) 249 if db.ComprssedUTXO { 250 u64 |= 0x8000000000000000 251 } 252 binary.Write(buf, binary.LittleEndian, u64) 253 buf.Write(db.LastBlockHash) 254 binary.Write(buf, binary.LittleEndian, uint64(total_records)) 255 256 // The data is written in a separate process 257 // so we can abort without waiting for disk. 258 db.lastFileClosed.Add(1) 259 go func(fname string) { 260 of_, er := os.Create(fname) 261 if er != nil { 262 println("Create file:", er.Error()) 263 return 264 } 265 266 of := bufio.NewWriter(of_) 267 268 var dat []byte 269 var abort, exit bool 270 271 for !exit || len(data_channel) > 0 { 272 select { 273 274 case dat = <-data_channel: 275 if len(exit_channel) > 0 { 276 if abort = <-exit_channel; abort { 277 goto exit 278 } else { 279 exit = true 280 } 281 } 282 of.Write(dat) 283 284 case abort = <-exit_channel: 285 if abort { 286 goto exit 287 } else { 288 exit = true 289 } 290 } 291 } 292 exit: 293 if abort { 294 of_.Close() // abort 295 os.Remove(fname) 296 } else { 297 of.Flush() 298 of_.Close() 299 os.Rename(fname, db.dir_utxo+"UTXO.db") 300 } 301 db.lastFileClosed.Done() 302 }(db.dir_utxo + btc.NewUint256(db.LastBlockHash).String() + ".db.tmp") 303 304 if UTXO_WRITING_TIME_TARGET == 0 { 305 hurryup = true 306 } 307 for _i := range db.HashMap { 308 db.MapMutex[_i].RLock() 309 defer db.MapMutex[_i].RUnlock() 310 for k, v := range db.HashMap[_i] { 311 if check_time { 312 check_time = false 313 data_progress = int64(current_record<<20) / int64(total_records) 314 time_progress = int64(time.Now().Sub(start_time)<<20) / int64(UTXO_WRITING_TIME_TARGET) 315 if data_progress > time_progress { 316 select { 317 case <-db.abortwritingnow: 318 abort = true 319 goto finito 320 case <-db.hurryup: 321 hurryup = true 322 case <-time.After((time.Duration(data_progress-time_progress) * UTXO_WRITING_TIME_TARGET) >> 20): 323 } 324 } 325 } 326 327 for len(data_channel) >= cap(data_channel) { 328 select { 329 case <-db.abortwritingnow: 330 abort = true 331 goto finito 332 case <-db.hurryup: 333 hurryup = true 334 case <-time.After(time.Millisecond): 335 } 336 } 337 338 btc.WriteVlen(buf, uint64(UtxoIdxLen+len(v))) 339 buf.Write(k[:]) 340 buf.Write(v) 341 if buf.Len() >= save_buffer_min { 342 data_channel <- buf.Bytes() 343 if !hurryup { 344 check_time = true 345 } 346 buf = bytes.NewBuffer(make([]byte, 0, save_buffer_min+0x1000)) // add 4K extra for the last record 347 } 348 349 current_record++ 350 } 351 } 352 finito: 353 354 if !abort && buf.Len() > 0 { 355 data_channel <- buf.Bytes() 356 } 357 exit_channel <- abort 358 359 if !abort { 360 db.DirtyDB.Clr() 361 //println("utxo written OK in", time.Now().Sub(start_time).String(), timewaits) 362 atomic.StoreUint32(&db.CurrentHeightOnDisk, db.LastBlockHeight) 363 } 364 db.WritingInProgress.Clr() 365 db.writingDone.Done() 366 } 367 368 // CommitBlockTxs commits the given add/del transactions to UTXO and Unwind DBs. 369 func (db *UnspentDB) CommitBlockTxs(changes *BlockChanges, blhash []byte) (e error) { 370 var wg sync.WaitGroup 371 372 undo_fn := fmt.Sprint(db.dir_undo, changes.Height) 373 374 db.Mutex.Lock() 375 defer db.Mutex.Unlock() 376 db.abortWriting() 377 378 if changes.UndoData != nil { 379 wg.Add(1) 380 go func() { 381 var tmp [0x100000]byte // static record for Serialize to serialize to 382 bu := new(bytes.Buffer) 383 bu.Write(blhash) 384 if changes.UndoData != nil { 385 for _, xx := range changes.UndoData { 386 bin := Serialize(xx, true, tmp[:]) 387 btc.WriteVlen(bu, uint64(len(bin))) 388 bu.Write(bin) 389 } 390 } 391 if !db.undo_dir_created { // (try to) create undo folder before writing the first file 392 os.MkdirAll(db.dir_undo, 0770) 393 db.undo_dir_created = true 394 } 395 os.WriteFile(db.dir_undo+"tmp", bu.Bytes(), 0666) 396 os.Rename(db.dir_undo+"tmp", undo_fn) 397 wg.Done() 398 }() 399 } 400 401 db.commit(changes) 402 403 if db.LastBlockHash == nil { 404 db.LastBlockHash = make([]byte, 32) 405 } 406 copy(db.LastBlockHash, blhash) 407 db.LastBlockHeight = changes.Height 408 409 if changes.Height > db.UnwindBufLen { 410 os.Remove(fmt.Sprint(db.dir_undo, changes.Height-db.UnwindBufLen)) 411 } 412 413 db.DirtyDB.Set() 414 wg.Wait() 415 return 416 } 417 418 func (db *UnspentDB) UndoBlockTxs(bl *btc.Block, newhash []byte) { 419 db.Mutex.Lock() 420 defer db.Mutex.Unlock() 421 db.abortWriting() 422 423 for _, tx := range bl.Txs { 424 lst := make([]bool, len(tx.TxOut)) 425 for i := range lst { 426 lst[i] = true 427 } 428 db.del(tx.Hash.Hash[:], lst) 429 } 430 431 fn := fmt.Sprint(db.dir_undo, db.LastBlockHeight) 432 var addback []*UtxoRec 433 434 if _, er := os.Stat(fn); er != nil { 435 fn += ".tmp" 436 } 437 438 dat, er := os.ReadFile(fn) 439 if er != nil { 440 panic(er.Error()) 441 } 442 443 off := 32 // ship the block hash 444 for off < len(dat) { 445 le, n := btc.VLen(dat[off:]) 446 off += n 447 qr := FullUtxoRec(dat[off : off+le]) 448 off += le 449 addback = append(addback, qr) 450 } 451 452 for _, rec := range addback { 453 if db.CB.NotifyTxAdd != nil { 454 db.CB.NotifyTxAdd(rec) 455 } 456 457 var ind UtxoKeyType 458 copy(ind[:], rec.TxID[:]) 459 db.MapMutex[ind[0]].RLock() 460 v := db.HashMap[ind[0]][ind] 461 db.MapMutex[ind[0]].RUnlock() 462 if v != nil { 463 oldrec := NewUtxoRec(ind, v) 464 for a := range rec.Outs { 465 if rec.Outs[a] == nil { 466 rec.Outs[a] = oldrec.Outs[a] 467 } 468 } 469 } 470 db.MapMutex[ind[0]].Lock() 471 db.HashMap[ind[0]][ind] = Serialize(rec, false, nil) 472 db.MapMutex[ind[0]].Unlock() 473 } 474 475 os.Remove(fn) 476 db.LastBlockHeight-- 477 copy(db.LastBlockHash, newhash) 478 db.DirtyDB.Set() 479 } 480 481 // Idle should be called when the main thread is idle. 482 func (db *UnspentDB) Idle() bool { 483 if db.volatimemode { 484 return false 485 } 486 487 db.Mutex.Lock() 488 defer db.Mutex.Unlock() 489 490 if db.DirtyDB.Get() && db.LastBlockHeight-atomic.LoadUint32(&db.CurrentHeightOnDisk) > UTXO_SKIP_SAVE_BLOCKS { 491 return db.Save() 492 } 493 494 return false 495 } 496 497 func (db *UnspentDB) Save() bool { 498 if db.WritingInProgress.Get() { 499 return false 500 } 501 db.WritingInProgress.Set() 502 db.writingDone.Add(1) 503 go db.save() // this one will call db.writingDone.Done() 504 return true 505 } 506 507 func (db *UnspentDB) HurryUp() { 508 select { 509 case db.hurryup <- true: 510 default: 511 } 512 } 513 514 // Close flushes the data and closes all the files. 515 func (db *UnspentDB) Close() { 516 db.volatimemode = false 517 if db.DirtyDB.Get() { 518 db.HurryUp() 519 db.Save() 520 } 521 db.writingDone.Wait() 522 db.lastFileClosed.Wait() 523 } 524 525 // UnspentGet gets the given unspent output. 526 func (db *UnspentDB) UnspentGet(po *btc.TxPrevOut) (res *btc.TxOut) { 527 var ind UtxoKeyType 528 var v []byte 529 copy(ind[:], po.Hash[:]) 530 531 db.MapMutex[ind[0]].RLock() 532 v = db.HashMap[ind[0]][ind] 533 db.MapMutex[ind[0]].RUnlock() 534 if v != nil { 535 res = OneUtxoRec(ind, v, po.Vout) 536 } 537 538 return 539 } 540 541 // TxPresent returns true if gived TXID is in UTXO. 542 func (db *UnspentDB) TxPresent(id *btc.Uint256) (res bool) { 543 var ind UtxoKeyType 544 copy(ind[:], id.Hash[:]) 545 db.MapMutex[ind[0]].RLock() 546 _, res = db.HashMap[ind[0]][ind] 547 db.MapMutex[ind[0]].RUnlock() 548 return 549 } 550 551 func (db *UnspentDB) del(hash []byte, outs []bool) { 552 var ind UtxoKeyType 553 copy(ind[:], hash) 554 db.MapMutex[ind[0]].RLock() 555 v := db.HashMap[ind[0]][ind] 556 db.MapMutex[ind[0]].RUnlock() 557 if v == nil { 558 return // no such txid in UTXO (just ignorde delete request) 559 } 560 rec := NewUtxoRec(ind, v) 561 if db.CB.NotifyTxDel != nil { 562 db.CB.NotifyTxDel(rec, outs) 563 } 564 var anyout bool 565 for i, rm := range outs { 566 if rm || UTXO_PURGE_UNSPENDABLE && rec.Outs[i] != nil && script.IsUnspendable(rec.Outs[i].PKScr) { 567 rec.Outs[i] = nil 568 } else if !anyout && rec.Outs[i] != nil { 569 anyout = true 570 } 571 } 572 db.MapMutex[ind[0]].Lock() 573 if anyout { 574 db.HashMap[ind[0]][ind] = Serialize(rec, false, nil) 575 } else { 576 delete(db.HashMap[ind[0]], ind) 577 } 578 db.MapMutex[ind[0]].Unlock() 579 Memory_Free(v) 580 } 581 582 func (db *UnspentDB) commit(changes *BlockChanges) { 583 var wg sync.WaitGroup 584 // Now aplly the unspent changes 585 for _, rec := range changes.AddList { 586 var ind UtxoKeyType 587 copy(ind[:], rec.TxID[:]) 588 if db.CB.NotifyTxAdd != nil { 589 db.CB.NotifyTxAdd(rec) 590 } 591 var add_this_tx bool 592 if UTXO_PURGE_UNSPENDABLE { 593 for idx, r := range rec.Outs { 594 if r != nil { 595 if script.IsUnspendable(r.PKScr) { 596 rec.Outs[idx] = nil 597 } else { 598 add_this_tx = true 599 } 600 } 601 } 602 } else { 603 add_this_tx = true 604 } 605 if add_this_tx { 606 wg.Add(1) 607 go func(ind UtxoKeyType, rec *UtxoRec) { 608 v := Serialize(rec, false, nil) 609 db.MapMutex[ind[0]].Lock() 610 db.HashMap[ind[0]][ind] = v 611 db.MapMutex[ind[0]].Unlock() 612 wg.Done() 613 }(ind, rec) 614 } 615 } 616 for k, v := range changes.DeledTxs { 617 wg.Add(1) 618 go func(k [32]byte, v []bool) { 619 db.del(k[:], v) 620 wg.Done() 621 }(k, v) 622 } 623 wg.Wait() 624 } 625 626 func (db *UnspentDB) AbortWriting() { 627 db.Mutex.Lock() 628 db.abortWriting() 629 db.Mutex.Unlock() 630 } 631 632 func (db *UnspentDB) abortWriting() { 633 if db.WritingInProgress.Get() { 634 db.abortwritingnow <- true 635 db.writingDone.Wait() 636 select { 637 case <-db.abortwritingnow: 638 default: 639 } 640 } 641 } 642 643 func (db *UnspentDB) UTXOStats() (s string) { 644 var outcnt, sum, sumcb uint64 645 var filesize, unspendable, unspendable_recs, unspendable_bytes uint64 646 647 filesize = 8 + 32 + 8 // UTXO.db: block_no + block_hash + rec_cnt 648 649 var lele int 650 651 for _i := range db.HashMap { 652 db.MapMutex[_i].RLock() 653 lele += len(db.HashMap[_i]) 654 for k, v := range db.HashMap[_i] { 655 reclen := uint64(len(v) + UtxoIdxLen) 656 filesize += uint64(btc.VLenSize(reclen)) 657 filesize += reclen 658 rec := NewUtxoRecStatic(k, v) 659 var spendable_found bool 660 for _, r := range rec.Outs { 661 if r != nil { 662 outcnt++ 663 sum += r.Value 664 if rec.Coinbase { 665 sumcb += r.Value 666 } 667 if script.IsUnspendable(r.PKScr) { 668 unspendable++ 669 unspendable_bytes += uint64(8 + len(r.PKScr)) 670 } else { 671 spendable_found = true 672 } 673 } 674 } 675 if !spendable_found { 676 unspendable_recs++ 677 } 678 } 679 db.MapMutex[_i].RUnlock() 680 } 681 682 s = fmt.Sprintf("UNSPENT: %.8f BTC in %d outs from %d txs. %.8f BTC in coinbase.\n", 683 float64(sum)/1e8, outcnt, lele, float64(sumcb)/1e8) 684 s += fmt.Sprintf(" MaxTxOutCnt: %d DirtyDB: %t Writing: %t Abort: %t Compressed: %t\n", 685 len(rec_outs), db.DirtyDB.Get(), db.WritingInProgress.Get(), len(db.abortwritingnow) > 0, 686 db.ComprssedUTXO) 687 s += fmt.Sprintf(" Last Block : %s @ %d\n", btc.NewUint256(db.LastBlockHash).String(), 688 db.LastBlockHeight) 689 s += fmt.Sprintf(" Unspendable Outputs: %d (%dKB) txs:%d UTXO.db file size: %d\n", 690 unspendable, unspendable_bytes>>10, unspendable_recs, filesize) 691 692 return 693 } 694 695 // GetStats returns DB statistics. 696 func (db *UnspentDB) GetStats() (s string) { 697 var hml int 698 for i := range db.HashMap { 699 db.MapMutex[i].RLock() 700 hml += len(db.HashMap[i]) 701 db.MapMutex[i].RUnlock() 702 } 703 704 s = fmt.Sprintf("UNSPENT: %d txs. MaxCnt:%d Dirt:%t Writ:%t Abort:%t Compr:%t\n", 705 hml, len(rec_outs), db.DirtyDB.Get(), db.WritingInProgress.Get(), 706 len(db.abortwritingnow) > 0, db.ComprssedUTXO) 707 s += fmt.Sprintf(" Last Block : %s @ %d\n", btc.NewUint256(db.LastBlockHash).String(), 708 db.LastBlockHeight) 709 return 710 } 711 712 func (db *UnspentDB) PurgeUnspendable(all bool) { 713 var unspendable_txs, unspendable_recs uint64 714 db.Mutex.Lock() 715 db.abortWriting() 716 717 for _i := range db.HashMap { 718 db.MapMutex[_i].Lock() 719 for k, v := range db.HashMap[_i] { 720 rec := NewUtxoRecStatic(k, v) 721 var spendable_found bool 722 var record_removed uint64 723 for idx, r := range rec.Outs { 724 if r != nil { 725 if script.IsUnspendable(r.PKScr) { 726 if all { 727 rec.Outs[idx] = nil 728 record_removed++ 729 } 730 } else { 731 spendable_found = true 732 } 733 } 734 } 735 if !spendable_found { 736 Memory_Free(v) 737 delete(db.HashMap[k[0]], k) 738 unspendable_txs++ 739 } else if record_removed > 0 { 740 db.HashMap[k[0]][k] = Serialize(rec, false, nil) 741 Memory_Free(v) 742 unspendable_recs += record_removed 743 } 744 } 745 db.MapMutex[_i].Unlock() 746 } 747 748 db.Mutex.Unlock() 749 750 fmt.Println("Purged", unspendable_txs, "transactions and", unspendable_recs, "extra records") 751 }